diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 94873081..ca3d537d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,14 +8,12 @@ updates: runtime-minors: patterns: ["*"] update-types: ["minor", "patch"] - open-pull-requests-limit: 10 + open-pull-requests-limit: 5 rebase-strategy: "auto" commit-message: prefix: "😨" labels: - "python-dependencies" - cooldown: - default-days: 7 - package-ecosystem: "github-actions" directory: "/" @@ -31,6 +29,4 @@ updates: prefix: "😨" labels: - "github-actions-dependencies" - cooldown: - default-days: 7 diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml index e7582f4a..839776ce 100644 --- a/.github/workflows/build_docs.yml +++ b/.github/workflows/build_docs.yml @@ -14,7 +14,7 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v5 with: fetch-depth: 0 persist-credentials: true @@ -32,14 +32,15 @@ jobs: VERSION=$(grep -m1 '^version' pyproject.toml | cut -d '"' -f2 | cut -d '.' -f 1,2) echo "VERSION=$VERSION" >> "$GITHUB_ENV" - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v6 - name: Set up Python run: uv python install ${{ vars.PYTHON_VERSION }} - name: Set up venv run: | - uv sync --group docs --no-default-groups + uv venv -p ${{ vars.PYTHON_VERSION }} + uv pip install '.[docs]' - name: Run Mike build run: | diff --git a/.github/workflows/doc_examples.yml b/.github/workflows/doc_examples.yml index d6e186af..8c3263a5 100644 --- a/.github/workflows/doc_examples.yml +++ b/.github/workflows/doc_examples.yml @@ -14,8 +14,8 @@ jobs: RF_ASI_TOKEN: ${{ secrets.RF_ASI_TOKEN }} steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 - name: Install GNU parallel run: sudo apt-get update && sudo apt-get install -y parallel @@ -25,7 +25,8 @@ jobs: - name: Set up venv run: | - uv sync --group docs --no-default-groups + uv venv -p ${{ vars.PYTHON_VERSION }} + uv pip install '.[docs]' - name: Run Examples shell: bash diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 2c154b70..bc7ac1c2 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v5 - name: Run ruff check uses: astral-sh/ruff-action@v3 diff --git a/.github/workflows/import_checks.yml b/.github/workflows/import_checks.yml index 3e585a05..477c960e 100644 --- a/.github/workflows/import_checks.yml +++ b/.github/workflows/import_checks.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v5 - name: Check imports run: | if matches=$(grep -RIn --line-number -E '^from[[:space:]]+psengine' psengine) ; then diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 77968b94..013cf83a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v5 - name: Run ruff check uses: astral-sh/ruff-action@v3 diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 3aa1444a..2a6b8482 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -16,10 +16,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v5 - name: Spell Check Repo - uses: crate-ci/typos@v1.45.2 + uses: crate-ci/typos@v1.35.5 with: files: | ./psengine/ diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 48c80f12..65238f2f 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -27,15 +27,17 @@ jobs: MIN_COVERAGE: 94 steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 - name: Set up Python run: uv python install ${{ matrix.python_version }} - name: Set up venv - run: uv sync - + run: | + uv venv -p ${{ matrix.python_version }} + uv pip install '.[dev]' + - name: Run tests without config run: | uv run -p ${{ matrix.python_version}} pytest tests_without_config @@ -55,7 +57,7 @@ jobs: - name: Upload coverage HTML if: always() id: cov_html - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: coverage-html-${{ matrix.python_version }} path: htmlcov @@ -64,7 +66,7 @@ jobs: - name: Comment link to coverage HTML if: ${{ always() && github.event_name == 'pull_request' }} - uses: mshick/add-pr-comment@v3 + uses: mshick/add-pr-comment@v2 with: message: | Coverage HTML (Python ${{ matrix.python_version }}): ${{ steps.cov_html.outputs.artifact-url }} @@ -81,7 +83,7 @@ jobs: - name: Post test results to PR if: ${{ always() && github.event_name == 'pull_request'}} - uses: mshick/add-pr-comment@v3 + uses: mshick/add-pr-comment@v2 with: message-path: FINAL_REPORT.md message-id: TestResults-${{ matrix.python_version }} diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 5ba38fc6..f20f7300 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -13,7 +13,7 @@ jobs: version: ${{ steps.get_version.outputs.version }} tag_exists: ${{ steps.tagcheck.outputs.exists }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v5 with: { fetch-depth: 0 } - name: Extract version @@ -81,14 +81,15 @@ jobs: with: ref: ${{ needs.release.outputs.version }} fetch-depth: 0 - - - uses: astral-sh/setup-uv@v7 - + + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + - name: Build release distributions run: | - uv python install ${{ vars.PYTHON_VERSION }} - uv build - + python -m pip install --upgrade pip build + python -m build - name: Publish release distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 45c7508a..ce832e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ bundles/ site/ + +.idea/ diff --git a/.python-version b/.python-version deleted file mode 100644 index 2c073331..00000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.11 diff --git a/Makefile b/Makefile index ed225612..540c9f56 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ FOLDERS=psengine tests docs help: - @echo "Available targets" + @echo "Available targets:" @echo " test - run pytest" @echo " format - run ruff format" @echo " lint - run ruff check --fix" diff --git a/README.md b/README.md index e060b0fa..6bdc88d4 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ PSEngine is a simple, yet elegant, library for rapid development of integrations PSEngine allows you to interact with the Recorded Future API extremely easily. There’s no need to manually build the URLs and query parameters, just use the modules dedicated to individual API endpoints. -PSEngine is a Python package solely built and maintained by the Recorded Future Cyber Security Engineering team powering a number of high profile integrations, such as: [Banshee](https://recordedfuture-professionalservices.github.io/banshee); [Recorded Future Alerts for QRadar](https://apps.xforce.ibmcloud.com/extension/b36efdf42b7bf5e3759d036dbcdbf606); Anomali ThreatStream: [Alerts](https://support.recordedfuture.com/hc/en-us/articles/29255683708691-Recorded-Future-Alerts-for-Anomali-ThreatStream) and [Analyst Notes](https://support.recordedfuture.com/hc/en-us/articles/12928414947475-Recorded-Future-Analyst-Notes-for-Anomali-ThreatStream) integrations; [Google SecOps](https://app.recordedfuture.com/portal/integration-center/detail/google-chronicle-nbfi?organization=uhash%3A5cJsHMHeSM&filter_tab=all) and many more. - +PSEngine is a Python package solely built and maintained by the Cyber Security Engineering team powering a number of high profile integrations, such as: Elasticsearch, QRadar, Anomali, Jira, TheHive, etc. ## Installation @@ -21,7 +20,7 @@ PSEngine is a Python package that can be installed using `pip`. To install PSeng pip install psengine ``` -PSEngine officially supports Python >= 3.10, < 3.15. +PSEngine officially supports Python >= 3.9, < 3.14. ## Supported Features & Best Practices @@ -38,7 +37,6 @@ It can easily interact with the following Recorded Future datasets: - Fusion File management - Identity Exposures management - List management -- Malware Intelligence (including Auto Yara and Auto Sigma) - Malware Sandbox reports download - On demand IOC enrichment - Risklists @@ -53,9 +51,3 @@ And facilitate the development with features like: - Markdown creation from certain data types - Proxy support -## Previous versions and version documentation - -PSEngine has been made public from our internal version 2.0.4. Previous versions, including version 1, are not publicly available. - -The documentation arrived at version 2.1.1. Older versions are not explicitly documented and changes can be found in the [Release History](./CHANGELOG.md) section. - diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 984f4997..0bc42677 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,39 +1,12 @@ # Changelog -## v2.7.0 - 2026-05-01 +## v2.6.1 - 2026-05-08 -- Added support for Threat Map and Malware Map via the `ThreatMapMgr`. - -## v2.6.0 - 2026-04-29 +- `psengine.links` now supports the Links API via the `LinksMgr` class. ### Added -- Added support for Python 3.14 -- `PlaybookAlertMgr.search` now supports filter for a single or a list of organisations. -- `TimeHelpers.rel_time_to_date` now supports a starting time from where the math begins. If not specified it will be the UTC execution time. -- `TimeHelpers.rel_time_to_date` now supports increment calculation, with `+1h`. -- `TimeHelpers.rel_time_to_date` now supports increment or decrement of minutes with `+10m` or `-10m`. -- Added `malware_intel.AutoYaraMgr` and `malware_intel.AutoSigmaMgr` managers to interact with auto-yara and auto-sigma APIs. -- `ClassicAlertMgr.fetch` and `ClassicAlertMgr.fetch_bulk` now allow to fetch images directly via the `fetch_images` argument. Defaults to `False`. -- `AnalystNote` model now supports `is_threat_actor` field. - -### Fixed - -- `playbook_alerts.helpers.save_pba_images` now allow for all alert types that support images. -- `IdentityMgr.fetch_incident_report` now support a single string `organization_id` field. - -### Changed - -- `IdentityMgr` methods now support 1000 maximum identities returned instead of 500. - -### Removed - -- Removed support for Python 3.9 - -## v2.5.2 - 2026-04-27 - -### Fixed -- When parsing any Playbook Alert object the `panel_log_v2.[].added.[].url` field no longer fails validation when the URL format does not conform to strict HTTP URL requirements. +- `psengine.links` implements Links search and metadata listing via `LinksMgr`. ## v2.5.1 - 2026-03-26 @@ -61,7 +34,7 @@ ### Fixed -- `PlaybookAlertMgr.fetch_bulk()` now correctly respects the `alerts_per_page` parameter for bulk lookup batching instead of using a hardcoded internal constant. +-`PlaybookAlertMgr.fetch_bulk()` now correctly respects the `alerts_per_page` parameter for bulk lookup batching instead of using a hardcoded internal constant. - `PBA_GeopoliticsFacility` event `url` field no longer fails validation when the URL format does not conform to strict HTTP URL requirements. diff --git a/docs/api/links/errors.md b/docs/api/links/errors.md new file mode 100644 index 00000000..4db337f4 --- /dev/null +++ b/docs/api/links/errors.md @@ -0,0 +1 @@ +::: psengine.links.errors \ No newline at end of file diff --git a/docs/api/links/links_adt.md b/docs/api/links/links_adt.md new file mode 100644 index 00000000..9d39fa9c --- /dev/null +++ b/docs/api/links/links_adt.md @@ -0,0 +1 @@ +::: psengine.links.links diff --git a/docs/api/links/links_mgr.md b/docs/api/links/links_mgr.md new file mode 100644 index 00000000..b287756f --- /dev/null +++ b/docs/api/links/links_mgr.md @@ -0,0 +1,8 @@ +::: psengine.links.links_mgr.LinksMgr + options: + members: + - __init__ + - list_sections + - list_events + - list_entity_types + - search diff --git a/docs/api/links/links_models.md b/docs/api/links/links_models.md new file mode 100644 index 00000000..ba4d9a55 --- /dev/null +++ b/docs/api/links/links_models.md @@ -0,0 +1 @@ +::: psengine.links.models diff --git a/docs/api/malware_intel/auto_sigma_mgr.md b/docs/api/malware_intel/auto_sigma_mgr.md deleted file mode 100644 index 423d996c..00000000 --- a/docs/api/malware_intel/auto_sigma_mgr.md +++ /dev/null @@ -1,11 +0,0 @@ -::: psengine.malware_intel.auto_sigma_mgr.AutoSigmaMgr - options: - members: - - create_rule_job - - fetch_rule_jobs - - fetch_rule_job_result - - edit_rule_str - - delete_rule_job - - retry_failed_rule_job - - diff --git a/docs/api/malware_intel/auto_yara_mgr.md b/docs/api/malware_intel/auto_yara_mgr.md deleted file mode 100644 index b8ac6600..00000000 --- a/docs/api/malware_intel/auto_yara_mgr.md +++ /dev/null @@ -1,10 +0,0 @@ -::: psengine.malware_intel.auto_yara_mgr.AutoYaraMgr - options: - members: - - create_rule_job - - fetch_rule_jobs - - fetch_rule_job_result - - edit_rule_str - - delete_rule_job - - retry_failed_rule_job - diff --git a/docs/api/malware_intel/helpers.md b/docs/api/malware_intel/helpers.md deleted file mode 100644 index ae6d6c53..00000000 --- a/docs/api/malware_intel/helpers.md +++ /dev/null @@ -1 +0,0 @@ -::: psengine.malware_intel.helpers.save_rules \ No newline at end of file diff --git a/docs/api/threat_maps/errors.md b/docs/api/threat_maps/errors.md deleted file mode 100644 index b45e9a25..00000000 --- a/docs/api/threat_maps/errors.md +++ /dev/null @@ -1 +0,0 @@ -::: psengine.threat_maps.errors diff --git a/docs/api/threat_maps/models.md b/docs/api/threat_maps/models.md deleted file mode 100644 index 1bc8bf24..00000000 --- a/docs/api/threat_maps/models.md +++ /dev/null @@ -1 +0,0 @@ -::: psengine.threat_maps.models \ No newline at end of file diff --git a/docs/api/threat_maps/threat_map.md b/docs/api/threat_maps/threat_map.md deleted file mode 100644 index af972d3e..00000000 --- a/docs/api/threat_maps/threat_map.md +++ /dev/null @@ -1 +0,0 @@ -::: psengine.threat_maps.threat_map \ No newline at end of file diff --git a/docs/api/threat_maps/threat_map_mgr.md b/docs/api/threat_maps/threat_map_mgr.md deleted file mode 100644 index 4b879aa6..00000000 --- a/docs/api/threat_maps/threat_map_mgr.md +++ /dev/null @@ -1,7 +0,0 @@ -::: psengine.threat_maps.threat_map_mgr.ThreatMapMgr - options: - members: - - fetch_available_maps - - fetch_entity_categories - - search_threat_actor - - fetch_map diff --git a/docs/examples/classic_alerts/example_2.py b/docs/examples/classic_alerts/example_2.py index 16dcd479..5f4f6c8e 100644 --- a/docs/examples/classic_alerts/example_2.py +++ b/docs/examples/classic_alerts/example_2.py @@ -9,11 +9,8 @@ ALERT_IDS = ['9Z0ts8', '9Z0ttT'] mgr = ClassicAlertMgr() -alerts = mgr.fetch_bulk( - ALERT_IDS, - fetch_images=True, - max_workers=2, -) +alerts = mgr.fetch_bulk(ALERT_IDS, max_workers=2) for alert in alerts: + mgr.fetch_all_images(alert) save_images(alert, OUTPUT_DIR) diff --git a/docs/examples/collective_insights/example_1.py b/docs/examples/collective_insights/example_1.py index 2a61aec9..02d4a8d9 100644 --- a/docs/examples/collective_insights/example_1.py +++ b/docs/examples/collective_insights/example_1.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime +import datetime from psengine.collective_insights import ( DETECTION_SUB_TYPE_SIGMA, @@ -9,7 +9,7 @@ ci = CollectiveInsights() -now = datetime.now(UTC).isoformat() +now = datetime.datetime.utcnow().isoformat()[:-3] + 'Z' insight1 = ci.create( ioc_value='fbee00cb1d1ea4d7e0604436d9a36def71a9f3be804f1e2b8d117fd5d35aeabc', diff --git a/docs/examples/enrich/example_3.py b/docs/examples/enrich/example_3.py index 1ad3ff26..2efe01f2 100644 --- a/docs/examples/enrich/example_3.py +++ b/docs/examples/enrich/example_3.py @@ -23,7 +23,5 @@ with enriched_file.open('w', newline='') as f: writer = csv.writer(f) writer.writerow(['ip', 'score']) - for ip, enriched in zip( - ips_to_enrich, enriched_ips, strict=False - ): + for ip, enriched in zip(ips_to_enrich, enriched_ips): writer.writerow([ip, enriched.content.risk.score]) diff --git a/docs/examples/threat_maps/__init__.py b/docs/examples/links/__init__.py similarity index 100% rename from docs/examples/threat_maps/__init__.py rename to docs/examples/links/__init__.py diff --git a/docs/examples/links/example_1.py b/docs/examples/links/example_1.py new file mode 100644 index 00000000..127d30c8 --- /dev/null +++ b/docs/examples/links/example_1.py @@ -0,0 +1,17 @@ +from psengine.links import LinksMgr + +mgr = LinksMgr() + +results = mgr.search(entities=['QCwdoU']) + +for result in results.data: + if result.error: + print(f'Failed: {result.error.message}') + continue + entity = result.entity + print(f'Entity: {entity.name} ({entity.type_})') + for link in result.links: + print( + f' -> {link.name} ' + f'({link.type_}) source={link.source}' + ) diff --git a/docs/examples/links/example_2.py b/docs/examples/links/example_2.py new file mode 100644 index 00000000..7dcb95f7 --- /dev/null +++ b/docs/examples/links/example_2.py @@ -0,0 +1,25 @@ +from psengine.links import ( + FilterTechnical, + LinksFilterObjects, + LinksLimitsObjects, + LinksMgr, +) + +mgr = LinksMgr() + +filters = LinksFilterObjects( + sources=['technical'], + entity_types=['Malware'], + technical=FilterTechnical(timeframe='-30d'), +) +limits = LinksLimitsObjects( + search_scope='small', per_entity_type=50 +) + +results = mgr.search( + entities=['QCwdoU'], filters=filters, limits=limits +) + +for result in results.data: + for link in result.links: + print(f'{link.name} ({link.type_})') diff --git a/docs/examples/links/example_3.py b/docs/examples/links/example_3.py new file mode 100644 index 00000000..fc017f1d --- /dev/null +++ b/docs/examples/links/example_3.py @@ -0,0 +1,15 @@ +from psengine.links import LinksMgr + +mgr = LinksMgr() + +print('Sections:') +for section in mgr.list_sections(): + print(f' {section.id_}: {section.name}') + +print('\nEvent types:') +for event in mgr.list_events(): + print(f' {event.id_}: {event.name}') + +print('\nEntity types:') +for entity_type in mgr.list_entity_types(): + print(f' {entity_type.id_}: {entity_type.name}') diff --git a/docs/examples/malware_intel/example_2.py b/docs/examples/malware_intel/example_2.py deleted file mode 100644 index b59c66cb..00000000 --- a/docs/examples/malware_intel/example_2.py +++ /dev/null @@ -1,27 +0,0 @@ -from pathlib import Path - -from psengine.malware_intel import AutoYaraMgr, save_rules - -mgr = AutoYaraMgr() - -OUTPUT_DIR = Path.cwd() / 'rules' -OUTPUT_DIR.mkdir(exist_ok=True) - - -query = 'sample.tags == "family:wannacry"' -wannacry_hashes = [ - 'ed01ebfbc9eb5bbea545af4d01bf5f1071661840480439c6e5babe8e080e41aa', - 'be22645c61949ad6a077373a7d6cd85e3fae44315632f161adc4c99d5a8e6844', - '892f11af94dea87bc8a85acdb092c74541b0ab63c8fcc1823ba7987c82c6e9ba', -] - -job = mgr.create_rule_job( - wannacry_hashes, 'WannaCry Monitoring', query -) -rule = mgr.fetch_rule_job_result( - job.job_id, wait_until_finished=True -) -print(f'Rule status: {rule.job.status}') -save_rules(rule, OUTPUT_DIR) - -print(mgr.fetch_rule_jobs()) diff --git a/docs/examples/malware_intel/example_3.py b/docs/examples/malware_intel/example_3.py deleted file mode 100644 index 76762a3b..00000000 --- a/docs/examples/malware_intel/example_3.py +++ /dev/null @@ -1,7 +0,0 @@ -from psengine.malware_intel import AutoYaraMgr - -mgr = AutoYaraMgr() - -rules = mgr.fetch_rule_jobs() -for rule in rules.jobs: - print(mgr.delete_rule_job(rule.job_id)) diff --git a/docs/examples/malware_intel/example_4.py b/docs/examples/malware_intel/example_4.py deleted file mode 100644 index 719d19d3..00000000 --- a/docs/examples/malware_intel/example_4.py +++ /dev/null @@ -1,25 +0,0 @@ -from pathlib import Path - -from psengine.malware_intel import AutoSigmaMgr, save_rules - -mgr = AutoSigmaMgr() - -OUTPUT_DIR = Path.cwd() / 'rules' -OUTPUT_DIR.mkdir(exist_ok=True) - -job = mgr.create_rule_job( - name='WannaCry Sigma Monitoring', - query='sample.tags == "family:wannacry"', - start_date='2025-03-01', - end_date='2025-03-05', -) - -result = mgr.fetch_rule_job_result( - job.job_id, wait_until_finished=True -) -print(f'Rule generation status: {result.status}') -print(f'Matched hashes: {result.n_matched_hashes}') -print(f'Generated Sigma rules: {len(result.sigma_rules)}') -save_rules(result, OUTPUT_DIR) - -print(mgr.fetch_rule_jobs()) diff --git a/docs/examples/malware_intel/example_5.py b/docs/examples/malware_intel/example_5.py deleted file mode 100644 index d75c0d83..00000000 --- a/docs/examples/malware_intel/example_5.py +++ /dev/null @@ -1,7 +0,0 @@ -from psengine.malware_intel import AutoSigmaMgr - -mgr = AutoSigmaMgr() - -jobs = mgr.fetch_rule_jobs() -for job in jobs.jobs: - print(mgr.delete_rule_job(job.job_id)) diff --git a/docs/examples/threat_maps/example_1.py b/docs/examples/threat_maps/example_1.py deleted file mode 100644 index 255cc918..00000000 --- a/docs/examples/threat_maps/example_1.py +++ /dev/null @@ -1,7 +0,0 @@ -from psengine.threat_maps import ThreatMapMgr - -mgr = ThreatMapMgr() -threat_maps = mgr.fetch_available_maps() - -for threat_map in threat_maps: - print(threat_map.name) diff --git a/docs/examples/threat_maps/example_2.py b/docs/examples/threat_maps/example_2.py deleted file mode 100644 index 8d00ed50..00000000 --- a/docs/examples/threat_maps/example_2.py +++ /dev/null @@ -1,9 +0,0 @@ -from psengine.threat_maps import ThreatMapMgr - -mgr = ThreatMapMgr() -actors = mgr.search_threat_actor( - name='Lazarus', max_results=10 -) - -for actor in actors: - print(actor) diff --git a/docs/examples/threat_maps/example_3.py b/docs/examples/threat_maps/example_3.py deleted file mode 100644 index bbff42d9..00000000 --- a/docs/examples/threat_maps/example_3.py +++ /dev/null @@ -1,13 +0,0 @@ -from psengine.threat_maps import ThreatMapMgr - -mgr = ThreatMapMgr() -malware_map = mgr.fetch_map( - map_type='malware', categories=['0fK7b', 'RTkDB2'] -) - -for malware in malware_map.threat_map: - if ( - malware.opportunity >= 65 - and malware.prevalence >= 65 - ): - print(malware) diff --git a/docs/index.md b/docs/index.md index e8d6f23a..7f0824d0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,7 +21,7 @@ PSEngine is a Python package that can be installed using `pip`. To install PSeng pip install psengine ``` -PSEngine officially supports Python >= 3.10, < 3.15. +PSEngine officially supports Python >= 3.9, < 3.14. ## Supported Features & Best Practices @@ -38,7 +38,6 @@ It can easily interact with the following Recorded Future datasets: - Fusion File management - Identity Exposures management - List management -- Malware Intelligence (including Auto Yara and Auto Sigma) - Malware Sandbox reports download - On demand IOC enrichment - Risklists diff --git a/docs/modules/classic_alerts.md b/docs/modules/classic_alerts.md index ea41d0f2..de31249f 100644 --- a/docs/modules/classic_alerts.md +++ b/docs/modules/classic_alerts.md @@ -42,7 +42,7 @@ This example starts with the assumption that you have a list of alert IDs retrie In this example we use the `fetch_bulk` method to download the alerts. We use `max_workers=2` to split the task into two threads for better performance. -The alerts might have an image ID in their payload, which will be collected when the `fetch_images` argument is set to `True`. This argument does not return the images but saves them in an `images` property of the alert object. If you need to access these images programmatically you can do that with `alert.images`. +The alerts might have an image ID in their payload, which will be collected by the `fetch_all_images` method. This method does not return the images but saves them in an `images` property of the alert object. If you need to access these images programmatically you can do that with `alert.images`. `save_images` will save in `OUTPUT_DIR` a `.png` file for each image called `img:.png`. diff --git a/docs/modules/links.md b/docs/modules/links.md new file mode 100644 index 00000000..e4910a21 --- /dev/null +++ b/docs/modules/links.md @@ -0,0 +1,80 @@ +## Introduction + +The `LinksMgr` class of the `links` module lets you discover entities connected to a given Recorded Future entity. Links are sourced from two places: + +- **technical** β€” automatically extracted relationships across recent references (the API selects the most recent references per event type, controlled by `search_scope`) +- **insikt** β€” relationships curated by Insikt Group analysts in published notes + +Each connected entity comes back annotated with attributes such as risk score, risk level, criticality, and the section it was found under (for example *Actors, Tools & TTPs* or *Indicators & Detection Rules*). + +The module also exposes three metadata listings β€” sections, event types, and entity types β€” that you can query to discover which filter values the API currently accepts. + +See the [**API Reference**](../api/links/links_mgr.md) for internal details of the module. + +## Notes + +`LinksMgr.search` accepts a list of Recorded Future entity IDs (not free-text names). If you only have a name, look the entity up first via `EntityMatchMgr` or `LookupMgr` to obtain its ID. + +The `filters` parameter is optional. If omitted, the API returns links from both sources across all sections and entity types within its default lookback. Static values (such as `sources`) are validated by the request models before the call is made; section, event, and entity-type IDs are validated server-side. + +## Examples + +{! modules/_includes/examples_warning.md !} + +#### 1: Discover entities connected to a given entity + +This example uses `LinksMgr.search` with a single Recorded Future entity ID. The response is a `LinksSearchResponse` with one `SearchResultSet` per queried entity. Each result either contains a list of `LinkedEntity` objects under `links` or, if the API failed for that specific entity, an `error` payload β€” so the example checks `result.error` before iterating. + +```python +--8<-- "docs/examples/links/example_1.py" +``` + +A typical line of the output looks like: + +``` +Entity: APT28 (Threat Actor) + -> X-Agent (Malware) source=insikt + -> Sofacy (Malware) source=insikt + -> 185.86.148.5 (IpAddress) source=technical +``` + +#### 2: Narrow the search with filters and limits + +This example restricts the search to technical links from the last 30 days, returning only `Malware` entities, and caps the result set with `LinksLimitsObjects`. + +The `search_scope` value (`small`, `medium`, or `large`) controls how many references the API scans before returning links β€” `small` is the fastest, `large` the most thorough. `per_entity_type` caps the number of entities returned of each type. + +`FilterTechnical.timeframe` accepts a relative string like `-30d`. The exact lookback bound is enforced by the API. + +```python +--8<-- "docs/examples/links/example_2.py" +``` + +#### 3: Discover the available filter values + +Section IDs, event types, and entity types are not stable strings you should hard-code β€” the API exposes them through three metadata endpoints. Use this example to print the current values before constructing a `LinksFilterObjects`. + +```python +--8<-- "docs/examples/links/example_3.py" +``` + +The output is similar to: + +``` +Sections: + iU_ZsE: Actors, Tools & TTPs + iU_ZsG: Indicators & Detection Rules + iU_ZsI: Targets & Vulnerabilities + ... + +Event types: + TTPAnalysis: TTP Analysis + InfrastructureAnalysis: Infrastructure Analysis + ... + +Entity types: + Company: Company + CyberVulnerability: Cyber Vulnerability + Malware: Malware + ... +``` diff --git a/docs/modules/malware_intel.md b/docs/modules/malware_intel.md index 916a5685..de990737 100644 --- a/docs/modules/malware_intel.md +++ b/docs/modules/malware_intel.md @@ -1,11 +1,6 @@ ## Introduction -The `malware_intel` module allows you to interact with the Recorded Future Malware Intelligence. It includes: - -1. Querying for known hashes -2. Auto YARA rule generation based on hashes -3. Auto Sigma rule generation based on hashes -4. Saving generated rules to disk +The `malware_intel` module allows you to interact with the Recorded Future Malware Intelligence. Currently it is only supported the retrieval of reports from an already known SHA256. See the [**API Reference**](../api/malware_intel/malware_intel_mgr.md) for internal details of the module. @@ -13,7 +8,6 @@ See the [**API Reference**](../api/malware_intel/malware_intel_mgr.md) for inter ## Notes - The `reports` method returns at most 10 reports, with the highest sandbox score. -- Use the `save_rule` helper function to write generated Auto YARA and Auto Sigma rules to disk. Auto YARA results are saved as a `.yar` file named after the job, while Auto Sigma results are saved as multiple `.yml` files named ` - Rule N.yml`. See the [**Helpers API**](../api/malware_intel/helpers.md) for details. ## Examples @@ -38,50 +32,3 @@ Sandbox Report of: c5455c43f6a295392cf7db66c68f8c725029f88e089ed01e3de858a114f07 Sandbox Report of: c5455c43f6a295392cf7db66c68f8c725029f88e089ed01e3de858a114f0764f, Score: 3, Task: behavioral1, Submitted: 2025-09-25T10:48:25.000Z ``` -#### 2: Generate a YARA rule based on hashes - -In this example you are creating a YARA rule based on 3 known hashes of WannaCry that has been identified via the query `'sample.tags == "family:wannacry"'`. Adding a query is not mandatory but useful for future reference. - -```python ---8<-- "docs/examples/malware_intel/example_2.py" -``` - -By default, fetching a newly created job may return a non-terminal status such as `CREATED` or `RUNNING`, and `yara_rule_str` can still be `None`. - -To simplify this flow, `fetch_rule_job_result` supports an optional `wait_until_finished=True` argument. When enabled, the client polls the job until it reaches `FINISHED` (or raises if the job fails or times out), so you do not need to write your own wait loop. - -Once the job is created, all jobs can be printed using `fetch_rule_jobs`. - -``` -Name: WannaCry Monitoring, ID: 74dc1c5c-c7dc-4863-8d09-1647629fdd01, Created: 2026-04-16 09:42:31, Covered Hashes: 3, Uncovered Hashes: 0 -Name: Dindoor Monitoring, ID: 60e587c2-8341-4266-a0de-b6620e15e54e, Created: 2026-04-16 09:33:30, Covered Hashes: 5, Uncovered Hashes: 0 -``` - - -#### 3: Delete the generated YARA rules - -In this example you are going to delete all the rules generated by the token configured. This example is used to demonstrate that `fetch_rule_jobs` can be used to access the job ID via the `job_id` field, and pass it as argument of `delete_rule_job`. - -```python ---8<-- "docs/examples/malware_intel/example_3.py" -``` - -#### 4: Generate Sigma rules from malware query results - -In this example you create an Auto Sigma job from a malware query and a time range, then fetch the result. - -As with Auto YARA, `fetch_rule_job_result` supports `wait_until_finished=True` so the client polls until the job reaches `FINISHED`. - -```python ---8<-- "docs/examples/malware_intel/example_4.py" -``` - -#### 5: Delete generated Auto Sigma jobs - -In this example you fetch all Auto Sigma jobs for the configured token and delete them one by one using `delete_rule_job`. - -```python ---8<-- "docs/examples/malware_intel/example_5.py" -``` - - diff --git a/docs/modules/threat_maps.md b/docs/modules/threat_maps.md deleted file mode 100644 index f217300b..00000000 --- a/docs/modules/threat_maps.md +++ /dev/null @@ -1,45 +0,0 @@ -## Introduction - -The `ThreatMapMgr` class in the `threat_maps` module enables you to search for and retrieve enterprise threat maps. Currently this includes: - -- Malware -- Actors - -See the [**API Reference**](../api/threat_maps/threat_map_mgr.md) for internal details of the module. - -## Examples - -{! modules/_includes/examples_warning.md !} - -#### 1: Fetch all available threat maps - -This example uses the `fetch_available_maps` method to fetch all threat maps available to an organization, including any sub-organization threat maps. - -```python ---8<-- "docs/examples/threat_maps/example_1.py" -``` - -#### 2: Search for threat actor by name - -In this example, we use the `search_threat_actor` method to find threat actors by name. We set the `name` to the target entity name and limit the results with `max_results`. - -```python ---8<-- "docs/examples/threat_maps/example_2.py" -``` - -The code will output: - -``` -ID: QCwdoU Name: Lazarus Group, Common Names: Diamond Sleet, Cyber Warfare Guidance Unit -ID: TyZBlf Name: Lazarus -ID: idrp3c Name: Fancy Lazarus -ID: sMJUDp Name: lazaruscore -``` - -#### 3: Fetch malware threat map with categories and filter by scores - -This example assumes that you have the malware category IDs from the `fetch_entity_categories` method. We use the `fetch_map` method to fetch the primary organization's malware threat map. By setting `map_type` to `malware`, we fetch the malware threat map only. To further narrow the results to those related to Rootkit and Linux Malware categories, we use the category IDs: `["0fK7b", "RTkDB2"]`. Once the map is returned we then filter for targeted entities based on opportunity and prevalence score. - -```python ---8<-- "docs/examples/threat_maps/example_3.py" -``` diff --git a/mkdocs.yml b/mkdocs.yml index 7d2f6f74..0debc07a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -101,12 +101,12 @@ nav: - Entity Match: modules/entity_match.md - Fusion: modules/fusion.md - Identity: modules/identity.md + - Links: modules/links.md - Malware Intelligence: modules/malware_intel.md - Playbook Alerts: modules/playbook_alerts.md - Risk History: modules/risk_history.md - Risklists: modules/risklists.md - STIX2: modules/stix2.md - - Threat Maps: modules/threat_maps.md - Error Handling: modules/errors.md - Config: modules/config.md - Logger: modules/logger.md @@ -191,17 +191,19 @@ nav: - ADT: api/identity/identity.md - Constants: api/identity/constants.md - Errors: api/identity/errors.md + - Links: + - Manager: api/links/links_mgr.md + - ADT: api/links/links_adt.md + - Models: api/links/links_models.md + - Errors: api/links/errors.md - Logger: - Logger: api/logger/rf_logger.md - Constants: api/logger/constants.md - Errors: api/logger/errors.md - Malware Intelligence: - Manager: api/malware_intel/malware_intel_mgr.md - - Auto Yara Manager: api/malware_intel/auto_yara_mgr.md - - Auto Sigma Manager: api/malware_intel/auto_sigma_mgr.md - ADT: api/malware_intel/malware_intel.md - Models: api/malware_intel/models.md - - Helpers: api/malware_intel/helpers.md - Errors: api/malware_intel/errors.md - Markdown: - Markdown: api/markdown/markdown.md @@ -244,11 +246,6 @@ nav: - Helpers: api/stix2/helpers.md - Constants: api/stix2/constants.md - Errors: api/stix2/errors.md - - Threat Maps: - - Manager: api/threat_maps/threat_map_mgr.md - - ADT: api/threat_maps/threat_map.md - - Models: api/threat_maps/models.md - - Errors: api/threat_maps/errors.md - Client: - Client: api/client/rf_client.md - Base HTTP Client: api/client/base_client.md diff --git a/psengine/analyst_notes/helpers.py b/psengine/analyst_notes/helpers.py index 6b71fa6b..6729f07e 100644 --- a/psengine/analyst_notes/helpers.py +++ b/psengine/analyst_notes/helpers.py @@ -14,7 +14,7 @@ import json import logging from pathlib import Path -from typing import Annotated +from typing import Annotated, Union from pydantic import validate_call from typing_extensions import Doc @@ -30,9 +30,9 @@ @validate_call def save_attachment( note_id: Annotated[str, Doc('The ID of the AnalystNote.')], - data: Annotated[bytes | str, Doc('The data returned from `fetch_attachment`.')], + data: Annotated[Union[bytes, str], Doc('The data returned from `fetch_attachment`.')], ext: Annotated[str, Doc('The extension of the attachment, returned by `fetch_attachment`.')], - output_directory: Annotated[str | Path, Doc('The directory to save the file into.')], + output_directory: Annotated[Union[str, Path], Doc('The directory to save the file into.')], ) -> None: """Save a YARA, Sigma, Snort, or PDF attachment to a file. @@ -48,7 +48,7 @@ def save_attachment( @validate_call def save_note( note: Annotated[AnalystNote, Doc('The note to save.')], - output_directory: Annotated[str | Path, Doc('The directory to save the file into.')], + output_directory: Annotated[Union[str, Path], Doc('The directory to save the file into.')], ) -> None: """Save an `AnalystNote` object to a file named with the note ID.""" output_directory = ( @@ -62,7 +62,9 @@ def save_note( ) -def _save_attachment(note_id: str, data: bytes | str, ext: str, output_directory: str) -> None: +def _save_attachment( + note_id: str, data: Union[bytes, str], ext: str, output_directory: str +) -> None: """Save attachment from bytes or note itself from json. Raises: diff --git a/psengine/analyst_notes/models.py b/psengine/analyst_notes/models.py index 3f1fc702..6c622f83 100644 --- a/psengine/analyst_notes/models.py +++ b/psengine/analyst_notes/models.py @@ -13,7 +13,7 @@ import logging from datetime import datetime -from typing import Annotated, Any +from typing import Annotated, Any, Optional, Union from pydantic import BeforeValidator, Field, ValidationError, field_validator, model_validator @@ -21,22 +21,18 @@ from ..helpers import Validators -class NoteEntity(IdNameTypeDescription): - is_threat_actor: bool | None = None - - class DiamondModel(RFBaseModel): - start: datetime | None = None - stop: datetime | None = None - malicious_infrastructure: list[NoteEntity] | None = [] - capabilities: list[NoteEntity] | None = [] - adversary: list[NoteEntity] | None = [] - target: list[NoteEntity] | None = [] + start: Optional[datetime] = None + stop: Optional[datetime] = None + malicious_infrastructure: Optional[list[IdNameTypeDescription]] = [] + capabilities: Optional[list[IdNameTypeDescription]] = [] + adversary: Optional[list[IdNameTypeDescription]] = [] + target: Optional[list[IdNameTypeDescription]] = [] class Query(RFBaseModel): title: str - url: NoteEntity | None = None + url: Optional[IdNameTypeDescription] = None class Position(RFBaseModel): @@ -47,35 +43,35 @@ class Position(RFBaseModel): class PositionEvent(RFBaseModel): start: datetime stop: datetime - location: list[NoteEntity] | None = [] - event_positions: list[Position] | None = [] + location: Optional[list[IdNameTypeDescription]] = [] + event_positions: Optional[list[Position]] = [] class CyberAttackEvent(RFBaseModel): start: datetime stop: datetime - adversary: list[NoteEntity] | None = [] - target: list[NoteEntity] | None = [] - capabilities: list[NoteEntity] = [] - malicious_infrastructure: list[NoteEntity] | None = [] - operation: list[NoteEntity] | None = [] + adversary: Optional[list[IdNameTypeDescription]] = [] + target: Optional[list[IdNameTypeDescription]] = [] + capabilities: list[IdNameTypeDescription] = [] + malicious_infrastructure: Optional[list[IdNameTypeDescription]] = [] + operation: Optional[list[IdNameTypeDescription]] = [] class ArmedConflictEvent(PositionEvent): - attacker: list[NoteEntity] | None = [] - target: list[NoteEntity] | None = [] + attacker: Optional[list[IdNameTypeDescription]] = [] + target: Optional[list[IdNameTypeDescription]] = [] class ArmsPurchaseSaleEvent(RFBaseModel): start: datetime stop: datetime - arms_seller: list[NoteEntity] | None = [] - arms_purchaser: list[NoteEntity] | None = [] + arms_seller: Optional[list[IdNameTypeDescription]] = [] + arms_purchaser: Optional[list[IdNameTypeDescription]] = [] class DiseaseOutbreakEvent(PositionEvent): - disease: list[NoteEntity] | None = [] - facility: list[NoteEntity] | None = [] + disease: Optional[list[IdNameTypeDescription]] = [] + facility: Optional[list[IdNameTypeDescription]] = [] class EnvironmentalIssueEvent(PositionEvent): @@ -83,45 +79,45 @@ class EnvironmentalIssueEvent(PositionEvent): class ManMadeDisasterEvent(PositionEvent): - facility: list[NoteEntity] - manmade_disaster: list[NoteEntity] | list[str] + facility: list[IdNameTypeDescription] + manmade_disaster: Union[list[IdNameTypeDescription], list[str]] class MilitaryManeuverEvent(PositionEvent): - actors: list[NoteEntity] | None = [] + actors: Optional[list[IdNameTypeDescription]] = [] class NaturalDisasterEvent(PositionEvent): - natural_disaster: list[NoteEntity] + natural_disaster: list[IdNameTypeDescription] class NuclearMaterialTransactionEvent(PositionEvent): material: list[str] - location_origin: list[str] | None = [] - location_destination: list[str] | None = [] + location_origin: Optional[list[str]] = [] + location_destination: Optional[list[str]] = [] class PersonThreatEvent(RFBaseModel): start: datetime stop: datetime - threatened: list[NoteEntity] - actor: list[NoteEntity] | None = [] + threatened: list[IdNameTypeDescription] + actor: Optional[list[IdNameTypeDescription]] = [] class ProtestEvent(RFBaseModel): - protest_target: list[NoteEntity] | None = [] + protest_target: Optional[list[IdNameTypeDescription]] = [] class MalwareAnalysisEvent(RFBaseModel): start: datetime stop: datetime - malware: list[NoteEntity] - attacker: list[NoteEntity] | None = [] - malicious_infrastructure: list[NoteEntity] | None = [] - ttp: list[NoteEntity] | None = [] - target: list[NoteEntity] | None = [] - exploit: list[NoteEntity] | None = [] - hash_: list[NoteEntity] | None = Field(alias='hash', default=[]) + malware: list[IdNameTypeDescription] + attacker: Optional[list[IdNameTypeDescription]] = [] + malicious_infrastructure: Optional[list[IdNameTypeDescription]] = [] + ttp: Optional[list[IdNameTypeDescription]] = [] + target: Optional[list[IdNameTypeDescription]] = [] + exploit: Optional[list[IdNameTypeDescription]] = [] + hash_: Optional[list[IdNameTypeDescription]] = Field(alias='hash', default=[]) ATTRIBUTES_MAPPING = { @@ -147,8 +143,8 @@ class MalwareAnalysisEvent(RFBaseModel): class NoteEvent(RFBaseModel): - type_: str | None = Field(alias='type', default=None) - attributes: Any | None = None + type_: Optional[str] = Field(alias='type', default=None) + attributes: Optional[Any] = None @model_validator(mode='before') @classmethod @@ -179,17 +175,17 @@ class Attributes(RFBaseModel): title: str text: str published: datetime - attachment: str | None = None - events: list[NoteEvent] | None = [] - validated_on: datetime | None = None - note_entities: list[NoteEntity] | None = [] - context_entities: list[NoteEntity] | None = [] - topic: list[NoteEntity] | NoteEntity | None = [] - labels: list[NoteEntity] | None = [] - validation_urls: list[NoteEntity] | None = [] - diamond_model: list[DiamondModel] | None = [] - recommended_queries: list[Query] | None = [] - header_image: IdNameType | None = None + attachment: Optional[str] = None + events: Optional[list[NoteEvent]] = [] + validated_on: Optional[datetime] = None + note_entities: Optional[list[IdNameTypeDescription]] = [] + context_entities: Optional[list[IdNameTypeDescription]] = [] + topic: Optional[Union[list[IdNameTypeDescription], IdNameTypeDescription]] = [] + labels: Optional[list[IdNameTypeDescription]] = [] + validation_urls: Optional[list[IdNameTypeDescription]] = [] + diamond_model: Optional[list[DiamondModel]] = [] + recommended_queries: Optional[list[Query]] = [] + header_image: Optional[IdNameType] = None @field_validator('events', mode='after') @classmethod @@ -201,24 +197,24 @@ def remove_empty_events(cls, values): class PreviewAttributesIn(RFBaseModel): title: str text: str - note_entities: list[str] | None = [] - context_entities: list[str] | None = [] + note_entities: Optional[list[str]] = [] + context_entities: Optional[list[str]] = [] topic: Annotated[ - list[str] | str | None, + Union[list[str], str, None], BeforeValidator(Validators.convert_str_to_list), ] = [] - labels: list[str] | None = [] - validation_urls: list[str] | None = [] + labels: Optional[list[str]] = [] + validation_urls: Optional[list[str]] = [] class PreviewAttributesOut(RFBaseModel): title: str text: str - note_entities: list[NoteEntity] | None = [] - context_entities: list[NoteEntity] | None = [] - topic: list[NoteEntity] | None = [] - labels: list[NoteEntity] | None = [] - validation_urls: list[NoteEntity] | None = [] + note_entities: Optional[list[IdNameTypeDescription]] = [] + context_entities: Optional[list[IdNameTypeDescription]] = [] + topic: Optional[list[IdNameTypeDescription]] = [] + labels: Optional[list[IdNameTypeDescription]] = [] + validation_urls: Optional[list[IdNameTypeDescription]] = [] class RequestAttachment(RFBaseModel): diff --git a/psengine/analyst_notes/note.py b/psengine/analyst_notes/note.py index b38f3c83..a9b0e515 100644 --- a/psengine/analyst_notes/note.py +++ b/psengine/analyst_notes/note.py @@ -12,7 +12,7 @@ ############################################################################################## from functools import total_ordering -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import Field from typing_extensions import Doc @@ -57,7 +57,7 @@ class AnalystNote(RFBaseModel): criterion. """ - external_id: str | None = None + external_id: Optional[str] = None source: IdNameTypeDescription attributes: Attributes id_: str = Field(alias='id') @@ -78,7 +78,7 @@ def __str__(self): ) @property - def detection_rule_type(self) -> str | None: + def detection_rule_type(self) -> Optional[str]: """Returns the attachment type if present, else None. It checks for specific types like `sigma rule`, `yara rule`, and `snort rule` in the topics of the note. """ @@ -113,7 +113,7 @@ def markdown( bool, Doc('Defang URLs or other malicious indicators.') ] = False, character_limit: Annotated[ - int | None, + Optional[int], Doc('Limit the output to a specified number of characters.'), ] = None, ) -> Annotated[str, Doc('The generated markdown string.')]: @@ -132,7 +132,7 @@ class AnalystNotePreviewIn(RFBaseModel): """Validate data sent to `/preview` endpoint.""" attributes: PreviewAttributesIn - source: str | None + source: Optional[str] tagged_text: bool = False serialization: str = 'full' @@ -148,12 +148,12 @@ class AnalystNotePublishIn(AnalystNotePreviewIn): """Validate data sent to `/publish` endpoint.""" attributes: PreviewAttributesIn - source: str | None = None + source: Optional[str] = None tagged_text: bool = False serialization: str = 'full' - note_id: str | None = None + note_id: Optional[str] = None resolve_entities: bool = True - attachment_content_details: RequestAttachment | None = None + attachment_content_details: Optional[RequestAttachment] = None class AnalystNotePublishOut(RFBaseModel): @@ -165,14 +165,14 @@ class AnalystNotePublishOut(RFBaseModel): class AnalystNoteSearchIn(RFBaseModel): """Validate data sent to `/search` endpoint.""" - published: str | None = None - entity: str | None = None - author: str | None = None - title: str | None = None - topic: list[str] | str | None = [] - label: str | None = None - source: str | None = None + published: Optional[str] = None + entity: Optional[str] = None + author: Optional[str] = None + title: Optional[str] = None + topic: Union[list[str], str, None] = [] + label: Optional[str] = None + source: Optional[str] = None serialization: str = None tagged_text: bool = None limit: int = NOTES_PER_PAGE - from_: str | None = Field(alias='from', default=None) + from_: Optional[str] = Field(alias='from', default=None) diff --git a/psengine/analyst_notes/note_mgr.py b/psengine/analyst_notes/note_mgr.py index ee3c791d..8c592398 100644 --- a/psengine/analyst_notes/note_mgr.py +++ b/psengine/analyst_notes/note_mgr.py @@ -14,7 +14,7 @@ import logging import re from itertools import chain -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import Field, validate_call from typing_extensions import Doc @@ -66,23 +66,23 @@ def __init__(self, rf_token: str = None): @validate_call def search( self, - published: Annotated[str | None, Doc('Notes published after a date.')] = None, - entity: Annotated[str | None, Doc('An entity the note refers to, RF ID.')] = None, - author: Annotated[str | None, Doc('An author of the note, RF ID.')] = None, - title: Annotated[str | None, Doc('A title of the note.')] = None, - topic: Annotated[str | list | None, Doc('A topic of the note, RF ID.')] = None, - label: Annotated[str | None, Doc('A label of the note, by name.')] = None, - source: Annotated[str | None, Doc('The source of the note.')] = None, + published: Annotated[Optional[str], Doc('Notes published after a date.')] = None, + entity: Annotated[Optional[str], Doc('An entity the note refers to, RF ID.')] = None, + author: Annotated[Optional[str], Doc('An author of the note, RF ID.')] = None, + title: Annotated[Optional[str], Doc('A title of the note.')] = None, + topic: Annotated[Optional[Union[str, list]], Doc('A topic of the note, RF ID.')] = None, + label: Annotated[Optional[str], Doc('A label of the note, by name.')] = None, + source: Annotated[Optional[str], Doc('The source of the note.')] = None, serialization: Annotated[ - str | None, Doc('An entity serializer (id, min, full, raw).') + Optional[str], Doc('An entity serializer (id, min, full, raw).') ] = None, - tagged_text: Annotated[bool | None, Doc('Should the text contain tags.')] = None, + tagged_text: Annotated[Optional[bool], Doc('Should the text contain tags.')] = None, max_results: Annotated[ - int | None, + Optional[int], Doc('The maximum number of references (not notes), max 1000.'), ] = Field(ge=1, le=1000, default=DEFAULT_LIMIT), notes_per_page: Annotated[ - int | None, Doc('The number of notes for each paged request.') + Optional[int], Doc('The number of notes for each paged request.') ] = Field(ge=1, le=1000, default=NOTES_PER_PAGE), ) -> Annotated[list[AnalystNote], Doc('A list of deduplicated AnalystNote objects.')]: """Execute a search for the analyst notes based on the parameters provided. @@ -189,16 +189,16 @@ def preview( self, title: Annotated[str, Doc('The title of the note.')], text: Annotated[str, Doc('The text of the note.')], - published: Annotated[str | None, Doc('The date when the note was published.')] = None, - topic: Annotated[str | list[str] | None, Doc('The topic of the note.')] = None, + published: Annotated[Optional[str], Doc('The date when the note was published.')] = None, + topic: Annotated[Union[str, list[str], None], Doc('The topic of the note.')] = None, context_entities: Annotated[ - list[str] | None, Doc('The context entities of the note.') + Optional[list[str]], Doc('The context entities of the note.') ] = None, - note_entities: Annotated[list[str] | None, Doc('The note entities of the note.')] = None, + note_entities: Annotated[Optional[list[str]], Doc('The note entities of the note.')] = None, validation_urls: Annotated[ - list[str] | None, Doc('The validation URLs of the note.') + Optional[list[str]], Doc('The validation URLs of the note.') ] = None, - source: Annotated[str | None, Doc('The source of the note.')] = None, + source: Annotated[Optional[str], Doc('The source of the note.')] = None, ) -> Annotated[AnalystNotePreviewOut, Doc('The note that will be created.')]: """Preview of the AnalystNote. It does not create a note; it just returns how the note will look. @@ -236,18 +236,18 @@ def publish( self, title: Annotated[str, Doc('The title of the note.')], text: Annotated[str, Doc('The text of the note.')], - published: Annotated[str | None, Doc('The date when the note was published.')] = None, - topic: Annotated[str | list[str] | None, Doc('The topic of the note.')] = None, + published: Annotated[Optional[str], Doc('The date when the note was published.')] = None, + topic: Annotated[Union[str, list[str], None], Doc('The topic of the note.')] = None, context_entities: Annotated[ - list[str] | None, Doc('The context entities of the note.') + Optional[list[str]], Doc('The context entities of the note.') ] = None, - note_entities: Annotated[list[str] | None, Doc('The note entities of the note.')] = None, + note_entities: Annotated[Optional[list[str]], Doc('The note entities of the note.')] = None, validation_urls: Annotated[ - list[str] | None, Doc('The validation URLs of the note.') + Optional[list[str]], Doc('The validation URLs of the note.') ] = None, - source: Annotated[str | None, Doc('The source of the note.')] = None, + source: Annotated[Optional[str], Doc('The source of the note.')] = None, note_id: Annotated[ - str | None, Doc('The ID of the note. Use if you want to modify an existing note.') + Optional[str], Doc('The ID of the note. Use if you want to modify an existing note.') ] = None, ) -> Annotated[AnalystNotePublishOut, Doc('The published note.')]: """Publish data. This method creates a note and returns its ID. diff --git a/psengine/asi/asi.py b/psengine/asi/asi.py index 5f705fe5..03aa6f1e 100644 --- a/psengine/asi/asi.py +++ b/psengine/asi/asi.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional from pydantic import Field @@ -58,9 +59,9 @@ class AssetWithExposureSearch(RFBaseModel): ``` """ - asset_exposures: list[AssetWithExposure] | None = [] + asset_exposures: Optional[list[AssetWithExposure]] = [] signature: ExposureSignature - meta: ApiMeta | None = None + meta: Optional[ApiMeta] = None def __str__(self) -> str: msg = 'Name: {}, Id: {}, Severity: {}' @@ -111,7 +112,7 @@ class ExposureSearch(RFBaseModel): """ asset_count: int - asset_exposures: list[AssetExposure] | None = [] + asset_exposures: Optional[list[AssetExposure]] = [] signature: ExposureSignature def __str__(self) -> str: @@ -180,20 +181,20 @@ class Asset(RFBaseModel): id_: str = Field(alias='id') name: str type_: str = Field(alias='type') - discovered_at: datetime | None + discovered_at: Optional[datetime] added_to_project_at: datetime - last_scanned_at: datetime | None = None - apex_domain: str | None = None - exposure_score: int | None = None - is_static_asset: bool | None = False - custom_tags: list[str] | None = None - resolved_ips: list[str] | None = None - dns_records: list[DNSRecord] | None = None - whois: WHOISRecord | None = None - certificates: list[CertificateInstance] | None = None - defenses: list[DefensiveControl] | None = None - exposures: list[Exposure] | None = None - scanned_ips: list[ScannedIP] | None = None + last_scanned_at: Optional[datetime] = None + apex_domain: Optional[str] = None + exposure_score: Optional[int] = None + is_static_asset: Optional[bool] = False + custom_tags: Optional[list[str]] = None + resolved_ips: Optional[list[str]] = None + dns_records: Optional[list[DNSRecord]] = None + whois: Optional[WHOISRecord] = None + certificates: Optional[list[CertificateInstance]] = None + defenses: Optional[list[DefensiveControl]] = None + exposures: Optional[list[Exposure]] = None + scanned_ips: Optional[list[ScannedIP]] = None def __str__(self) -> str: msg = 'Name: {}, Type: {}, Exposure Score: {}' @@ -249,10 +250,10 @@ class Project(RFBaseModel): id_: str = Field(alias='id') title: str - scanning_enabled: bool | None = None - last_scanned_at: datetime | None = None - inserted_at: datetime | None = None - max_exposure_score: int | None = None + scanning_enabled: Optional[bool] = None + last_scanned_at: Optional[datetime] = None + inserted_at: Optional[datetime] = None + max_exposure_score: Optional[int] = None def __str__(self) -> str: msg = 'Name: {}, Id: {}, Enabled: {}' diff --git a/psengine/asi/asi_mgr.py b/psengine/asi/asi_mgr.py index d706d018..0b34ca8a 100644 --- a/psengine/asi/asi_mgr.py +++ b/psengine/asi/asi_mgr.py @@ -13,7 +13,7 @@ import logging -from typing import Annotated, Literal +from typing import Annotated, Literal, Optional, Union from pydantic import AfterValidator, Field, validate_call from typing_extensions import Doc @@ -110,7 +110,7 @@ def __init__(self, api_token: str = None): def fetch_projects( self, sort_direction: Annotated[ - Literal['asc', 'desc'] | None, Doc('Sort direction for the projects') + Optional[Literal['asc', 'desc']], Doc('Sort direction for the projects') ] = 'asc', ) -> Annotated[ProjectListOut, Doc('List of ASI Project models')]: """Fetch ASI projects. @@ -136,28 +136,28 @@ def search_assets( self, project_id: Annotated[str, Doc('The ID of the ASI project to search assets within')], quick_search: Annotated[ - str | None, + Optional[str], Doc('Search term to match against asset name, IP addresses, and technology fields'), ] = None, asset_id: Annotated[ - str | None, + Optional[str], Doc( """Filter for the specific asset, which will be either a IP or domain value (examples: 192.88.99.2 or www.example.com).""" ), ] = None, asset_name: Annotated[ - str | None, Doc("""Filter on the name of the asset(IP address or domain).""") + Optional[str], Doc("""Filter on the name of the asset(IP address or domain).""") ] = None, asset_apex_domain: Annotated[ - str | list[str] | None, + Optional[Union[str, list[str]]], Doc( """Filter on the apex domain of the asset (example: example.com). Pass a single value or a list.""" ), ] = None, asset_discovered_date: Annotated[ - tuple[str | None, str | None] | None, + Optional[tuple[Optional[str], Optional[str]]], Doc( """Filter on the date (Y-m-d) the asset was discovered by Recorded Future ASI. This may be different than when the asset was added to the project. @@ -166,70 +166,71 @@ def search_assets( ), ] = None, asset_type: Annotated[ - AssetType | None, + Optional[AssetType], Doc( """The type of asset, one of: ip, domain and host (where domain and host represent the same asset type).""" ), ] = None, custom_tags: Annotated[ - list[str] | None, + Optional[list[str]], Doc('Filter for assets tagged with any of the provided custom tags.'), ] = None, is_static_asset: Annotated[ - bool | None, + Optional[bool], Doc( """Filter for assets that are static, meaning they have a consistent IP address or domain name over time.""" ), ] = None, exposure_severity: Annotated[ - ExposureSeverity | list[ExposureSeverity] | None, + Optional[Union[ExposureSeverity, list[ExposureSeverity]]], Doc("""Filter assets by exposure severity. Pass a single value or a list to match any of the provided severities."""), ] = None, exposure_signature_id: Annotated[ - str | list[str] | None, + Optional[Union[str, list[str]]], Doc("""Filter assets by ASI Signature ID. Pass a single ID or a list. Some signatures align with CVEs, e.g. "cve-2024-6387" or "cve-OpenSSH"."""), ] = None, exposure_score: Annotated[ - Annotated[ - tuple[Annotated[int, Field(ge=0, le=100)], Annotated[int, Field(ge=0, le=100)]], - AfterValidator(_validate_exposure_score_range), - ] - | None, + Optional[ + Annotated[ + tuple[Annotated[int, Field(ge=0, le=100)], Annotated[int, Field(ge=0, le=100)]], + AfterValidator(_validate_exposure_score_range), + ] + ], Doc("""Filter assets by exposure score range (0–100). Provide a (min, max) tuple. The score indicates potential asset risk based on various factors."""), ] = None, exposure_last_scanned: Annotated[ - tuple[str | None, str | None] | None, + Optional[tuple[Optional[str], Optional[str]]], Doc("""Filter assets by the date they were last scanned for exposures. Provide a (start, end) tuple of "YYYY-MM-DD" strings. Use None for an open-ended bound."""), ] = None, open_port_number: Annotated[ - int | list[int] | None, + Optional[Union[int, list[int]]], Doc( """Filter for assets which have an open port with the provided number (e.g. 80).""" ), ] = None, open_port_service: Annotated[ - str | list[str] | None, + Optional[Union[str, list[str]]], Doc( """Filter for assets which have an open port with the provided service (e.g. http, ftp, rdp).""" ), ] = None, open_port_protocol: Annotated[ - str | list[str] | None, + Optional[Union[str, list[str]]], Doc( """Filter for assets which have an open port with the provided protocol (e.g. tcp, udp).""" ), ] = None, technology_name: Annotated[ - str | list[str] | None, + Optional[Union[str, list[str]]], Doc( """Filter for the name of a technology found on the asset. Could be directly attached to the port (nginx, etc) or a web technology (e.g. 'jQuery', @@ -237,14 +238,14 @@ def search_assets( ), ] = None, certificate_issuer: Annotated[ - str | list[str] | None, + Optional[Union[str, list[str]]], Doc( """Filter where the certificate (or in the chain) issuer's common name or organization matches the provided value""" ), ] = None, is_responsive: Annotated[ - bool | None, + Optional[bool], Doc( """Filter for assets that are unresponsive over ICMP and no ports are open. This is a boolean filter, so it will return assets that are either responsive @@ -261,7 +262,7 @@ def search_assets( Doc('Number of assets to fetch per page'), ] = ASSETS_PER_PAGE, max_results: Annotated[ - int | None, Doc('Maximum number of assets to fetch') + Optional[int], Doc('Maximum number of assets to fetch') ] = DEFAULT_LIMIT, ) -> Annotated[AssetResponse, Doc('Response model for ASI assets search')]: """Search for assets within an ASI project. @@ -311,28 +312,29 @@ def search_assets( @debug_call def _lookup_filter( self, - quick_search: str | None = None, - asset_id: str | None = None, - asset_name: str | None = None, - asset_apex_domain: str | list[str] | None = None, - asset_type: AssetType | None = None, - asset_discovered_date: tuple[str | None, str | None] | None = None, - custom_tags: list[str] | None = None, - is_static_asset: bool | None = None, - open_port_number: int | list[int] | None = None, - open_port_service: str | list[str] | None = None, - open_port_protocol: str | list[str] | None = None, - technology_name: str | list[str] | None = None, - certificate_issuer: str | list[str] | None = None, - is_responsive: bool | None = None, - exposure_severity: ExposureSeverity | list[ExposureSeverity] | None = None, - exposure_signature_id: str | list[str] | None = None, - exposure_score: Annotated[ - tuple[Annotated[int, Field(ge=0, le=100)], Annotated[int, Field(ge=0, le=100)]], - AfterValidator(_validate_exposure_score_range), - ] - | None = None, - exposure_last_scanned: tuple[str | None, str | None] | None = None, + quick_search: Optional[str] = None, + asset_id: Optional[str] = None, + asset_name: Optional[str] = None, + asset_apex_domain: Optional[Union[str, list[str]]] = None, + asset_type: Optional[AssetType] = None, + asset_discovered_date: Optional[tuple[Optional[str], Optional[str]]] = None, + custom_tags: Optional[list[str]] = None, + is_static_asset: Optional[bool] = None, + open_port_number: Optional[Union[int, list[int]]] = None, + open_port_service: Optional[Union[str, list[str]]] = None, + open_port_protocol: Optional[Union[str, list[str]]] = None, + technology_name: Optional[Union[str, list[str]]] = None, + certificate_issuer: Optional[Union[str, list[str]]] = None, + is_responsive: Optional[bool] = None, + exposure_severity: Optional[Union[ExposureSeverity, list[ExposureSeverity]]] = None, + exposure_signature_id: Optional[Union[str, list[str]]] = None, + exposure_score: Optional[ + Annotated[ + tuple[Annotated[int, Field(ge=0, le=100)], Annotated[int, Field(ge=0, le=100)]], + AfterValidator(_validate_exposure_score_range), + ] + ] = None, + exposure_last_scanned: Optional[tuple[Optional[str], Optional[str]]] = None, ) -> AssetSearchFilterIn: """Create a query for filtering asset searches.""" params = {key: val for key, val in locals().items() if val is not None and key != 'self'} @@ -372,14 +374,14 @@ def search_exposures( self, project_id: Annotated[str, Doc('The ID of the ASI project to search assets within')], filter_cve_id: Annotated[ - str | None, + Optional[str], Doc( """Filter for asset or exposure tied to a vulnerability with the provided CVE. Example CVE-2024-6387.""" ), ] = None, filter_cvss_score_gte: Annotated[ - float | None, + Optional[float], Field(ge=0, le=10), Doc( """Filter for asset or exposure tied to a vulnerability with the provided CVSS @@ -387,7 +389,7 @@ def search_exposures( ), ] = None, filter_cvss_score_lte: Annotated[ - float | None, + Optional[float], Field(ge=0, le=10), Doc( """Filter for asset or exposure tied to a vulnerability with the provided CVSS @@ -395,27 +397,27 @@ def search_exposures( ), ] = None, filter_cwe_id: Annotated[ - str | None, + Optional[str], Doc( """Filter for asset or exposure tied to a vulnerability associated with the provided CWE. Example CWE-79.""" ), ] = None, filter_severity_exact: Annotated[ - SEVERITY_FILTER | None, + Optional[SEVERITY_FILTER], Doc('Filter for assets which have an exposure severity matching the provided value.'), ] = None, filter_severity_min: Annotated[ - SEVERITY_FILTER | None, + Optional[SEVERITY_FILTER], Doc( """Filter for assets which have an exposure severity matching or higher than the provided value.""" ), ] = None, max_results: Annotated[ - int | None, Doc('Maximum number of assets to fetch') + Optional[int], Doc('Maximum number of assets to fetch') ] = DEFAULT_LIMIT, - exposures_per_page: Annotated[int | None, Doc('Results per page')] = DEFAULT_LIMIT, + exposures_per_page: Annotated[Optional[int], Doc('Results per page')] = DEFAULT_LIMIT, ) -> Annotated[ExposureSearchOut, Doc('Response model for ASI exposures search')]: """Search for exposures within an ASI project. @@ -451,9 +453,9 @@ def fetch_exposures_by_signature( project_id: Annotated[str, Doc('The ID of the ASI project to search assets within')], signature_id: Annotated[str, Doc('The ID of the signature to search assets within')], max_results: Annotated[ - int | None, Doc('Maximum number of assets to fetch') + Optional[int], Doc('Maximum number of assets to fetch') ] = DEFAULT_LIMIT, - exposures_per_page: Annotated[int | None, Doc('Results per page')] = DEFAULT_LIMIT, + exposures_per_page: Annotated[Optional[int], Doc('Results per page')] = DEFAULT_LIMIT, ) -> Annotated[ AssetWithExposureSearch, Doc('ASI asset with exposure details for the requested signature') ]: @@ -504,49 +506,49 @@ def fetch_assets( Doc('The direction to sort by.'), ] = 'desc', asset_type: Annotated[ - Literal['domain', 'host', 'ip'] | None, + Optional[Literal['domain', 'host', 'ip']], Doc('The type of asset, one of: ip, domain, or host.'), ] = None, custom_tags: Annotated[ - str | None, + Optional[str], Doc('Filter by custom tags placed on your assets.'), ] = None, custom_tags_strict: Annotated[ - str | None, + Optional[str], Doc( 'Filter by custom tags placed on your assets. Strict version will return a ' 'validation error if any of the tags have not been defined on your project.' ), ] = None, has_custom_tags: Annotated[ - bool | None, + Optional[bool], Doc( 'Filter for assets that have at least one custom tag applied. Overrides any ' 'other custom tag filtering specified.' ), ] = None, added_to_project_before: Annotated[ - str | None, + Optional[str], Doc('Filter on the date (YYYY-MM-DD) the asset was added to the project.'), ] = None, added_to_project_after: Annotated[ - str | None, + Optional[str], Doc('Filter on the date (YYYY-MM-DD) the asset was added to the project.'), ] = None, discovered_before: Annotated[ - str | None, + Optional[str], Doc('Filter on the date (YYYY-MM-DD) the asset was discovered.'), ] = None, discovered_after: Annotated[ - str | None, + Optional[str], Doc('Filter on the date (YYYY-MM-DD) the asset was discovered.'), ] = None, apex: Annotated[ - str | None, + Optional[str], Doc('Filter on the apex domain of the assets. Example: example.com.'), ] = None, referenced_ip: Annotated[ - str | None, + Optional[str], Doc( 'Filter on an A or CNAME record pointing to the IP address. Use eq or in for ' 'exact IP matching. Use contains with a trailing . for CIDR range matching, ' @@ -554,204 +556,205 @@ def fetch_assets( ), ] = None, referenced_ip_before: Annotated[ - str | None, + Optional[str], Doc( 'If filtering on a referenced_ip, include additional criteria that the record ' 'existed during a date range. The reference must have started before this date.' ), ] = None, referenced_ip_after: Annotated[ - str | None, + Optional[str], Doc( 'If filtering on a referenced_ip, include additional criteria that the record ' 'existed during a date range. The reference must have existed after this date.' ), ] = None, has_dns_record_type: Annotated[ - str | None, + Optional[str], Doc('Filter for assets that have this DNS record type, e.g. A, CNAME, MX.'), ] = None, dns_resolves: Annotated[ - bool | None, + Optional[bool], Doc( 'Filter for assets that in the end resolve to a valid IP currently, either via ' 'an A or CNAME. IP assets are included when filtering for assets that resolve.' ), ] = None, asn: Annotated[ - int | None, + Optional[int], Doc( 'Filter for assets which either are, or point to, an IP address announced by ' 'the provided ASN.' ), ] = None, cname_reference: Annotated[ - str | None, + Optional[str], Doc( 'Filter on a domain that is referenced by a CNAME record. Only makes sense for ' 'domain asset types. Treated as a wildcard.' ), ] = None, geo_country_iso: Annotated[ - str | None, + Optional[str], Doc( 'Filter for assets which either are, or point to, an IP address located in the ' 'provided ISO country code.' ), ] = None, ip_owner: Annotated[ - str | None, + Optional[str], Doc( 'Filter for assets which either are, or point to, an IP address owned by the ' 'provided organization.' ), ] = None, whois_email: Annotated[ - str | None, + Optional[str], Doc('Filter for assets where the WHOIS email address matches the provided value.'), ] = None, whois_email_current: Annotated[ - str | None, + Optional[str], Doc( 'Filter for assets where the WHOIS email address matches the provided value on ' 'the current WHOIS record.' ), ] = None, open_port_number: Annotated[ - int | None, + Optional[int], Doc('Filter for assets which have an open port with the provided number.'), ] = None, open_port_protocol: Annotated[ - str | None, + Optional[str], Doc('Filter for assets which have an open port on the provided protocol.'), ] = None, open_port_service: Annotated[ - str | None, + Optional[str], Doc( 'Filter for assets which have an open port that appears to support the provided ' 'protocol.' ), ] = None, open_port_technology: Annotated[ - str | None, + Optional[str], Doc('Filter for assets which have a specific product listening on an open port.'), ] = None, technology_name: Annotated[ - str | None, + Optional[str], Doc('Filter for the name of a technology found on the asset.'), ] = None, web_technology_name: Annotated[ - str | None, + Optional[str], Doc( 'Filter for the name of a technology specifically associated with web ' 'resources, such as jQuery or Wordpress.' ), ] = None, certificate_issuer: Annotated[ - str | None, + Optional[str], Doc( "Filter where the certificate issuer's common name or organization matches the " 'provided value.' ), ] = None, certificate_expires_before: Annotated[ - str | None, + Optional[str], Doc('Filter where the certificate expiration date is before the provided value.'), ] = None, certificate_expires_after: Annotated[ - str | None, + Optional[str], Doc('Filter where the certificate expiration date is after the provided value.'), ] = None, certificate_issued_before: Annotated[ - str | None, + Optional[str], Doc('Filter where the certificate issuance date is before the provided value.'), ] = None, certificate_issued_after: Annotated[ - str | None, + Optional[str], Doc('Filter where the certificate issuance date is after the provided value.'), ] = None, certificate_subject: Annotated[ - str | None, + Optional[str], Doc('Filter where certificate subject or organizationName matches the value.'), ] = None, certificate_subject_alt_name: Annotated[ - str | None, + Optional[str], Doc('Filter where the certificate Subject Alternative Name matches the value.'), ] = None, certificate_sha256: Annotated[ - str | None, + Optional[str], Doc('Filter where the certificate public key sha256 value matches the value.'), ] = None, certificate_covers_domain: Annotated[ - str | None, + Optional[str], Doc( 'Filter where the certificate subject common name or SAN exactly matches or ' 'wildcard-covers the provided value.' ), ] = None, waf_detected: Annotated[ - bool | None, + Optional[bool], Doc('Filter for assets where a WAF is detected.'), ] = None, waf_name: Annotated[ - str | None, + Optional[str], Doc('Filter for assets where a specific WAF is detected.'), ] = None, is_responsive: Annotated[ - bool | None, + Optional[bool], Doc( 'Filter for assets that are either responsive or not responsive over ICMP and ' 'port scanning.' ), ] = None, exposure_score_gte: Annotated[ - int | None, + Optional[int], Field(ge=0, le=100), Doc('Filter for assets with exposure score greater than or equal to this value.'), ] = None, exposure_score_lte: Annotated[ - int | None, + Optional[int], Field(ge=0, le=100), Doc('Filter for assets with exposure score less than or equal to this value.'), ] = None, exposure_severity: Annotated[ - Literal['unknown', 'informational', 'moderate', 'critical'] | None, + Optional[Literal['unknown', 'informational', 'moderate', 'critical']], Doc( 'Filter for assets with an exposure severity matching or higher than the ' 'provided value.' ), ] = None, exposure_id: Annotated[ - str | None, + Optional[str], Doc('Filter for assets which have an exposure with the provided ASI Signature ID.'), ] = None, additional_fields: Annotated[ - list[ - Literal[ - 'custom_tags', - 'dns_records', - 'whois', - 'ip_metadata', - 'open_tcp_ports', - 'open_udp_ports', - 'web_technologies', - 'certificates', - 'certificate_chain', - 'defenses', - 'exposures', - 'exposure_instance_details', + Optional[ + list[ + Literal[ + 'custom_tags', + 'dns_records', + 'whois', + 'ip_metadata', + 'open_tcp_ports', + 'open_udp_ports', + 'web_technologies', + 'certificates', + 'certificate_chain', + 'defenses', + 'exposures', + 'exposure_instance_details', + ] ] - ] - | None, + ], Doc( 'Additional fields to include in the response. May be specified multiple times ' 'or as a comma-separated list in the raw API.' ), ] = None, max_results: Annotated[ - int | None, Doc('Maximum number of assets to fetch') + Optional[int], Doc('Maximum number of assets to fetch') ] = DEFAULT_LIMIT, - assets_per_page: Annotated[int | None, Doc('Results per page')] = DEFAULT_LIMIT, + assets_per_page: Annotated[Optional[int], Doc('Results per page')] = DEFAULT_LIMIT, ) -> Annotated[AssetResponse, Doc('Response model for ASI assets list')]: """Fetch assets within an ASI project. @@ -787,23 +790,24 @@ def fetch_asset( project_id: Annotated[str, Doc('The ID of the ASI project to search assets within')], asset_id: Annotated[str, Doc('The asset ID to search for.')], additional_fields: Annotated[ - list[ - Literal[ - 'custom_tags', - 'dns_records', - 'whois', - 'ip_metadata', - 'open_tcp_ports', - 'open_udp_ports', - 'web_technologies', - 'certificates', - 'certificate_chain', - 'defenses', - 'exposures', - 'exposure_instance_details', + Optional[ + list[ + Literal[ + 'custom_tags', + 'dns_records', + 'whois', + 'ip_metadata', + 'open_tcp_ports', + 'open_udp_ports', + 'web_technologies', + 'certificates', + 'certificate_chain', + 'defenses', + 'exposures', + 'exposure_instance_details', + ] ] - ] - | None, + ], Doc( 'Additional fields to include in the response. May be specified multiple times ' 'or as a comma-separated list in the raw API.' diff --git a/psengine/asi/client.py b/psengine/asi/client.py index 50ba17b0..cfc72ef5 100644 --- a/psengine/asi/client.py +++ b/psengine/asi/client.py @@ -13,7 +13,7 @@ import re from copy import deepcopy -from typing import Annotated, Any +from typing import Annotated, Any, Optional, Union from pydantic import Field, validate_call from requests.exceptions import JSONDecodeError @@ -43,17 +43,17 @@ class ASIClient(BaseHTTPClient): def __init__( self, api_token: Annotated[ - str | None, + Union[str, None], Doc('A Recorded Future ASI API key.'), ] = None, http_proxy: Annotated[str, Doc('An HTTP proxy URL.')] = None, https_proxy: Annotated[str, Doc('An HTTPS proxy URL.')] = None, verify: Annotated[ - str | bool, + Union[str, bool], Doc('An SSL verification flag or path to CA bundle.'), ] = None, auth: Annotated[tuple[str, str], Doc('Basic Auth credentials.')] = None, - cert: Annotated[str | tuple[str, str] | None, Doc('Client certificates.')] = None, + cert: Annotated[Union[str, tuple[str, str], None], Doc('Client certificates.')] = None, timeout: Annotated[int, Doc('A request timeout. Defaults to 120.')] = None, retries: Annotated[int, Doc('A number of retries. Defaults to 5.')] = None, backoff_factor: Annotated[int, Doc('A backoff factor. Defaults to 1.')] = None, @@ -92,11 +92,11 @@ def request( str, Doc('An HTTP method, one of GET, PUT, POST, DELETE, HEAD, OPTIONS, PATCH.') ], url: Annotated[str, Doc('A URL or API path to make the request to.')], - data: Annotated[dict | list[dict] | bytes | None, Doc('A request body.')] = None, + data: Annotated[Union[dict, list[dict], bytes, None], Doc('A request body.')] = None, *, - params: Annotated[dict | None, Doc('HTTP query parameters.')] = None, + params: Annotated[Optional[dict], Doc('HTTP query parameters.')] = None, headers: Annotated[ - dict | None, + Optional[dict], Doc('If specified, it overrides default headers and does not set the API key.'), ] = None, **kwargs, @@ -119,17 +119,17 @@ def request_paged( # noqa: C901 self, method: Annotated[str, Doc('An HTTP method. Supports GET and POST.')], url: Annotated[str, Doc('A URL or API path to make the request to.')], - data: Annotated[dict | None, Doc('A request body.')] = None, + data: Annotated[Optional[dict], Doc('A request body.')] = None, *, - params: Annotated[dict | None, Doc('HTTP query parameters.')] = None, + params: Annotated[Optional[dict], Doc('HTTP query parameters.')] = None, headers: Annotated[ - dict | None, + Optional[dict], Doc('If specified, it overrides default headers and does not set the API key.'), ] = None, max_results: Annotated[ int, Doc('The maximum number of results to return.') ] = DEFAULT_LIMIT, - objects_per_page: Annotated[int | None, Doc('Requested page size.')] = Field( + objects_per_page: Annotated[Optional[int], Doc('Requested page size.')] = Field( ge=1, le=MAX_ASI_PAGE_SIZE, default=DEFAULT_ASI_PAGE_SIZE ), **kwargs, @@ -252,10 +252,10 @@ def request_paged( # noqa: C901 def _initialize_paged_request( self, method: str, - params: dict | None, - data: dict | None, - limit: int | None, - ) -> tuple[dict, dict | list[dict] | bytes | None]: + params: Optional[dict], + data: Optional[dict], + limit: Optional[int], + ) -> tuple[dict, Union[dict, list[dict], bytes, None]]: request_params = deepcopy(params) if params else {} request_data = deepcopy(data) diff --git a/psengine/asi/models.py b/psengine/asi/models.py index 7a6113d5..23b8d5ba 100644 --- a/psengine/asi/models.py +++ b/psengine/asi/models.py @@ -13,7 +13,7 @@ from datetime import date, datetime from enum import Enum -from typing import Any, Generic, Optional, TypeVar +from typing import Any, Generic, Optional, TypeVar, Union from pydantic import Field @@ -84,38 +84,38 @@ class ContainsFilter(RFBaseModel): class RangeFilter(RFBaseModel, Generic[FilterValueT]): - start: FilterValueT | None = None - end: FilterValueT | None = None + start: Optional[FilterValueT] = None + end: Optional[FilterValueT] = None class PaginationResponse(RFBaseModel): - next_cursor: str | None = None - limit: int | None = 50 - total: int | None = None - sort: list[list[str]] | None = None + next_cursor: Optional[str] = None + limit: Optional[int] = 50 + total: Optional[int] = None + sort: Optional[list[list[str]]] = None class ApiCount(RFBaseModel): returned: int - total: int | None = None + total: Optional[int] = None class ApiMeta(RFBaseModel): - counts: ApiCount | None = None - pagination: PaginationResponse | None = None - request_id: str | None = None + counts: Optional[ApiCount] = None + pagination: Optional[PaginationResponse] = None + request_id: Optional[str] = None class Pagination(RFBaseModel): - next_cursor: str | None = None - limit: int | None = 50 + next_cursor: Optional[str] = None + limit: Optional[int] = 50 class CertificateEntity(RFBaseModel): - common_name: str | None = None - organization_name: str | None = None - organizational_unit_name: str | None = None - country_name: str | None = None + common_name: Optional[str] = None + organization_name: Optional[str] = None + organizational_unit_name: Optional[str] = None + country_name: Optional[str] = None class Certificate(RFBaseModel): @@ -123,36 +123,36 @@ class Certificate(RFBaseModel): issued_at: datetime sha256: str subject: CertificateEntity - subject_alt_names: list[str] | None = None - issuer: CertificateEntity | None = None - chain: list['Certificate'] | None = None - signature_algorithm: str | None = None + subject_alt_names: Optional[list[str]] = None + issuer: Optional[CertificateEntity] = None + chain: Optional[list['Certificate']] = None + signature_algorithm: Optional[str] = None class ExposureInstance(RFBaseModel): port_number: int - url: str | None = None + url: Optional[str] = None class VulnerabilityPublic(RFBaseModel): name: str slug: str - cvss_score: float | None = None - cvss_metrics: str | None = None + cvss_score: Optional[float] = None + cvss_metrics: Optional[str] = None references: list[str] - cve_id: str | None = None - cwe_ids: list[str | None] | None = None - epss_score: float | None = None + cve_id: Optional[str] = None + cwe_ids: Optional[list[Optional[str]]] = None + epss_score: Optional[float] = None class ExposureSignature(RFBaseModel): id_: str = Field(alias='id') name: str - description: str | None - severity: ExposureSeverity | None - references: list[str] | None - added_at: datetime | None = None - vulnerabilities: list[VulnerabilityPublic] | None = None + description: Optional[str] + severity: Optional[ExposureSeverity] + references: Optional[list[str]] + added_at: Optional[datetime] = None + vulnerabilities: Optional[list[VulnerabilityPublic]] = None class AssetExposure(RFBaseModel): @@ -165,177 +165,181 @@ class AssetWithExposure(RFBaseModel): asset_id: str details: Any instances: list[ExposureInstance] - signature: ExposureSignature | None = None + signature: Optional[ExposureSignature] = None class Exposure(RFBaseModel): id_: str = Field(alias='id') - detection_id: str | None + detection_id: Optional[str] severity: ExposureSeverity instances: list[ExposureInstance] - supports_evidence: bool | None = None + supports_evidence: Optional[bool] = None class GeoLocation(RFBaseModel): - continent: str | None = None - country: str | None = None - city: str | None = None - country_iso: str | None = None + continent: Optional[str] = None + country: Optional[str] = None + city: Optional[str] = None + country_iso: Optional[str] = None class CertificatePropertiesFilter(RFBaseModel): - certificate_subject: ContainsFilter | EqFilter[str] | InFilter[str] | None = None - certificate_subject_alt_name: ContainsFilter | EqFilter[str] | InFilter[str] | None = None - certificate_sha256: EqFilter[str] | None = None - certificate_expires_at: RangeFilter[date] | None = None - certificate_issued_at: RangeFilter[date] | None = None - certificate_issuer: EqFilter[str] | InFilter[str] | None = None - certificate_covers_domain: ContainsFilter | EqFilter[str] | InFilter[str] | None = None + certificate_subject: Optional[Union[ContainsFilter, EqFilter[str], InFilter[str]]] = None + certificate_subject_alt_name: Optional[Union[ContainsFilter, EqFilter[str], InFilter[str]]] = ( + None + ) + certificate_sha256: Optional[EqFilter[str]] = None + certificate_expires_at: Optional[RangeFilter[date]] = None + certificate_issued_at: Optional[RangeFilter[date]] = None + certificate_issuer: Optional[Union[EqFilter[str], InFilter[str]]] = None + certificate_covers_domain: Optional[Union[ContainsFilter, EqFilter[str], InFilter[str]]] = None class ExposurePropertiesFilter(RFBaseModel): - severity: EqFilter[ExposureSeverity] | InFilter[ExposureSeverity] | None = None - signature_id: EqFilter[str] | InFilter[str] | None = None - asset_exposure_score: RangeFilter[int] | None = None - last_scanned_at: RangeFilter[date] | None = None + severity: Optional[Union[EqFilter[ExposureSeverity], InFilter[ExposureSeverity]]] = None + signature_id: Optional[Union[EqFilter[str], InFilter[str]]] = None + asset_exposure_score: Optional[RangeFilter[int]] = None + last_scanned_at: Optional[RangeFilter[date]] = None class IPMetadata(RFBaseModel): - as_number: int | None = None - owner_name: str | None = None - registry: str | None = None - owner_geo: GeoLocation | None = None + as_number: Optional[int] = None + owner_name: Optional[str] = None + registry: Optional[str] = None + owner_geo: Optional[GeoLocation] = None class AssetPropertiesFilter(RFBaseModel): - asset_id: EqFilter[str] | None = None - name: ContainsFilter | None = None - static_asset: EqFilter[bool] | None = None - apex: EqFilter[str] | InFilter[str] | None = None - added_to_project: RangeFilter[date] | None = None - discovered: RangeFilter[date] | None = None - asset_type: EqFilter[str] | None = None - referenced_ip: ContainsFilter | EqFilter[str] | InFilter[str] | None = None - cname_reference: ContainsFilter | EqFilter[str] | None = None - referenced_ip_at: RangeFilter[date] | None = None - valid_record_type: EqFilter[str] | InFilter[str] | NeqFilter[str] | None = None - dns_resolves: EqFilter[bool] | None = None - custom_tags: EqFilter[str] | InFilter[str] | RequireAllFilter[str] | None = None - custom_tags_strict: EqFilter[str] | InFilter[str] | RequireAllFilter[str] | None = None - asn: EqFilter[int] | InFilter[int] | None = None - ip_geo_country_iso: EqFilter[str] | InFilter[str] | None = None - ip_owner: EqFilter[str] | InFilter[str] | None = None - registry: EqFilter[str] | InFilter[str] | None = None - whois_email_current: EqFilter[str] | InFilter[str] | None = None - whois_email: EqFilter[str] | InFilter[str] | None = None + asset_id: Optional[EqFilter[str]] = None + name: Optional[ContainsFilter] = None + static_asset: Optional[EqFilter[bool]] = None + apex: Optional[Union[EqFilter[str], InFilter[str]]] = None + added_to_project: Optional[RangeFilter[date]] = None + discovered: Optional[RangeFilter[date]] = None + asset_type: Optional[EqFilter[str]] = None + referenced_ip: Optional[Union[ContainsFilter, EqFilter[str], InFilter[str]]] = None + cname_reference: Optional[Union[ContainsFilter, EqFilter[str]]] = None + referenced_ip_at: Optional[RangeFilter[date]] = None + valid_record_type: Optional[Union[EqFilter[str], InFilter[str], NeqFilter[str]]] = None + dns_resolves: Optional[EqFilter[bool]] = None + custom_tags: Optional[Union[EqFilter[str], InFilter[str], RequireAllFilter[str]]] = None + custom_tags_strict: Optional[Union[EqFilter[str], InFilter[str], RequireAllFilter[str]]] = None + asn: Optional[Union[EqFilter[int], InFilter[int]]] = None + ip_geo_country_iso: Optional[Union[EqFilter[str], InFilter[str]]] = None + ip_owner: Optional[Union[EqFilter[str], InFilter[str]]] = None + registry: Optional[Union[EqFilter[str], InFilter[str]]] = None + whois_email_current: Optional[Union[EqFilter[str], InFilter[str]]] = None + whois_email: Optional[Union[EqFilter[str], InFilter[str]]] = None class TechnologyInstance(RFBaseModel): seen_at: datetime seen_port: int - seen_url: str | None = None + seen_url: Optional[str] = None class DefensiveControl(RFBaseModel): name: str - vendor: str | None = None - technology_type: str | None = None - version: str | None = None - instances: list[TechnologyInstance] | None = None + vendor: Optional[str] = None + technology_type: Optional[str] = None + version: Optional[str] = None + instances: Optional[list[TechnologyInstance]] = None class TechnologyPropertiesFilter(RFBaseModel): - open_port_number: EqFilter[int] | InFilter[int] | None = None - open_port_service: EqFilter[str] | InFilter[str] | None = None - open_port_protocol: EqFilter[str] | InFilter[str] | None = None - open_port_technology: EqFilter[str] | InFilter[str] | None = None - waf_detected: EqFilter[bool] | None = None - waf_name: EqFilter[str] | InFilter[str] | None = None - technology_name: EqFilter[str] | InFilter[str] | None = None - web_technology_name: EqFilter[str] | InFilter[str] | None = None - is_responsive: EqFilter[bool] | None = None + open_port_number: Optional[Union[EqFilter[int], InFilter[int]]] = None + open_port_service: Optional[Union[EqFilter[str], InFilter[str]]] = None + open_port_protocol: Optional[Union[EqFilter[str], InFilter[str]]] = None + open_port_technology: Optional[Union[EqFilter[str], InFilter[str]]] = None + waf_detected: Optional[EqFilter[bool]] = None + waf_name: Optional[Union[EqFilter[str], InFilter[str]]] = None + technology_name: Optional[Union[EqFilter[str], InFilter[str]]] = None + web_technology_name: Optional[Union[EqFilter[str], InFilter[str]]] = None + is_responsive: Optional[EqFilter[bool]] = None class AssetSearchFilterIn(RFBaseModel): - asset_properties: AssetPropertiesFilter | None = None - certificate_properties: CertificatePropertiesFilter | None = None - exposure_properties: ExposurePropertiesFilter | None = None - technology_properties: TechnologyPropertiesFilter | None = None - quick_search: QuickSearchFilter | None = None + asset_properties: Optional[AssetPropertiesFilter] = None + certificate_properties: Optional[CertificatePropertiesFilter] = None + exposure_properties: Optional[ExposurePropertiesFilter] = None + technology_properties: Optional[TechnologyPropertiesFilter] = None + quick_search: Optional[QuickSearchFilter] = None class AssetSearchRequest(RFBaseModel): - filter_: AssetSearchFilterIn | None = Field(None, alias='filter') - pagination: Pagination | None = None - enrichments: list[AssetEnrichment] | None = None - sort: list[AssetSortField] | list[list[AssetSortField | SortDirection]] | None = None + filter_: Optional[AssetSearchFilterIn] = Field(None, alias='filter') + pagination: Optional[Pagination] = None + enrichments: Optional[list[AssetEnrichment]] = None + sort: Optional[ + Union[list[AssetSortField], list[list[Union[AssetSortField, SortDirection]]]] + ] = None class TechnologyWithInstances(RFBaseModel): name: str - vendor: str | None = None - technology_type: str | None = None - version: str | None = None - instances: list[TechnologyInstance] | None = None + vendor: Optional[str] = None + technology_type: Optional[str] = None + version: Optional[str] = None + instances: Optional[list[TechnologyInstance]] = None class PortInstance(RFBaseModel): seen_ip: str seen_at: datetime - service: str | None = None - technology: TechnologyWithInstances | None = None - web_technologies: list[TechnologyWithInstances] | None = None - exposures: list[Exposure] | None = None - defenses: list[DefensiveControl] | None = None + service: Optional[str] = None + technology: Optional[TechnologyWithInstances] = None + web_technologies: Optional[list[TechnologyWithInstances]] = None + exposures: Optional[list[Exposure]] = None + defenses: Optional[list[DefensiveControl]] = None class Port(RFBaseModel): port: int protocol: str - instances: list[PortInstance] | None = None - certificate: Certificate | None = None + instances: Optional[list[PortInstance]] = None + certificate: Optional[Certificate] = None class CertificateInstance(RFBaseModel): certificate: Certificate - seen_ports: list[Port] | None = None + seen_ports: Optional[list[Port]] = None class ScannedIP(RFBaseModel): ip: str - last_scanned_at: datetime | None = None + last_scanned_at: Optional[datetime] = None whois: Optional['WHOISRecord'] = None - open_ports: list[Port] | None = None - metadata: IPMetadata | None = None - is_responsive: bool | None = None + open_ports: Optional[list[Port]] = None + metadata: Optional[IPMetadata] = None + is_responsive: Optional[bool] = None class WHOISContact(RFBaseModel): - email: str | None = None - name: str | None = None - organization: str | None = None - is_current: bool | None = True + email: Optional[str] = None + name: Optional[str] = None + organization: Optional[str] = None + is_current: Optional[bool] = True class WHOISRecord(RFBaseModel): - registrar: str | None = None - expires_at: datetime | None = None - updated_at: datetime | None = None - created_at: datetime | None = None - is_private: bool | None = None - is_from_parent: bool | None = False - contacts: list[WHOISContact] | None = None - name_servers: list[str] | None = None + registrar: Optional[str] = None + expires_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + created_at: Optional[datetime] = None + is_private: Optional[bool] = None + is_from_parent: Optional[bool] = False + contacts: Optional[list[WHOISContact]] = None + name_servers: Optional[list[str]] = None class DNSValue(RFBaseModel): value: Any - last_resolved_at: datetime | None - seen_from: list[str] | None = None - first_seen_at: datetime | None = None + last_resolved_at: Optional[datetime] + seen_from: Optional[list[str]] = None + first_seen_at: Optional[datetime] = None class DNSRecord(RFBaseModel): record_type: str - value: list[DNSValue] | None - is_virtual: bool | None = False + value: Optional[list[DNSValue]] + is_virtual: Optional[bool] = False diff --git a/psengine/base_http_client.py b/psengine/base_http_client.py index 837bc784..638779ba 100644 --- a/psengine/base_http_client.py +++ b/psengine/base_http_client.py @@ -13,7 +13,7 @@ import json import logging -from typing import Annotated +from typing import Annotated, Union from pydantic import validate_call from requests import ( @@ -51,10 +51,10 @@ def __init__( http_proxy: Annotated[str, Doc('An HTTP proxy URL.')] = None, https_proxy: Annotated[str, Doc('An HTTPS proxy URL.')] = None, verify: Annotated[ - str | bool, Doc('An SSL verification flag or path to CA bundle.') + Union[str, bool], Doc('An SSL verification flag or path to CA bundle.') ] = SSL_VERIFY, auth: Annotated[tuple[str, str], Doc('Basic Auth credentials.')] = None, - cert: Annotated[str | tuple[str, str] | None, Doc('Client certificates.')] = None, + cert: Annotated[Union[str, tuple[str, str], None], Doc('Client certificates.')] = None, timeout: Annotated[int, Doc('A request timeout.')] = REQUEST_TIMEOUT, retries: Annotated[int, Doc('A number of retries.')] = RETRY_TOTAL, backoff_factor: Annotated[int, Doc('A backoff factor.')] = BACKOFF_FACTOR, @@ -97,11 +97,11 @@ def call( self, method: Annotated[str, Doc('An HTTP method.')], url: Annotated[str, Doc('A URL to make the request to.')], - data: Annotated[dict | list[dict] | bytes | None, Doc('A request body.')] = None, + data: Annotated[Union[dict, list[dict], bytes, None], Doc('A request body.')] = None, *, - params: Annotated[dict | None, Doc('HTTP query parameters.')] = None, + params: Annotated[Union[dict, None], Doc('HTTP query parameters.')] = None, headers: Annotated[ - dict | None, + Union[dict, None], Doc('If specified, overrides default headers and does not set the token.'), ] = None, **kwargs, @@ -260,15 +260,14 @@ def _choose_method_type(self, method: str): def _get_user_agent_header(self): os_info = OSHelpers.os_platform() - app_id = self.config.app_id or 'app_id/0.0.0' - platform_id = self.config.platform_id + app_id = self.config.app_id or 'app_id unknown' + platform_id = self.config.platform_id or 'platform_id unknown' user_agent_list = [] user_agent_list.append(app_id) if os_info is not None: user_agent_list.append(f'({os_info})') user_agent_list.append(SDK_ID) - if platform_id: - user_agent_list.append(platform_id) + user_agent_list.append(platform_id) return ' '.join(user_agent_list) diff --git a/psengine/classic_alerts/classic_alert.py b/psengine/classic_alerts/classic_alert.py index ba6d4a87..c49b84a7 100644 --- a/psengine/classic_alerts/classic_alert.py +++ b/psengine/classic_alerts/classic_alert.py @@ -15,7 +15,7 @@ from datetime import datetime from functools import total_ordering from itertools import chain -from typing import Annotated +from typing import Annotated, Optional from pydantic import Field, field_validator from typing_extensions import Doc @@ -72,17 +72,17 @@ class ClassicAlert(RFBaseModel): id_: str = Field(alias='id') log: AlertLog title: str - review: AlertReview | None = None - owner_organisation_details: OwnerOrganisationDetails | None = None - url: AlertURL | None = None - rule: AlertDeprecation | None = None - hits: list[ClassicAlertHit] | None = None - enriched_entities: list[EnrichedEntity] | None = None - ai_insights: AlertAiInsight | None = None + review: Optional[AlertReview] = None + owner_organisation_details: Optional[OwnerOrganisationDetails] = None + url: Optional[AlertURL] = None + rule: Optional[AlertDeprecation] = None + hits: Optional[list[ClassicAlertHit]] = None + enriched_entities: Optional[list[EnrichedEntity]] = None + ai_insights: Optional[AlertAiInsight] = None type_: str = Field(alias='type', default=None) - triggered_by: list[TriggeredBy] | None = None + triggered_by: Optional[list[TriggeredBy]] = None - _images: dict | None = {} + _images: Optional[dict] = {} @field_validator('triggered_by', mode='before') @classmethod @@ -206,7 +206,7 @@ def markdown( fragment_entities: Annotated[bool, Doc('Include fragment entities.')] = True, triggered_by: Annotated[bool, Doc('Include triggered by.')] = True, html_tags: Annotated[bool, Doc('Include HTML tags in the markdown.')] = False, - character_limit: Annotated[int | None, Doc('Character limit for the markdown.')] = None, + character_limit: Annotated[Optional[int], Doc('Character limit for the markdown.')] = None, defang_iocs: Annotated[bool, Doc('Defang IOCs in hits.')] = False, ) -> Annotated[str, Doc('Markdown representation of the alert.')]: """Return a markdown string representation of the `ClassicAlert` instance. diff --git a/psengine/classic_alerts/classic_alert_mgr.py b/psengine/classic_alerts/classic_alert_mgr.py index 3d1fb667..61f9aa2a 100644 --- a/psengine/classic_alerts/classic_alert_mgr.py +++ b/psengine/classic_alerts/classic_alert_mgr.py @@ -13,7 +13,7 @@ import logging from itertools import chain -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import Field, validate_call from typing_extensions import Doc @@ -58,25 +58,27 @@ def __init__(self, rf_token: str = None): def search( self, triggered: Annotated[ - str | None, Doc('Filter on triggered time. Format: -1d or [2017-07-30,2017-07-31].') + Optional[str], Doc('Filter on triggered time. Format: -1d or [2017-07-30,2017-07-31].') ] = None, status: Annotated[ - str | None, + Optional[str], Doc('Filter on status, such as: `New`, `Resolved`, `Pending`, `Dismissed`.'), ] = None, rule_id: Annotated[ - str | list[str] | None, Doc('Filter by a specific Alert Rule ID.') + Union[str, list[str], None], Doc('Filter by a specific Alert Rule ID.') ] = None, - freetext: Annotated[str | None, Doc('Filter by a freetext search.')] = None, + freetext: Annotated[Optional[str], Doc('Filter by a freetext search.')] = None, tagged_text: Annotated[ - bool | None, Doc('Entities in the alert title and message body will be marked up.') + Optional[bool], Doc('Entities in the alert title and message body will be marked up.') ] = None, order_by: Annotated[ - str | None, Doc('Sort by a specific field, such as: `triggered`.') + Optional[str], Doc('Sort by a specific field, such as: `triggered`.') + ] = None, + direction: Annotated[ + Optional[str], Doc('Sort direction, such as: `asc` or `desc`.') ] = None, - direction: Annotated[str | None, Doc('Sort direction, such as: `asc` or `desc`.')] = None, fields: Annotated[ - list[str] | None, + Optional[list[str]], Doc( """ Fields to include in the search result. @@ -88,10 +90,10 @@ def search( ), ] = REQUIRED_CA_FIELDS, max_results: Annotated[ - int | None, Doc('Maximum number of records to return. Maximum 1000.') + Optional[int], Doc('Maximum number of records to return. Maximum 1000.') ] = Field(ge=1, le=1000, default=DEFAULT_LIMIT), max_workers: Annotated[ - int | None, + Optional[int], Doc( """ Number of workers to use for concurrent fetches. @@ -100,7 +102,7 @@ def search( ), ] = Field(ge=0, le=50, default=0), alerts_per_page: Annotated[ - int | None, Doc('Number of items to retrieve per page.') + Optional[int], Doc('Number of items to retrieve per page.') ] = Field(ge=1, le=1000, default=ALERTS_PER_PAGE), ) -> Annotated[list[ClassicAlert], Doc('List of ClassicAlert models.')]: """Search for triggered alerts. @@ -152,7 +154,7 @@ def fetch( self, id_: Annotated[str, Doc('The alert ID to be fetched.')] = Field(min_length=4), fields: Annotated[ - list[str] | None, + Optional[list[str]], Doc( """ Fields to include in the fetch result. @@ -165,10 +167,9 @@ def fetch( ), ] = ALL_CA_FIELDS, tagged_text: Annotated[ - bool | None, + Optional[bool], Doc('Entities in the alert title and message body will be marked up with entity IDs.'), ] = None, - fetch_images: Annotated[bool | None, Doc('Fetch images for alerts.')] = False, ) -> Annotated[ClassicAlert, Doc('ClassicAlert model.')]: """Fetch a specific alert. @@ -193,7 +194,6 @@ def fetch( Raises: ValidationError: If any supplied parameter is of incorrect type. AlertFetchError: If a fetch of the alert via the API fails. - AlertImageFetchError: If a fetch of the alert image via the API fails. """ params = {} params['fields'] = set((fields or []) + REQUIRED_CA_FIELDS) @@ -206,10 +206,7 @@ def fetch( response = self.rf_client.request( 'get', url=EP_CLASSIC_ALERTS_ID.format(id_), params=params ).json() - alert = ClassicAlert.model_validate(response.get('data')) - if fetch_images: - self.fetch_all_images(alert) - return alert + return ClassicAlert.model_validate(response.get('data')) @debug_call @validate_call @@ -217,7 +214,7 @@ def fetch_bulk( self, ids: Annotated[list[str], Doc('Alert IDs that should be fetched.')], fields: Annotated[ - list[str] | None, + Optional[list[str]], Doc( """ Fields to include in the fetch result. @@ -230,11 +227,12 @@ def fetch_bulk( ), ] = ALL_CA_FIELDS, tagged_text: Annotated[ - bool | None, + Optional[bool], Doc('Entities in the alert title and message body will be marked up with entity IDs.'), ] = None, - fetch_images: Annotated[bool | None, Doc('Fetch images for alerts.')] = False, - max_workers: Annotated[int | None, Doc('Number of workers to multithread requests.')] = 0, + max_workers: Annotated[ + Optional[int], Doc('Number of workers to multithread requests.') + ] = 0, ) -> Annotated[list[ClassicAlert], Doc('List of ClassicAlert models.')]: """Fetch multiple alerts. @@ -272,7 +270,6 @@ def fetch_bulk( Raises: ValidationError: If any supplied parameter is of incorrect type. AlertFetchError: If a fetch of the alert via the API fails. - AlertImageFetchError: If a fetch of the alert image via the API fails. """ self.log.info(f'Fetching alerts: {ids}') results = [] @@ -283,10 +280,9 @@ def fetch_bulk( iterator=ids, fields=fields, tagged_text=tagged_text, - fetch_images=fetch_images, ) else: - results = [self.fetch(id_, fields, tagged_text, fetch_images) for id_ in ids] + results = [self.fetch(id_, fields, tagged_text) for id_ in ids] return results @@ -295,9 +291,9 @@ def fetch_bulk( @connection_exceptions(ignore_status_code=[], exception_to_raise=AlertFetchError) def fetch_hits( self, - ids: Annotated[str | list[str], Doc('One or more alert IDs to fetch.')], + ids: Annotated[Union[str, list[str]], Doc('One or more alert IDs to fetch.')], tagged_text: Annotated[ - bool | None, + Optional[bool], Doc('Entities in the alert title and message body will be marked up with entity IDs.'), ] = None, ) -> Annotated[list[ClassicAlertHit], Doc('List of ClassicAlertHit models.')]: @@ -359,7 +355,6 @@ def fetch_all_images( Raises: ValidationError: If any supplied parameter is of incorrect type. - AlertImageFetchError: If a fetch of the alert image via the API fails. """ for hit in alert.hits: for entity in hit.entities: @@ -370,7 +365,9 @@ def fetch_all_images( @validate_call def fetch_rules( self, - freetext: Annotated[str | list[str] | None, Doc('Filter by a freetext search.')] = None, + freetext: Annotated[ + Union[str, list[str], None], Doc('Filter by a freetext search.') + ] = None, max_results: Annotated[ int, Doc('Maximum number of rules to return. Maximum 1000.') ] = Field(default=DEFAULT_LIMIT, ge=1, le=1000), @@ -429,7 +426,7 @@ def update( @validate_call def update_status( self, - ids: Annotated[str | list[str], Doc('One or more alert IDs.')], + ids: Annotated[Union[str, list[str]], Doc('One or more alert IDs.')], status: Annotated[str, Doc('Status to update to.')], ): """Update the status of one or several alerts. @@ -448,8 +445,8 @@ def update_status( @connection_exceptions(ignore_status_code=[], exception_to_raise=NoRulesFoundError) def _fetch_rules( self, - freetext: str | None = None, - max_results: int | None = Field(default=DEFAULT_LIMIT, ge=1, le=1000), + freetext: Optional[str] = None, + max_results: Optional[int] = Field(default=DEFAULT_LIMIT, ge=1, le=1000), ) -> list[AlertRuleOut]: data = {} @@ -468,7 +465,7 @@ def _fetch_rules( def _search( self, - rule_id: str | None = None, + rule_id: Optional[str] = None, *, triggered, status, diff --git a/psengine/classic_alerts/helpers.py b/psengine/classic_alerts/helpers.py index 372b7077..32744b9c 100644 --- a/psengine/classic_alerts/helpers.py +++ b/psengine/classic_alerts/helpers.py @@ -13,7 +13,7 @@ import logging from pathlib import Path -from typing import Annotated +from typing import Annotated, Union from pydantic import validate_call from typing_extensions import Doc @@ -31,7 +31,7 @@ def save_image( image_bytes: Annotated[bytes, Doc('The image to save.')], file_name: Annotated[str, Doc('The file name to save the image as, without extension.')], output_directory: Annotated[ - str | Path, Doc('The directory to save the image to.') + Union[str, Path], Doc('The directory to save the image to.') ] = DEFAULT_CA_OUTPUT_DIR, ) -> Annotated[Path, Doc('The path to the file written.')]: """Save an image to disk as a PNG file. @@ -62,7 +62,7 @@ def save_image( def save_images( alert: Annotated[ClassicAlert, Doc('The alert to save images from.')], output_directory: Annotated[ - str | Path, Doc('The directory to save the images to.') + Union[str, Path], Doc('The directory to save the images to.') ] = DEFAULT_CA_OUTPUT_DIR, ) -> Annotated[dict, Doc('A dictionary of image file paths with the image ID as the key.')]: """Save all images from a `ClassicAlert` to disk. diff --git a/psengine/classic_alerts/models.py b/psengine/classic_alerts/models.py index ae36b7e4..4488634a 100644 --- a/psengine/classic_alerts/models.py +++ b/psengine/classic_alerts/models.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional from pydantic import Field, HttpUrl @@ -19,10 +20,10 @@ class AlertReview(RFBaseModel): - assignee: str | None = None - note: str | None = None + assignee: Optional[str] = None + note: Optional[str] = None status_in_portal: str - status: str | None = None + status: Optional[str] = None class Organisation(RFBaseModel): @@ -31,11 +32,11 @@ class Organisation(RFBaseModel): class OwnerOrganisationDetails(RFBaseModel): - organisations: list[Organisation] | None = [] - enterprise_id: str | None = None - enterprise_name: str | None = None - owner_id: str | None = None - owner_name: str | None = None + organisations: Optional[list[Organisation]] = [] + enterprise_id: Optional[str] = None + enterprise_name: Optional[str] = None + owner_id: Optional[str] = None + owner_name: Optional[str] = None class AlertURL(RFBaseModel): @@ -53,30 +54,30 @@ class PortalURL(RFBaseModel): class AlertDeprecation(RFBaseModel): - use_case_deprecation: str | None = None + use_case_deprecation: Optional[str] = None name: str id_: str = Field(alias='id') url: PortalURL class AlertDocument(RFBaseModel): - source: IdNameType | None = None - title: str | None = None - url: str | None = None + source: Optional[IdNameType] = None + title: Optional[str] = None + url: Optional[str] = None authors: list[IdNameType] class AlertAiInsight(RFBaseModel): - comment: str | None = None - text: str | None = None + comment: Optional[str] = None + text: Optional[str] = None class AlertLog(RFBaseModel): - note_author: str | None = None - note_date: datetime | None = None - status_date: datetime | None = None + note_author: Optional[str] = None + note_date: Optional[datetime] = None + status_date: Optional[datetime] = None triggered: datetime - status_change_by: str | None = None + status_change_by: Optional[str] = None class AlertSummary(RFBaseModel): @@ -94,7 +95,7 @@ class AlertCounts(RFBaseModel): class NotificationSettings(RFBaseModel): email_subscribers: list[IdName] - mobile_subscribers: list[IdName] | None = None + mobile_subsribers: Optional[list[IdName]] = None class Evidence(RFBaseModel): @@ -108,7 +109,7 @@ class Evidence(RFBaseModel): class EntityCriticality(RFBaseModel): name: str - score: int | None = None + score: Optional[int] = None last_triggered: datetime triggered: datetime level: int @@ -119,13 +120,13 @@ class ClassicAlertHit(RFBaseModel): entities: list[IdNameTypeDescription] document: AlertDocument - fragment: str | None = None + fragment: Optional[str] = None id_: str = Field(alias='id') - language: str | None = None - primary_entity: IdNameTypeDescription | None = None - analyst_note: AlertAnalystNote | None = None - alert_id: str | None = None - index: int | None = None + language: Optional[str] = None + primary_entity: Optional[IdNameTypeDescription] = None + analyst_note: Optional[AlertAnalystNote] = None + alert_id: Optional[str] = None + index: Optional[int] = None class EnrichedEntity(RFBaseModel): @@ -137,4 +138,4 @@ class EnrichedEntity(RFBaseModel): class TriggeredBy(RFBaseModel): reference_id: str - triggered_by_strings: list[str] | None = None + triggered_by_strings: Optional[list[str]] = None diff --git a/psengine/collective_insights/collective_insights.py b/psengine/collective_insights/collective_insights.py index 5b64d87f..ad665675 100644 --- a/psengine/collective_insights/collective_insights.py +++ b/psengine/collective_insights/collective_insights.py @@ -13,7 +13,7 @@ import json import logging -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import validate_call from typing_extensions import Doc @@ -46,19 +46,19 @@ def create( ioc_type: Annotated[str, Doc('The type of the IOC.')], timestamp: Annotated[str, Doc('The timestamp associated with the detection as ISO 8601.')], detection_type: Annotated[str, Doc('The type of the detection.')], - detection_sub_type: Annotated[str | None, Doc('The subtype of the detection.')] = None, - detection_id: Annotated[str | None, Doc('The ID of the detection.')] = None, - detection_name: Annotated[str | None, Doc('The name of the detection.')] = None, - ioc_field: Annotated[str | None, Doc('The field in which the IOC was detected.')] = None, - ioc_source_type: Annotated[str | None, Doc('The source type of the IOC.')] = None, - incident_id: Annotated[str | None, Doc('The ID of the incident.')] = None, - incident_name: Annotated[str | None, Doc('The name of the incident.')] = None, - incident_type: Annotated[str | None, Doc('The type of the incident.')] = None, + detection_sub_type: Annotated[Optional[str], Doc('The subtype of the detection.')] = None, + detection_id: Annotated[Optional[str], Doc('The ID of the detection.')] = None, + detection_name: Annotated[Optional[str], Doc('The name of the detection.')] = None, + ioc_field: Annotated[Optional[str], Doc('The field in which the IOC was detected.')] = None, + ioc_source_type: Annotated[Optional[str], Doc('The source type of the IOC.')] = None, + incident_id: Annotated[Optional[str], Doc('The ID of the incident.')] = None, + incident_name: Annotated[Optional[str], Doc('The name of the incident.')] = None, + incident_type: Annotated[Optional[str], Doc('The type of the incident.')] = None, mitre_codes: Annotated[ - list[str] | str | None, Doc('MITRE ATT&CK technique or tactic codes.') + Union[list[str], str, None], Doc('MITRE ATT&CK technique or tactic codes.') ] = None, malwares: Annotated[ - list[str] | str | None, Doc('Associated malware family or names.') + Union[list[str], str, None], Doc('Associated malware family or names.') ] = None, **kwargs, ) -> Annotated[Insight, Doc('The created Insight object.')]: @@ -105,12 +105,12 @@ def create( def submit( self, insight: Annotated[ - Insight | list[Insight], Doc('A detection or list of detections to submit.') + Union[Insight, list[Insight]], Doc('A detection or list of detections to submit.') ], debug: Annotated[ bool, Doc('Whether the submission should appear in the SecOPS dashboard.') ] = True, - organization_ids: Annotated[list | None, Doc('List of organization IDs.')] = None, + organization_ids: Annotated[Optional[list], Doc('List of organization IDs.')] = None, ) -> Annotated[InsightsIn, Doc('Response from the Recorded Future API.')]: """Submit a detection or insight to the Recorded Future Collective Insights API. diff --git a/psengine/collective_insights/insight.py b/psengine/collective_insights/insight.py index e3c7831c..5dbd676d 100644 --- a/psengine/collective_insights/insight.py +++ b/psengine/collective_insights/insight.py @@ -13,7 +13,7 @@ from datetime import datetime from functools import total_ordering -from typing import Annotated +from typing import Annotated, Optional from pydantic import BeforeValidator @@ -61,9 +61,11 @@ class Insight(RFBaseModel): timestamp: datetime ioc: RequestIOC - incident: IdNameType | None = None - mitre_codes: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - malwares: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None + incident: Optional[IdNameType] = None + mitre_codes: Annotated[Optional[list[str]], BeforeValidator(Validators.convert_str_to_list)] = ( + None + ) + malwares: Annotated[Optional[list[str]], BeforeValidator(Validators.convert_str_to_list)] = None detection: RequestDetection def __hash__(self): @@ -85,8 +87,8 @@ def __str__(self): class InsightsOut(RFBaseModel): """Validate data sent to CI.""" - options: RequestOptions | None = None - organization_ids: list[str] | None = None + options: Optional[RequestOptions] = None + organization_ids: Optional[list[str]] = None data: list[Insight] diff --git a/psengine/collective_insights/models.py b/psengine/collective_insights/models.py index a750f35f..26cab883 100644 --- a/psengine/collective_insights/models.py +++ b/psengine/collective_insights/models.py @@ -12,6 +12,7 @@ ############################################################################################## from enum import Enum +from typing import Optional from pydantic import Field, model_validator @@ -45,15 +46,15 @@ class RequestOptions(RFBaseModel): class RequestIOC(RFBaseModel): type_: IOCType = Field(alias='type') value: str - source_type: str | None = None - field: str | None = None + source_type: Optional[str] = None + field: Optional[str] = None class RequestDetection(RFBaseModel): - id_: str | None = Field(alias='id', default=None) - name: str | None = None + id_: Optional[str] = Field(alias='id', default=None) + name: Optional[str] = None type_: DetectionType = Field(alias='type') - sub_type: DetectionRuleType | None = None + sub_type: Optional[DetectionRuleType] = None @model_validator(mode='before') @classmethod @@ -77,4 +78,4 @@ def validate_detection_rule(cls, data): class SubmissionResult(RFBaseModel): status: str debug: bool - summary: ResponseSummary | None = None + summary: Optional[ResponseSummary] = None diff --git a/psengine/common_models.py b/psengine/common_models.py index 981f2c02..2ab7496e 100644 --- a/psengine/common_models.py +++ b/psengine/common_models.py @@ -13,7 +13,7 @@ import os from enum import Enum -from typing import Annotated +from typing import Annotated, Optional from pydantic import BaseModel, ConfigDict, Field, Secret from typing_extensions import Doc @@ -67,18 +67,18 @@ class IdName(RFBaseModel): class IdNameType(RFBaseModel): id_: str = Field(alias='id', default=None) - name: str | None = None + name: Optional[str] = None type_: str = Field(alias='type', default=None) class IdOptionalNameType(RFBaseModel): id_: str = Field(alias='id', default=None) - name: str | None = None + name: Optional[str] = None type_: str = Field(alias='type', default=None) class IdNameTypeDescription(IdNameType): - description: str | None = None + description: Optional[str] = None class IOCType(Enum): diff --git a/psengine/config/config.py b/psengine/config/config.py index 2736d313..19a3dbce 100644 --- a/psengine/config/config.py +++ b/psengine/config/config.py @@ -16,7 +16,7 @@ import re from copy import deepcopy from pathlib import Path -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import Field, Secret, field_validator, validate_call from pydantic_settings import ( @@ -86,23 +86,23 @@ class ConfigModel(BaseSettings): ``` """ - config_path: str | Path | None = None + config_path: Union[str, Path, None] = None model_config = SettingsConfigDict(arbitrary_types_allowed=True, extra='allow', frozen=True) - platform_id: str | None = Field(default=None, pattern=PLAT_REGEX, examples=['Splunk/8.0.0']) - app_id: str | None = Field(default=None, pattern=APP_ID_REGEX, examples=['get-alerts/1.0.0']) - rf_token: RFToken | None = Field(default=os.environ.get(RF_TOKEN_ENV_VAR, '')) - asi_token: RFToken | None = Field(default=os.environ.get(ASI_TOKEN_ENV_VAR, '')) - http_proxy: str | None = None - https_proxy: str | None = None - client_ssl_verify: bool | None = SSL_VERIFY - client_basic_auth: tuple[str, str] | None = None - client_cert: str | tuple[str, str] | None = None - client_timeout: int | None = REQUEST_TIMEOUT - client_retries: int | None = RETRY_TOTAL - client_backoff_factor: int | None = BACKOFF_FACTOR - client_status_forcelist: list[int] | None = STATUS_FORCELIST - client_pool_max_size: int | None = POOL_MAX_SIZE + platform_id: Optional[str] = Field(default=None, pattern=PLAT_REGEX, examples=['Splunk/8.0.0']) + app_id: Optional[str] = Field(default=None, pattern=APP_ID_REGEX, examples=['get-alerts/1.0.0']) + rf_token: Optional[RFToken] = Field(default=os.environ.get(RF_TOKEN_ENV_VAR, '')) + asi_token: Optional[RFToken] = Field(default=os.environ.get(ASI_TOKEN_ENV_VAR, '')) + http_proxy: Optional[str] = None + https_proxy: Optional[str] = None + client_ssl_verify: Optional[bool] = SSL_VERIFY + client_basic_auth: Optional[tuple[str, str]] = None + client_cert: Optional[Union[str, tuple[str, str]]] = None + client_timeout: Optional[int] = REQUEST_TIMEOUT + client_retries: Optional[int] = RETRY_TOTAL + client_backoff_factor: Optional[int] = BACKOFF_FACTOR + client_status_forcelist: Optional[list[int]] = STATUS_FORCELIST + client_pool_max_size: Optional[int] = POOL_MAX_SIZE @classmethod def settings_customise_sources( @@ -185,11 +185,10 @@ def validate_token( @validate_call def save_config( self, - directory: Annotated[str | Path, Doc('The directory to save the config file into.')] = Path( - ROOT_DIR - ) - / 'config', - file: Annotated[str | Path, Doc('The name of the config file.')] = 'config.json', + directory: Annotated[ + Union[str, Path], Doc('The directory to save the config file into.') + ] = Path(ROOT_DIR) / 'config', + file: Annotated[Union[str, Path], Doc('The name of the config file.')] = 'config.json', ): """Write the current values in `Config` to the specified file as JSON. @@ -218,7 +217,7 @@ class Config: _instance = None @classmethod - def _get_instance(cls) -> ConfigModel | None: + def _get_instance(cls) -> Union[ConfigModel, None]: """Get instance of `Config`. `get_config()` should be used instead of calling this method directly diff --git a/psengine/detection/detection_mgr.py b/psengine/detection/detection_mgr.py index 187bb3cf..5e461df8 100644 --- a/psengine/detection/detection_mgr.py +++ b/psengine/detection/detection_mgr.py @@ -12,7 +12,7 @@ ############################################################################################## import logging -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import validate_call from typing_extensions import Doc @@ -33,7 +33,7 @@ class DetectionMgr: def __init__( self, - rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None, + rf_token: Annotated[Optional[str], Doc('Recorded Future API token.')] = None, ): """Initialize the `DetectionMgr` object.""" self.log = logging.getLogger(__name__) @@ -45,28 +45,30 @@ def __init__( def search( self, detection_rule: Annotated[ - str | list[str] | None, Doc('Types of detection rules to search for.') + Union[str, list[str], None], Doc('Types of detection rules to search for.') + ] = None, + entities: Annotated[ + Optional[list[str]], Doc('List of entities to filter the search.') ] = None, - entities: Annotated[list[str] | None, Doc('List of entities to filter the search.')] = None, created_before: Annotated[ - str | None, Doc('Filter for rules created before this date or relative date.') + Optional[str], Doc('Filter for rules created before this date or relative date.') ] = None, created_after: Annotated[ - str | None, Doc('Filter for rules created after this date or relative date.') + Optional[str], Doc('Filter for rules created after this date or relative date.') ] = None, updated_before: Annotated[ - str | None, Doc('Filter for rules updated before this date or relative date.') + Optional[str], Doc('Filter for rules updated before this date or relative date.') ] = None, updated_after: Annotated[ - str | None, Doc('Filter for rules updated after this date or relative date.') + Optional[str], Doc('Filter for rules updated after this date or relative date.') ] = None, - doc_id: Annotated[str | None, Doc('Filter by document ID.')] = None, - title: Annotated[str | None, Doc('Filter by title.')] = None, + doc_id: Annotated[Optional[str], Doc('Filter by document ID.')] = None, + title: Annotated[Optional[str], Doc('Filter by title.')] = None, tagged_entities: Annotated[ - bool | None, Doc('Whether to filter by tagged entities.') + Optional[bool], Doc('Whether to filter by tagged entities.') ] = None, max_results: Annotated[ - int | None, Doc('Limit the total number of results returned.') + Optional[int], Doc('Limit the total number of results returned.') ] = DEFAULT_LIMIT, ) -> Annotated[ list[DetectionRule], Doc('A list of detection rules matching the search criteria.') @@ -112,7 +114,7 @@ def search( def fetch( self, doc_id: Annotated[str, Doc('Detection rule ID to look up.')], - ) -> Annotated[DetectionRule | None, Doc('The detection rule found for the given ID.')]: + ) -> Annotated[Optional[DetectionRule], Doc('The detection rule found for the given ID.')]: """Fetch a detection rule based on its ID. Endpoint: diff --git a/psengine/detection/detection_rule.py b/psengine/detection/detection_rule.py index 61fcc8d8..98f97455 100644 --- a/psengine/detection/detection_rule.py +++ b/psengine/detection/detection_rule.py @@ -13,6 +13,7 @@ from datetime import datetime from functools import total_ordering +from typing import Optional from pydantic import Field @@ -79,7 +80,7 @@ def __str__(self): class DetectionRuleSearchOut(RFBaseModel): """Model to validate `/search` endpoint payload sent.""" - filter_: SearchFilter | None = Field(alias='filter', default={}) - tagged_entities: bool | None = False - limit: int | None = None - offset: str | None = None + filter_: Optional[SearchFilter] = Field(alias='filter', default={}) + tagged_entities: Optional[bool] = False + limit: Optional[int] = None + offset: Optional[str] = None diff --git a/psengine/detection/helpers.py b/psengine/detection/helpers.py index c4d8daea..f5a76d06 100644 --- a/psengine/detection/helpers.py +++ b/psengine/detection/helpers.py @@ -13,7 +13,7 @@ import logging from pathlib import Path -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import validate_call from typing_extensions import Doc @@ -30,7 +30,7 @@ def save_rule( rule: Annotated[DetectionRule, Doc('Single detection rule to write.')], output_directory: Annotated[ - str | Path | None, + Optional[Union[str, Path]], Doc('Path to write to. If not provided, the current working directory will be used.'), ] = None, ): diff --git a/psengine/detection/models.py b/psengine/detection/models.py index be4fe6b8..b5e97bc6 100644 --- a/psengine/detection/models.py +++ b/psengine/detection/models.py @@ -12,7 +12,7 @@ ############################################################################################## from datetime import datetime -from typing import Annotated +from typing import Annotated, Optional from pydantic import BeforeValidator, Field @@ -22,29 +22,29 @@ class Entity(RFBaseModel): id_: str = Field(alias='id', default=None) - name: str | None = None + name: Optional[str] = None type_: str = Field(alias='type', default=None) - display_name: str | None = None + display_name: Optional[str] = None class RuleContext(RFBaseModel): entities: list[Entity] content: str - file_name: str | None = None + file_name: Optional[str] = None class TimeRange(RFBaseModel): - after: Annotated[datetime | None, BeforeValidator(Validators.convert_relative_time)] = None - before: Annotated[datetime | None, BeforeValidator(Validators.convert_relative_time)] = None + after: Annotated[Optional[datetime], BeforeValidator(Validators.convert_relative_time)] = None + before: Annotated[Optional[datetime], BeforeValidator(Validators.convert_relative_time)] = None class SearchFilter(RFBaseModel): types: Annotated[ - list[DetectionRuleType] | None, BeforeValidator(Validators.convert_str_to_list) + Optional[list[DetectionRuleType]], BeforeValidator(Validators.convert_str_to_list) ] = None - entities: list[str] | None = None - created: TimeRange | None = None - updated: TimeRange | None = None - doc_id: str | None = None - title: str | None = None + entities: Optional[list[str]] = None + created: Optional[TimeRange] = None + updated: Optional[TimeRange] = None + doc_id: Optional[str] = None + title: Optional[str] = None diff --git a/psengine/endpoints.py b/psengine/endpoints.py index b41b51d8..e9a2607c 100644 --- a/psengine/endpoints.py +++ b/psengine/endpoints.py @@ -23,8 +23,10 @@ BASE_URL = 'https://api.recordedfuture.com' CONNECT_API_BASE_URL = BASE_URL + '/' + API_VERSION -BASE_URL = environ.get('RF_BASE_URL') or BASE_URL -CONNECT_API_BASE_URL = environ.get('RF_BASE_URL') or CONNECT_API_BASE_URL +BASE_URL = environ.get('RF_BASE_URL') if environ.get('RF_BASE_URL') else BASE_URL +CONNECT_API_BASE_URL = ( + environ.get('RF_BASE_URL') if environ.get('RF_BASE_URL') else CONNECT_API_BASE_URL +) ############################################################################### # Classic Alerts Endpoints V3 @@ -118,17 +120,6 @@ ############################################################################### EP_MALWARE_INTELLIGENCE = BASE_URL + '/malware-intelligence/v1/' EP_MALWARE_INTEL_REPORTS = EP_MALWARE_INTELLIGENCE + 'reports' -EP_AUTO_YARA = EP_MALWARE_INTELLIGENCE + 'auto-yara/' -EP_AUTO_YARA_JOBS = EP_AUTO_YARA + 'jobs' -EP_AUTO_YARA_JOB_ID = EP_AUTO_YARA_JOBS + '/{}' -EP_AUTO_YARA_JOB_ID_RETRY = EP_AUTO_YARA_JOB_ID + '/retry' -EP_AUTO_YARA_JOBS_EDIT = EP_AUTO_YARA_JOBS + '/edit' -EP_AUTO_SIGMA = EP_MALWARE_INTELLIGENCE + 'auto-sigma/' -EP_AUTO_SIGMA_JOBS = EP_AUTO_SIGMA + 'jobs' -EP_AUTO_SIGMA_GET_JOBS = EP_AUTO_SIGMA + 'get_jobs' -EP_AUTO_SIGMA_JOB_ID = EP_AUTO_SIGMA_JOBS + '/{}' -EP_AUTO_SIGMA_JOB_ID_RULE_ID = EP_AUTO_SIGMA_JOB_ID + '/{}' -EP_AUTO_SIGMA_JOB_ID_RETRY = EP_AUTO_SIGMA_JOB_ID + '/retry' ############################################################################### # Risk History API Endpoints @@ -136,6 +127,16 @@ EP_RISK_HISTORY_BASE = BASE_URL + '/risk' EP_RISK_HISTORY = EP_RISK_HISTORY_BASE + '/history' +############################################################################### +# Links API Endpoints +############################################################################### +LINKS_BASE_URL = f'{BASE_URL}/links' +LINKS_METADATA_URL = f'{LINKS_BASE_URL}/metadata' +EP_LINKS_SEARCH = f'{LINKS_BASE_URL}/search' +EP_LINKS_METADATA_SECTIONS = f'{LINKS_METADATA_URL}/sections' +EP_LINKS_METADATA_EVENTS = f'{LINKS_METADATA_URL}/events' +EP_LINKS_METADATA_ENTITIES = f'{LINKS_METADATA_URL}/entities' + ################################################################################ # Attack Surface Intelligence API Endpoints ################################################################################ @@ -147,13 +148,3 @@ EP_ASI_ASSETS_SEARCH = f'{EP_ASI_ASSETS}/_search' EP_ASI_EXPOSURES = f'{EP_ASI_PROJECTS}/{{}}/exposures' EP_ASI_EXPOSURES_BY_SIGNATURE = f'{EP_ASI_EXPOSURES}/{{}}' - -################################################################################ -# Threat Map API Endpoints -################################################################################ -EP_THREAT_MAPS_BASE = BASE_URL + '/threat' -EP_THREAT_MAPS_LIST = EP_THREAT_MAPS_BASE + '/maps' -EP_THREAT_MAP = EP_THREAT_MAPS_BASE + '/map/{}' -EP_THREAT_MAP_ORG = EP_THREAT_MAPS_BASE + '/map/{}/{}' -EP_ACTOR_SEARCH = EP_THREAT_MAPS_BASE + '/actor/search' -EP_CATEGORIES = EP_THREAT_MAPS_BASE + '/{}/categories' diff --git a/psengine/enrich/lookup.py b/psengine/enrich/lookup.py index 43066315..165c57d1 100644 --- a/psengine/enrich/lookup.py +++ b/psengine/enrich/lookup.py @@ -11,6 +11,7 @@ # accessed from any third party API. # ############################################################################################## from functools import total_ordering +from typing import Optional, Union from pydantic import Field @@ -39,15 +40,15 @@ class EnrichedIP(BaseEnrichedEntity): """IP Enriched by `/v2/ip/{ip}` endpoint. Inherit behaviours from `BaseEnrichedEntity`.""" - risk: EntityRisk | None = None - links: Links | None = None - enterprise_lists: list[EnterpriseList] | None = Field(alias='enterpriseLists', default=None) - threat_list: list[IdNameTypeDescription] | None = Field(alias='threatLists', default=None) - risk_mapping: list[RiskMapping] | None = Field(alias='riskMapping', default=None) - dns_port_cert: DnsPortCert | None = Field(alias='dnsPortCert', default=None) - location: IPLocation | None = None - risky_cidr_ips: list[RiskyCIDRPIP] | None = Field(alias='riskyCIDRIPs', default=None) - scanner: Scanner | None = None + risk: Optional[EntityRisk] = None + links: Optional[Links] = None + enterprise_lists: Optional[list[EnterpriseList]] = Field(alias='enterpriseLists', default=None) + threat_list: Optional[list[IdNameTypeDescription]] = Field(alias='threatLists', default=None) + risk_mapping: Optional[list[RiskMapping]] = Field(alias='riskMapping', default=None) + dns_port_cert: Optional[DnsPortCert] = Field(alias='dnsPortCert', default=None) + location: Optional[IPLocation] = None + risky_cidr_ips: Optional[list[RiskyCIDRPIP]] = Field(alias='riskyCIDRIPs', default=None) + scanner: Optional[Scanner] = None class EnrichedDomain(BaseEnrichedEntity): @@ -55,11 +56,11 @@ class EnrichedDomain(BaseEnrichedEntity): Inherit behaviours from `BaseEnrichedEntity`. """ - risk: EntityRisk | None = None - links: Links | None = None - enterprise_lists: list[EnterpriseList] | None = Field(alias='enterpriseLists', default=None) - threat_lists: list[IdNameTypeDescription] | None = Field(alias='threatLists', default=None) - risk_mapping: list[RiskMapping] | None = Field(alias='riskMapping', default=None) + risk: Optional[EntityRisk] = None + links: Optional[Links] = None + enterprise_lists: Optional[list[EnterpriseList]] = Field(alias='enterpriseLists', default=None) + threat_lists: Optional[list[IdNameTypeDescription]] = Field(alias='threatLists', default=None) + risk_mapping: Optional[list[RiskMapping]] = Field(alias='riskMapping', default=None) class EnrichedURL(BaseEnrichedEntity): @@ -67,10 +68,10 @@ class EnrichedURL(BaseEnrichedEntity): Inherit behaviours from `BaseEnrichedEntity`. """ - risk: EntityRisk | None = None - links: Links | None = None - enterprise_lists: list[EnterpriseList] | None = Field(alias='enterpriseLists', default=None) - risk_mapping: list[RiskMapping] | None = Field(alias='riskMapping', default=None) + risk: Optional[EntityRisk] = None + links: Optional[Links] = None + enterprise_lists: Optional[list[EnterpriseList]] = Field(alias='enterpriseLists', default=None) + risk_mapping: Optional[list[RiskMapping]] = Field(alias='riskMapping', default=None) class EnrichedHash(BaseEnrichedEntity): @@ -78,13 +79,13 @@ class EnrichedHash(BaseEnrichedEntity): Inherit behaviours from `BaseEnrichedEntity`. """ - risk: EntityRisk | None = None - links: Links | None = None - enterprise_lists: list[EnterpriseList] | None = Field(alias='enterpriseLists', default=None) - threat_list: list[IdNameTypeDescription] | None = Field(alias='threatLists', default=None) - risk_mapping: list[RiskMapping] | None = Field(alias='riskMapping', default=None) - hash_algorithm: str | None = Field(alias='hashAlgorithm', default=None) - file_hashes: list[str] | None = Field(alias='fileHashes', default=None) + risk: Optional[EntityRisk] = None + links: Optional[Links] = None + enterprise_lists: Optional[list[EnterpriseList]] = Field(alias='enterpriseLists', default=None) + threat_list: Optional[list[IdNameTypeDescription]] = Field(alias='threatLists', default=None) + risk_mapping: Optional[list[RiskMapping]] = Field(alias='riskMapping', default=None) + hash_algorithm: Optional[str] = Field(alias='hashAlgorithm', default=None) + file_hashes: Optional[list[str]] = Field(alias='fileHashes', default=None) class EnrichedVulnerability(BaseEnrichedEntity): @@ -92,24 +93,24 @@ class EnrichedVulnerability(BaseEnrichedEntity): Inherit behaviours from `BaseEnrichedEntity`. """ - risk: EntityRisk | None = None - links: Links | None = None - enterprise_lists: list[EnterpriseList] | None = Field(alias='enterpriseLists', default=None) - threat_list: list[IdNameTypeDescription] | None = Field(alias='threatLists', default=None) - risk_mapping: list[RiskMapping] | None = Field(alias='riskMapping', default=None) - common_names: list[str] | None = Field(alias='commonNames', default=None) - lifecycle_stage: str | None = Field(alias='lifecycleStage', default=None) - linked_malware: LinkedMalware | None = Field(alias='linkedMalware', default=None) - cpe: list[str] | None = None - cpe_22_uri: list[str] | None = Field(alias='cpe22uri', default=None) - cvss: CVSS | None = None + risk: Optional[EntityRisk] = None + links: Optional[Links] = None + enterprise_lists: Optional[list[EnterpriseList]] = Field(alias='enterpriseLists', default=None) + threat_list: Optional[list[IdNameTypeDescription]] = Field(alias='threatLists', default=None) + risk_mapping: Optional[list[RiskMapping]] = Field(alias='riskMapping', default=None) + common_names: Optional[list[str]] = Field(alias='commonNames', default=None) + lifecycle_stage: Optional[str] = Field(alias='lifecycleStage', default=None) + linked_malware: Optional[LinkedMalware] = Field(alias='linkedMalware', default=None) + cpe: Optional[list[str]] = None + cpe_22_uri: Optional[list[str]] = Field(alias='cpe22uri', default=None) + cvss: Optional[CVSS] = None cvss_ratings: list[CVSSRating] = Field(alias='cvssRatings', default=None) - cvssv3: CVSSV3 | None = None - cvssv4: CVSSV4 | None = None - nvd_description: str | None = Field(alias='nvdDescription', default=None) - nvd_references: list[NvdReference] | None = Field(alias='nvdReferences', default=None) - raw_risk: list[RawRisk] | None = Field(alias='rawrisk', default=None) - related_links: list[str] | None = Field(alias='relatedLinks', default=None) + cvssv3: Optional[CVSSV3] = None + cvssv4: Optional[CVSSV4] = None + nvd_description: Optional[str] = Field(alias='nvdDescription', default=None) + nvd_references: Optional[list[NvdReference]] = Field(alias='nvdReferences', default=None) + raw_risk: Optional[list[RawRisk]] = Field(alias='rawrisk', default=None) + related_links: Optional[list[str]] = Field(alias='relatedLinks', default=None) class EnrichedMalware(BaseEnrichedEntity): @@ -117,8 +118,8 @@ class EnrichedMalware(BaseEnrichedEntity): Inherit behaviours from `BaseEnrichedEntity`. """ - links: Links | None = None - categories: list[IdNameType] | None = None + links: Optional[Links] = None + categories: Optional[list[IdNameType]] = None class EnrichedCompany(BaseEnrichedEntity): @@ -126,21 +127,21 @@ class EnrichedCompany(BaseEnrichedEntity): Inherit behaviours from `BaseEnrichedEntity`. """ - risk: EntityRisk | None = None - curated: bool | None = None - threat_list: list[IdNameTypeDescription] | None = Field(alias='threatLists', default=None) - risk_mapping: list[RiskMapping] | None = Field(alias='riskMapping', default=None) + risk: Optional[EntityRisk] = None + curated: Optional[bool] = None + threat_list: Optional[list[IdNameTypeDescription]] = Field(alias='threatLists', default=None) + risk_mapping: Optional[list[RiskMapping]] = Field(alias='riskMapping', default=None) -_EnrichmentObjectType = ( - EnrichedCompany - | EnrichedDomain - | EnrichedIP - | EnrichedHash - | EnrichedMalware - | EnrichedURL - | EnrichedVulnerability -) +_EnrichmentObjectType = Union[ + EnrichedCompany, + EnrichedDomain, + EnrichedIP, + EnrichedHash, + EnrichedMalware, + EnrichedURL, + EnrichedVulnerability, +] @total_ordering @@ -202,9 +203,9 @@ class EnrichmentData(RFBaseModel): """ entity: str - entity_type: str | None + entity_type: Optional[str] is_enriched: bool - content: str | _EnrichmentObjectType + content: Union[str, _EnrichmentObjectType] def __hash__(self): if isinstance(self.content, EnrichedMalware): diff --git a/psengine/enrich/lookup_mgr.py b/psengine/enrich/lookup_mgr.py index e3a4147d..f2e701fb 100644 --- a/psengine/enrich/lookup_mgr.py +++ b/psengine/enrich/lookup_mgr.py @@ -12,7 +12,7 @@ ############################################################################################## import logging -from typing import Annotated +from typing import Annotated, Optional from urllib.parse import quote from pydantic import validate_call @@ -38,7 +38,7 @@ class LookupMgr: def __init__( self, - rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None, + rf_token: Annotated[Optional[str], Doc('Recorded Future API token.')] = None, ): """Initialize the `LookupMgr` object.""" self.log = logging.getLogger(__name__) @@ -51,7 +51,7 @@ def lookup( entity: Annotated[str, Doc('Name or Recorded Future ID of the entity.')], entity_type: Annotated[ALLOWED_ENTITIES, Doc('Type of the entity to enrich.')], fields: Annotated[ - list[str] | None, Doc('Optional additional fields for enrichment.') + Optional[list[str]], Doc('Optional additional fields for enrichment.') ] = None, ) -> Annotated[EnrichmentData, Doc('An object containing the enriched entity details.')]: """Perform lookup of an entity based on its ID or name. @@ -156,7 +156,9 @@ def lookup_bulk( fields: Annotated[ list[str], Doc('Optional additional fields for enrichment.') ] = ENTITY_FIELDS, - max_workers: Annotated[int | None, Doc('Number of workers to multithread requests.')] = 0, + max_workers: Annotated[ + Optional[int], Doc('Number of workers to multithread requests.') + ] = 0, ) -> Annotated[ list[EnrichmentData], Doc('A list of objects containing the enriched entity details.') ]: diff --git a/psengine/enrich/models/base_enriched_entity.py b/psengine/enrich/models/base_enriched_entity.py index b9965432..669d22e3 100644 --- a/psengine/enrich/models/base_enriched_entity.py +++ b/psengine/enrich/models/base_enriched_entity.py @@ -11,6 +11,7 @@ # accessed from any third party API. # ############################################################################################## +from typing import Optional from pydantic import Field @@ -31,12 +32,12 @@ class BaseEnrichedEntity(RFBaseModel): This model is intended to be inherited and should not be used on its own. """ - ai_insights: AIInsights | None = Field(alias='aiInsights', default=None) - analyst_notes: list[AnalystNote] | None = Field(alias='analystNotes', default=[]) - counts: list[ReferenceCount] | None = [] - entity: IdNameTypeDescription | None = None - intel_card: str | None = Field(alias='intelCard', default=None) - metrics: list[Metric] | None = [] - related_entities: list[RelatedEntities] | None = Field(alias='relatedEntities', default=[]) - sightings: list[Sighting] | None = [] - timestamps: Timestamps | None = None + ai_insights: Optional[AIInsights] = Field(alias='aiInsights', default=None) + analyst_notes: Optional[list[AnalystNote]] = Field(alias='analystNotes', default=[]) + counts: Optional[list[ReferenceCount]] = [] + entity: Optional[IdNameTypeDescription] = None + intel_card: Optional[str] = Field(alias='intelCard', default=None) + metrics: Optional[list[Metric]] = [] + related_entities: Optional[list[RelatedEntities]] = Field(alias='relatedEntities', default=[]) + sightings: Optional[list[Sighting]] = [] + timestamps: Optional[Timestamps] = None diff --git a/psengine/enrich/models/lookup.py b/psengine/enrich/models/lookup.py index 5969b503..b10fe47f 100644 --- a/psengine/enrich/models/lookup.py +++ b/psengine/enrich/models/lookup.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional, Union from pydantic import Field @@ -22,7 +23,7 @@ # Enterprise Lists ########################################################### class EnterpriseList(RFBaseModel): - added: datetime | None + added: Optional[datetime] list_: IdNameTypeDescription = Field(alias='list') @@ -32,9 +33,9 @@ class RiskyCIDRPIP(RFBaseModel): class AIInsights(RFBaseModel): - comment: str | None = None - text: str | None = None - number_of_references: int | None = Field(alias='numberOfReferences', default=None) + comment: Optional[str] = None + text: Optional[str] = None + number_of_references: Optional[int] = Field(alias='numberOfReferences', default=None) class EvidenceDetails(RFBaseModel): @@ -72,7 +73,7 @@ class RiskMappingCategory(RFBaseModel): class RiskMapping(RFBaseModel): rule: str - categories: list[RiskMappingCategory] | None = None + categories: Optional[list[RiskMappingCategory]] = None class RelatedEntity(RFBaseModel): @@ -86,16 +87,16 @@ class RelatedEntities(RFBaseModel): class GeoLocation(RFBaseModel): - continent: str | None = None - country: str | None = None - city: str | None = None + continent: Optional[str] = None + country: Optional[str] = None + city: Optional[str] = None class IPLocation(RFBaseModel): - organization: str | None + organization: Optional[str] cidr: IdNameType location: GeoLocation - asn: str | None = None + asn: Optional[str] = None class Timestamps(RFBaseModel): @@ -110,7 +111,7 @@ class ReferenceCount(RFBaseModel): class Metric(RFBaseModel): type_: str = Field(alias='type') - value: int | float + value: Union[int, float] ########################################################### @@ -130,7 +131,7 @@ class LinksList(RFBaseModel): class SectionHits(RFBaseModel): section_id: IdNameType total_count: int - lists: list[LinksList] | None = None + lists: Optional[list[LinksList]] = None class Hits(RFBaseModel): @@ -166,16 +167,16 @@ class LinkedMalware(RFBaseModel): # CVSS ########################################################### class CVSS(RFBaseModel): - access_vector: str | None = Field(alias='accessVector', default=None) - last_modified: datetime | None = Field(alias='lastModified', default=None) - published: datetime | None = None - score: float | None = None - availability: str | None = None - authentication: str | None = None - access_complexity: str | None = Field(alias='accessComplexity', default=None) - integrity: str | None = None - confidentiality: str | None = None - version: str | None = None + access_vector: Optional[str] = Field(alias='accessVector', default=None) + last_modified: Optional[datetime] = Field(alias='lastModified', default=None) + published: Optional[datetime] = None + score: Optional[float] = None + availability: Optional[str] = None + authentication: Optional[str] = None + access_complexity: Optional[str] = Field(alias='accessComplexity', default=None) + integrity: Optional[str] = None + confidentiality: Optional[str] = None + version: Optional[str] = None class CVSSRating(RFBaseModel): @@ -187,62 +188,66 @@ class CVSSRating(RFBaseModel): class CVSSV3(RFBaseModel): - scope: str | None = None - exploitability_score: float | None = Field(alias='exploitabilityScore', default=None) - modified: datetime | None = None - base_severity: str | None = Field(alias='baseSeverity', default=None) - base_score: float | None = Field(alias='baseScore', default=None) - privileges_required: str | None = Field(alias='privilegesRequired', default=None) - user_interaction: str | None = Field(alias='userInteraction', default=None) - impact_score: float | None = Field(alias='impactScore', default=None) - attack_vector: str | None = Field(alias='attackVector', default=None) - integrity_impact: str | None = Field(alias='integrityImpact', default=None) - confidentiality_impact: str | None = Field(alias='confidentialityImpact', default=None) - vector_string: str | None = Field(alias='vectorString', default=None) - version: str | None = None - attack_complexity: str | None = Field(alias='attackComplexity', default=None) - created: datetime | None = None - availability_impact: str | None = Field(alias='availabilityImpact', default=None) + scope: Optional[str] = None + exploitability_score: Optional[float] = Field(alias='exploitabilityScore', default=None) + modified: Optional[datetime] = None + base_severity: Optional[str] = Field(alias='baseSeverity', default=None) + base_score: Optional[float] = Field(alias='baseScore', default=None) + privileges_required: Optional[str] = Field(alias='privilegesRequired', default=None) + user_interaction: Optional[str] = Field(alias='userInteraction', default=None) + impact_score: Optional[float] = Field(alias='impactScore', default=None) + attack_vector: Optional[str] = Field(alias='attackVector', default=None) + integrity_impact: Optional[str] = Field(alias='integrityImpact', default=None) + confidentiality_impact: Optional[str] = Field(alias='confidentialityImpact', default=None) + vector_string: Optional[str] = Field(alias='vectorString', default=None) + version: Optional[str] = None + attack_complexity: Optional[str] = Field(alias='attackComplexity', default=None) + created: Optional[datetime] = None + availability_impact: Optional[str] = Field(alias='availabilityImpact', default=None) class CVSSV4(RFBaseModel): - subsequent_system_integrity: str | None = Field(alias='subsequentSystemIntegrity', default=None) - provider_urgency: str | None = Field(alias='providerUrgency', default=None) - attack_requirements: str | None = Field(alias='attackRequirements', default=None) - vulnerable_system_confidentiality: str | None = Field( + subsequent_system_integrity: Optional[str] = Field( + alias='subsequentSystemIntegrity', default=None + ) + provider_urgency: Optional[str] = Field(alias='providerUrgency', default=None) + attack_requirements: Optional[str] = Field(alias='attackRequirements', default=None) + vulnerable_system_confidentiality: Optional[str] = Field( alias='vulnerableSystemConfidentiality', default=None ) - vulnerability_response_effort: str | None = Field( + vulnerability_response_effort: Optional[str] = Field( alias='vulnerabilityResponseEffort', default=None ) - threat_score: float | None = Field(alias='threatScore', default=None) - subsequent_system_availability: str | None = Field( + threat_score: Optional[float] = Field(alias='threatScore', default=None) + subsequent_system_availability: Optional[str] = Field( alias='subsequentSystemAvailability', default=None ) - base_severity: str | None = Field(alias='baseSeverity', default=None) - base_score: float | None = Field(alias='baseScore', default=None) - user_interaction: str | None = Field(alias='userInteraction', default=None) - attack_vector: str | None = Field(alias='attackVector', default=None) - source: str | None = None - vulnerable_system_integrity: str | None = Field(alias='vulnerableSystemIntegrity', default=None) - vulnerable_system_availability: str | None = Field( + base_severity: Optional[str] = Field(alias='baseSeverity', default=None) + base_score: Optional[float] = Field(alias='baseScore', default=None) + user_interaction: Optional[str] = Field(alias='userInteraction', default=None) + attack_vector: Optional[str] = Field(alias='attackVector', default=None) + source: Optional[str] = None + vulnerable_system_integrity: Optional[str] = Field( + alias='vulnerableSystemIntegrity', default=None + ) + vulnerable_system_availability: Optional[str] = Field( alias='vulnerableSystemAvailability', default=None ) - modified: datetime | None = None - vector_string: str | None = Field(alias='vectorString', default=None) - recovery: str | None = None - version: str | None = None - threat_severity: str | None = Field(alias='threatSeverity', default=None) - privileges_required: str | None = Field(alias='privilegesRequired', default=None) - exploit_maturity: str | None = Field(alias='exploitMaturity', default=None) - safety: str | None = None - subsequent_system_confidentiality: str | None = Field( + modified: Optional[datetime] = None + vector_string: Optional[str] = Field(alias='vectorString', default=None) + recovery: Optional[str] = None + version: Optional[str] = None + threat_severity: Optional[str] = Field(alias='threatSeverity', default=None) + privileges_required: Optional[str] = Field(alias='privilegesRequired', default=None) + exploit_maturity: Optional[str] = Field(alias='exploitMaturity', default=None) + safety: Optional[str] = None + subsequent_system_confidentiality: Optional[str] = Field( alias='subsequentSystemConfidentiality', default=None ) - automatable: str | None = None - value_density: str | None = Field(alias='valueDensity', default=None) - attack_complexity: str | None = Field(alias='attackComplexity', default=None) - created: datetime | None = None + automatable: Optional[str] = None + value_density: Optional[str] = Field(alias='valueDensity', default=None) + attack_complexity: Optional[str] = Field(alias='attackComplexity', default=None) + created: Optional[datetime] = None ########################################################### @@ -262,48 +267,48 @@ class Validity(RFBaseModel): class Issuer(RFBaseModel): - organization: str | None = None - location: str | None = None + organization: Optional[str] = None + location: Optional[str] = None class Certificate(RFBaseModel): - subject: str | None = None + subject: Optional[str] = None validity: Validity issuer: Issuer seen_on_port: list[int] = Field(alias='seenOnPort') class ForwardDNS(RFBaseModel): - hostname: str | None = None - last_seen: datetime | None = Field(alias='lastSeen') - first_seen: datetime | None = Field(alias='firstSeen') + hostname: Optional[str] = None + last_seen: Union[datetime, None] = Field(alias='lastSeen') + first_seen: Union[datetime, None] = Field(alias='firstSeen') class DNS(RFBaseModel): forward_dns: list[ForwardDNS] = Field(alias='forwardDns') - reverse_dns: str | None = Field(alias='reverseDns', default=None) + reverse_dns: Optional[str] = Field(alias='reverseDns', default=None) class Port(RFBaseModel): - name: str | None = None - version: str | None + name: Optional[str] = None + version: Union[str, None] port: int - extra_info: str | None = Field(alias='extraInfo') + extra_info: Union[str, None] = Field(alias='extraInfo') protocol: str - product: str | None + product: Union[str, None] class DnsPortCert(RFBaseModel): - certificates: list[Certificate] | None = None - dns: DNS | None = None - ports: list[Port] | None = None + certificates: Optional[list[Certificate]] = None + dns: Optional[DNS] = None + ports: Optional[list[Port]] = None ########################################################### # Scanner ########################################################### class Tag(RFBaseModel): - verdict_details: list[str] | None = Field(default=None, alias='verdictDetails') + verdict_details: Optional[list[str]] = Field(default=None, alias='verdictDetails') entity: list[IdNameType] @@ -335,7 +340,7 @@ class Scanner(RFBaseModel): global_scanner: bool = Field(alias='globalScanner') user_agents: list[str] = Field(alias='userAgents', default=None) web_requests: list[str] = Field(alias='webRequests', default=None) - evidence: list[Evidence] | None = [] + evidence: Optional[list[Evidence]] = [] ########################################################### diff --git a/psengine/enrich/models/soar.py b/psengine/enrich/models/soar.py index 887dbc63..9c17a893 100644 --- a/psengine/enrich/models/soar.py +++ b/psengine/enrich/models/soar.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional from pydantic import Field, model_validator @@ -19,7 +20,7 @@ class ScoreCount(RFBaseModel): - count: int | None = None + count: Optional[int] = None max_count: int = Field(alias='maxCount') @@ -46,10 +47,10 @@ class Evidence(RFBaseModel): class RiskRule(ScoreCount): - score: int | None = None + score: Optional[int] = None summary: list most_critical: str = Field(alias='mostCritical') - evidence: list[Evidence] | None = None + evidence: Optional[list[Evidence]] = None @model_validator(mode='before') @classmethod @@ -124,10 +125,10 @@ def evidence_transform(cls, data: dict) -> dict: class Context(RFBaseModel): - phishing: ScoreRule | None = None - public: Public | None = None - c2: ScoreRule | None = None - malware: ScoreRule | None = None + phishing: Optional[ScoreRule] = None + public: Optional[Public] = None + c2: Optional[ScoreRule] = None + malware: Optional[ScoreRule] = None class Risk(RFBaseModel): diff --git a/psengine/enrich/soar.py b/psengine/enrich/soar.py index 74e0221a..f9921d74 100644 --- a/psengine/enrich/soar.py +++ b/psengine/enrich/soar.py @@ -12,6 +12,7 @@ ############################################################################################## from functools import total_ordering +from typing import Optional from pydantic import Field @@ -74,12 +75,12 @@ def __str__(self): class SOAREnrichIn(RFBaseModel): """Model used to validate payload sent to SOAR enrichment endpoint.""" - ip: list[str] | None = None - domain: list[str] | None = None - url: list[str] | None = None - hash_: list[str] | None = Field(alias='hash', default=None) - vulnerability: list[str] | None = None - companybydomain: list[str] | None = None + ip: Optional[list[str]] = None + domain: Optional[list[str]] = None + url: Optional[list[str]] = None + hash_: Optional[list[str]] = Field(alias='hash', default=None) + vulnerability: Optional[list[str]] = None + companybydomain: Optional[list[str]] = None class SOAREnrichOut(RFBaseModel): @@ -87,4 +88,4 @@ class SOAREnrichOut(RFBaseModel): entity: str is_enriched: bool - content: SOAREnrichedEntity | None = None + content: Optional[SOAREnrichedEntity] = None diff --git a/psengine/enrich/soar_mgr.py b/psengine/enrich/soar_mgr.py index 9576e167..e63a283d 100644 --- a/psengine/enrich/soar_mgr.py +++ b/psengine/enrich/soar_mgr.py @@ -13,7 +13,7 @@ import logging from itertools import chain -from typing import Annotated +from typing import Annotated, Optional from pydantic import validate_call from typing_extensions import Doc @@ -31,7 +31,7 @@ class SoarMgr: def __init__( self, - rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None, + rf_token: Annotated[Optional[str], Doc('Recorded Future API token.')] = None, ): """Initialize the `SoarMgr` object.""" self.log = logging.getLogger(__name__) @@ -41,17 +41,19 @@ def __init__( @debug_call def soar( self, - ip: Annotated[list[str] | None, Doc('List of IP addresses to enrich.')] = None, - domain: Annotated[list[str] | None, Doc('List of domains to enrich.')] = None, - hash_: Annotated[list[str] | None, Doc('List of file hashes to enrich.')] = None, + ip: Annotated[Optional[list[str]], Doc('List of IP addresses to enrich.')] = None, + domain: Annotated[Optional[list[str]], Doc('List of domains to enrich.')] = None, + hash_: Annotated[Optional[list[str]], Doc('List of file hashes to enrich.')] = None, vulnerability: Annotated[ - list[str] | None, Doc('List of vulnerabilities to enrich.') + Optional[list[str]], Doc('List of vulnerabilities to enrich.') ] = None, - url: Annotated[list[str] | None, Doc('List of URLs to enrich.')] = None, + url: Annotated[Optional[list[str]], Doc('List of URLs to enrich.')] = None, companybydomain: Annotated[ - list[str] | None, Doc('List of company domains to enrich.') + Optional[list[str]], Doc('List of company domains to enrich.') ] = None, - max_workers: Annotated[int | None, Doc('Number of workers to multithread requests.')] = 0, + max_workers: Annotated[ + Optional[int], Doc('Number of workers to multithread requests.') + ] = 0, ) -> Annotated[list[SOAREnrichOut], Doc('A list of enriched data for the provided IOCs.')]: """Enrich multiple types of IOCs via the SOAR API. diff --git a/psengine/entity_lists/entity_list.py b/psengine/entity_lists/entity_list.py index 6c234700..d91e56b1 100644 --- a/psengine/entity_lists/entity_list.py +++ b/psengine/entity_lists/entity_list.py @@ -15,7 +15,7 @@ import time from datetime import datetime from functools import total_ordering -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import ConfigDict, Field, validate_call from typing_extensions import Doc @@ -66,7 +66,7 @@ class ListEntity(RFBaseModel): """Validate data received from `/{listId}/entities` endpoint.""" entity: IdNameType - context: dict | None = None + context: Optional[dict] = None status: str added: datetime @@ -99,8 +99,8 @@ class EntityList(RFBaseModel): updated: datetime owner_id: str owner_name: str - organisation_id: str | None = None - organisation_name: str | None = None + organisation_id: Optional[str] = None + organisation_name: Optional[str] = None owner_organisation_details: OwnerOrganisationDetails = Field( default_factory=OwnerOrganisationDetails ) @@ -160,9 +160,9 @@ def format_field(name, value): def add( self, entity: Annotated[ - str | tuple[str, str], Doc('ID or (name, type) tuple of the entity to add.') + Union[str, tuple[str, str]], Doc('ID or (name, type) tuple of the entity to add.') ], - context: Annotated[dict | None, Doc('Context object for the entity.')] = None, + context: Annotated[Optional[dict], Doc('Context object for the entity.')] = None, ) -> Annotated[ ListEntityOperationResponse, Doc('Response from the `list/{id}/entity/add` endpoint.') ]: @@ -182,7 +182,7 @@ def add( def remove( self, entity: Annotated[ - str | tuple[str, str], Doc('ID or (name, type) tuple of the entity to remove.') + Union[str, tuple[str, str]], Doc('ID or (name, type) tuple of the entity to remove.') ], ) -> Annotated[ ListEntityOperationResponse, Doc('Response from the `list/{id}/entity/remove` endpoint.') @@ -203,7 +203,7 @@ def remove( def bulk_add( self, entities: Annotated[ - list[str | tuple[str, str]], + list[Union[str, tuple[str, str]]], Doc('List of entity string IDs or (name, type) tuples to add.'), ], ) -> Annotated[ @@ -238,7 +238,7 @@ def bulk_add( def bulk_remove( self, entities: Annotated[ - list[str | tuple[str, str]], + list[Union[str, tuple[str, str]]], Doc('List of entity string IDs or (name, type) tuples to remove.'), ], ) -> Annotated[ @@ -346,7 +346,7 @@ def info(self) -> Annotated[ListInfoOut, Doc('Response from the `list/{id}/info` def _bulk_op( self, entities: Annotated[ - list[str | tuple[str, str]], + list[Union[str, tuple[str, str]]], Doc('List of entity string IDs or (name, type) tuples to process.'), ], operation: Annotated[ @@ -405,10 +405,10 @@ def _bulk_op( def _list_op( self, entity: Annotated[ - str | tuple[str, str], Doc('ID or (name, type) tuple of the entity to process.') + Union[str, tuple[str, str]], Doc('ID or (name, type) tuple of the entity to process.') ], op_name: Annotated[str, Doc("Operation to perform. Must be 'added' or 'removed'.")], - context: Annotated[dict | None, Doc('Optional context object for the entity.')] = None, + context: Annotated[Optional[dict], Doc('Optional context object for the entity.')] = None, ) -> Annotated[ ListEntityOperationResponse, Doc('Response from the `list/{id}/entity/[add|remove]` endpoint.'), diff --git a/psengine/entity_lists/entity_list_mgr.py b/psengine/entity_lists/entity_list_mgr.py index c5fe610d..12ccaaac 100644 --- a/psengine/entity_lists/entity_list_mgr.py +++ b/psengine/entity_lists/entity_list_mgr.py @@ -12,7 +12,7 @@ ############################################################################################## import logging -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import validate_call from typing_extensions import Doc @@ -32,7 +32,7 @@ class EntityListMgr: def __init__( self, - rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None, + rf_token: Annotated[Optional[str], Doc('Recorded Future API token.')] = None, ) -> None: """Initialize the `EntityListMgr` object.""" self.log = logging.getLogger(__name__) @@ -44,7 +44,9 @@ def __init__( @connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError) def fetch( self, - list_: Annotated[str | tuple[str, str], Doc('List string ID or tuple of (name, type).')], + list_: Annotated[ + Union[str, tuple[str, str]], Doc('List string ID or tuple of (name, type).') + ], ) -> Annotated[EntityList, Doc('RFList object for the given list ID.')]: """Get a list by its ID. Use this method to retrieve list info. @@ -102,8 +104,8 @@ def create( @connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError) def search( self, - list_name: Annotated[str | None, Doc('List name to search.')] = None, - list_type: Annotated[str | None, Doc('List type to filter by. Ignored if None.')] = None, + list_name: Annotated[Optional[str], Doc('List name to search.')] = None, + list_type: Annotated[Optional[str], Doc('List type to filter by. Ignored if None.')] = None, max_results: Annotated[int, Doc('Maximum number of lists to return.')] = DEFAULT_LIMIT, ) -> Annotated[list[EntityList], Doc('List of EntityList objects from `list/search`.')]: """Search lists. @@ -136,7 +138,7 @@ def search( ] @debug_call - def _resolve_list_id(self, list_: str | tuple[str, str]) -> str: + def _resolve_list_id(self, list_: Union[str, tuple[str, str]]) -> str: """Resolves a list name to a list ID. Args: diff --git a/psengine/entity_lists/models.py b/psengine/entity_lists/models.py index faa7fd86..7c9723b8 100644 --- a/psengine/entity_lists/models.py +++ b/psengine/entity_lists/models.py @@ -11,6 +11,7 @@ # accessed from any third party API. # ############################################################################################## +from typing import Optional from pydantic import Field @@ -27,11 +28,11 @@ class Organisation(RFBaseModel): class OwnerOrganisationDetails(RFBaseModel): - owner_id: str | None = None - owner_name: str | None = None - organisations: list[Organisation] | None = [] - enterprise_id: str | None = None - enterprise_name: str | None = None + owner_id: Optional[str] = None + owner_name: Optional[str] = None + organisations: Optional[list[Organisation]] = [] + enterprise_id: Optional[str] = None + enterprise_name: Optional[str] = None class CreateRequestModel(RFBaseModel): @@ -44,9 +45,9 @@ class CreateRequestModel(RFBaseModel): class SearchInModel(RFBaseModel): """Validate data sent to `/search` endpoint.""" - name: str | None = None + name: Optional[str] = None type_: str = Field(alias='type', default=None) - limit: int | None = None + limit: Optional[int] = None class InfoRequestModel(RFBaseModel): @@ -71,7 +72,7 @@ class AddEntityRequestModel(RFBaseModel): """Validate data sent to `/{listId}/entity/add` endpoint.""" entity: EntityID - context: dict | None = None + context: Optional[dict] = None class RemoveEntityRequestModel(RFBaseModel): diff --git a/psengine/entity_match/entity_match.py b/psengine/entity_match/entity_match.py index 1d5cbcbd..30fe47e3 100644 --- a/psengine/entity_match/entity_match.py +++ b/psengine/entity_match/entity_match.py @@ -11,6 +11,7 @@ # accessed from any third party API. # ############################################################################################## +from typing import Optional, Union from pydantic import Field @@ -22,7 +23,7 @@ class EntityMatchIn(RFBaseModel): """Model to validate data sent to `entity-match/match` endpoint.""" name: str - type_: list[str] | None = Field(alias='type', default=[]) + type_: Optional[list[str]] = Field(alias='type', default=[]) limit: int = Field(default=10) @@ -86,7 +87,7 @@ class ResolvedEntity(RFBaseModel): entity: str is_found: bool - content: str | IdNameType + content: Union[str, IdNameType] def __str__(self): if isinstance(self.content, IdNameType): diff --git a/psengine/entity_match/entity_match_mgr.py b/psengine/entity_match/entity_match_mgr.py index 10ce9b60..1ed7f6db 100644 --- a/psengine/entity_match/entity_match_mgr.py +++ b/psengine/entity_match/entity_match_mgr.py @@ -12,7 +12,7 @@ ############################################################################################## import logging -from typing import Annotated +from typing import Annotated, Optional, Union from urllib.parse import quote from pydantic import Field, validate_call @@ -32,7 +32,7 @@ class EntityMatchMgr: def __init__( self, - rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None, + rf_token: Annotated[Optional[str], Doc('Recorded Future API token.')] = None, ): """Initialize the `EntityMatchMgr` object.""" self.log = logging.getLogger(__name__) @@ -45,7 +45,7 @@ def match( self, entity_name: Annotated[str, Doc('Name of the entity.')], entity_type: Annotated[ - list | str | None, Doc('Type or list of types of the entity, if known.') + Optional[Union[list, str]], Doc('Type or list of types of the entity, if known.') ] = None, limit: Annotated[int, Doc('Maximum number of matches to return.')] = DEFAULT_LIMIT, ) -> Annotated[list[ResolvedEntity], Doc('List of deduplicated resolved entity matches.')]: @@ -75,10 +75,10 @@ def match( def resolve_entity_id( self, entity_name: Annotated[str, Doc('Name of the entity.')], - entity_type: Annotated[str | None, Doc('Type of the entity, if known.')] = Field( + entity_type: Annotated[Optional[str], Doc('Type of the entity, if known.')] = Field( min_length=2, default=None ), - limit: Annotated[int | None, Doc('Number of matches to check.')] = DEFAULT_LIMIT, + limit: Annotated[Optional[int], Doc('Number of matches to check.')] = DEFAULT_LIMIT, ) -> Annotated[ResolvedEntity, Doc('Resolved entity match.')]: """Resolve an entity name (and optionally type) to an ID. @@ -116,14 +116,14 @@ def resolve_entity_id( def resolve_entity_ids( self, entities: Annotated[ - list[str] | list[tuple[str, str]], + Union[list[str], list[tuple[str, str]]], Doc('List of entity names or (name, type) tuples.'), ], limit: Annotated[ - int | None, Doc('Number of matches to return for each entity.') + Optional[int], Doc('Number of matches to return for each entity.') ] = DEFAULT_LIMIT, max_workers: Annotated[ - int | None, Doc('Number of workers to multithread requests.') + Optional[int], Doc('Number of workers to multithread requests.') ] = DEFAULT_MAX_WORKERS, ) -> Annotated[list[ResolvedEntity], Doc('Resolved entities for the provided input list.')]: """Resolve a list of entities to their corresponding IDs. @@ -179,7 +179,9 @@ def lookup( def lookup_bulk( self, ids: Annotated[list[str], Doc('List of Recorded Future IDs to look up.')], - max_workers: Annotated[int | None, Doc('Number of workers to multithread requests.')] = 0, + max_workers: Annotated[ + Optional[int], Doc('Number of workers to multithread requests.') + ] = 0, ) -> Annotated[ list[EntityLookup], Doc('List of EntityLookup objects containing entity details.') ]: @@ -206,9 +208,11 @@ def lookup_bulk( def _bulk_resolution_helper( self, entity: Annotated[ - tuple[str, str | None], Doc('Tuple containing entity name and optional type.') + tuple[str, Optional[str]], Doc('Tuple containing entity name and optional type.') ], - limit: Annotated[int | None, Doc('Limit of results to check for matches.')] = DEFAULT_LIMIT, + limit: Annotated[ + Optional[int], Doc('Limit of results to check for matches.') + ] = DEFAULT_LIMIT, ) -> Annotated[ResolvedEntity, Doc('ResolvedEntity object.')]: """Helper function for multithreaded entity resolution.""" return self.resolve_entity_id(entity[0], entity[1], limit) diff --git a/psengine/fusion/fusion_mgr.py b/psengine/fusion/fusion_mgr.py index 4b2e7600..f0bc8d10 100644 --- a/psengine/fusion/fusion_mgr.py +++ b/psengine/fusion/fusion_mgr.py @@ -13,7 +13,7 @@ import logging from pathlib import Path -from typing import Annotated +from typing import Annotated, Union from urllib.parse import quote from pydantic import validate_call @@ -49,7 +49,7 @@ def __init__(self, rf_token: str = None): @validate_call @connection_exceptions(ignore_status_code=[], exception_to_raise=FusionGetFileError) def get_files( - self, file_paths: Annotated[str | list[str], Doc('One or more paths to fetch')] + self, file_paths: Annotated[Union[str, list[str]], Doc('One or more paths to fetch')] ) -> Annotated[list[FileGetOut], Doc('A FusionFile object with name and content of the file')]: """Get one or more files. @@ -114,7 +114,7 @@ def post_file( @debug_call @validate_call def delete_files( - self, file_paths: Annotated[str | list[str], Doc('One or more paths to delete')] + self, file_paths: Annotated[Union[str, list[str]], Doc('One or more paths to delete')] ) -> Annotated[list[FileDeleteOut], Doc('A list of deleted files.')]: """Delete one or more files. @@ -140,7 +140,7 @@ def delete_files( @debug_call @validate_call def head_files( - self, file_paths: Annotated[str | list[str], Doc('One or more paths to check')] + self, file_paths: Annotated[Union[str, list[str]], Doc('One or more paths to check')] ) -> Annotated[list[FileHeadOut], Doc('List of headers info for the requested files.')]: """Head of one or more files. diff --git a/psengine/fusion/models.py b/psengine/fusion/models.py index 0fcbec58..3c2ab16e 100644 --- a/psengine/fusion/models.py +++ b/psengine/fusion/models.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional from pydantic import Field @@ -22,12 +23,12 @@ class FileInfoOut(RFBaseModel): type_: str = Field(alias='type') name: str path: str - format: str | None = None - hash: str | None = None - created: datetime | None = None - size: int | None = None - flow: str | None = None - owner: str | None = None + format: Optional[str] = None + hash: Optional[str] = None + created: Optional[datetime] = None + size: Optional[int] = None + flow: Optional[str] = None + owner: Optional[str] = None class DirectoryListOut(RFBaseModel): @@ -51,8 +52,8 @@ class FileDeleteOut(RFBaseModel): class FileHeadOut(RFBaseModel): path: str exists: bool - content_disposition: str | None = Field(alias='content-disposition', default=None) - content_length: int | None = Field(alias='Content-Length', default=None) - content_type: str | None = Field(alias='content-type', default=None) - etag: str | None = None - last_modified: str | None = Field(alias='last-modified', default=None) + content_disposition: Optional[str] = Field(alias='content-disposition', default=None) + content_length: Optional[int] = Field(alias='Content-Length', default=None) + content_type: Optional[str] = Field(alias='content-type', default=None) + etag: Optional[str] = None + last_modified: Optional[str] = Field(alias='last-modified', default=None) diff --git a/psengine/helpers/helpers.py b/psengine/helpers/helpers.py index 7f180974..115c6e27 100644 --- a/psengine/helpers/helpers.py +++ b/psengine/helpers/helpers.py @@ -14,17 +14,16 @@ import functools import json import logging -import operator import os import platform import re import sys -from collections.abc import Callable, Iterable +from collections.abc import Iterable from concurrent.futures import ThreadPoolExecutor -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from inspect import getmodule, isclass, signature from pathlib import Path -from typing import Annotated +from typing import Annotated, Callable, Optional, Union from dateutil.parser import parse as date_parse from pydantic import BaseModel @@ -39,11 +38,11 @@ from typing_extensions import Doc from ..common_models import RFBaseModel -from ..constants import ROOT_DIR, TIMESTAMP_STR +from ..constants import ROOT_DIR from ..errors import ReadFileError, RecordedFutureError, WriteFileError LOG = logging.getLogger('psengine.helpers') -VALID_TIME_REGEX = r'^([-+]?)([1-9]?[0-9]+[dDhHmM])$' +VALID_TIME_REGEX = r'^(-?)([1-9]?[0-9]+[dDhH])$' IDS = ['ip:', 'idn:', 'url:', 'hash:', 'id:'] @@ -112,7 +111,7 @@ def wrapped(*args, **kwargs): def dump_models( models: Annotated[ - BaseModel | list[BaseModel], + Union[BaseModel, list[BaseModel]], Doc('A Pydantic model or list of models to serialize.'), ], ) -> Annotated[list[str], Doc('List of models serialized as JSON strings.')]: @@ -171,61 +170,38 @@ class TimeHelpers: @staticmethod def rel_time_to_date( - relative_time: Annotated[str, Doc("Relative time string like '7d', '3h', '4m' (minutes).")], - start_time: Annotated[ - str | None, - Doc( - 'Defined starting time in format %Y-%m-%d %H:%M:%S, if none it will be the run time' - ), - ] = None, + relative_time: Annotated[ + str, Doc("Relative time string like '7d', '3h'. Minutes not supported.") + ], ) -> Annotated[str, Doc("Formatted date string in ISO format, e.g., '2022-08-08T13:11'.")]: """Convert a relative time to a date. - `relative_time` specification: - - `1h` means 1 hour ago - - `-1h` means 1 hour ago - - `+1h` means 1 hour in the future - Example: ```python rel_time_to_date("1h") # returns ISO datetime string ~1 hour ago rel_time_to_date("1d") # returns ISO datetime string ~1 day ago - rel_time_to_date("+10m") # returns ISO datetime string 10 minutes in the future - rel_time_to_date("1h", "2022-01-22 22:12:20") # returns 1 hour ago from the specified - rel_time_to_date("+1h", "2022-01-22 22:14:20") # returns 1 hour after from the specified ``` Raises: - ValueError: If `relative_time` is invalid. + ValueError: If the relative time is invalid. """ logger = logging.getLogger(__name__) match = re.match(VALID_TIME_REGEX, relative_time) if match is None: raise ValueError( - f"Invalid relative time '{relative_time}'. Accepted format: [-|+]?[integer][h|d|m]", + f"Invalid relative time '{relative_time}'. Accepted format: [-|][integer][h|d]", ) - start_time = ( - datetime.strptime(start_time, TIMESTAMP_STR) - if start_time - else datetime.now(timezone.utc) - ) - - sign = match.groups()[0] - operation = operator.add if sign == '+' else operator.sub - relative_time = match.groups()[-1] + time_now = datetime.utcnow() digit = int(re.findall(r'^\d+', relative_time)[0]) if relative_time.endswith('d'): - result = (operation(start_time, timedelta(days=digit))).strftime('%Y-%m-%dT%H:%M') - elif relative_time.endswith('h'): - result = (operation(start_time, timedelta(hours=digit))).strftime('%Y-%m-%dT%H:%M') + subtracted = (time_now - timedelta(days=digit)).strftime('%Y-%m-%dT%H:%M') else: - result = (operation(start_time, timedelta(minutes=digit))).strftime('%Y-%m-%dT%H:%M') - - logger.debug(f'UTC Time now: {start_time}') - logger.debug(f'Relative time {sign}{relative_time} to date: {result}') + subtracted = (time_now - timedelta(hours=digit)).strftime('%Y-%m-%dT%H:%M') + logger.debug(f'UTC Time now: {time_now}') + logger.debug(f'Relative time -{relative_time} to date: {subtracted}') - return result + return subtracted @staticmethod def is_rel_time_valid( @@ -298,14 +274,14 @@ class OSHelpers: @staticmethod def os_platform() -> Annotated[ - str | None, Doc('OS platform info string, or None if unavailable.') + Optional[str], Doc('OS platform info string, or None if unavailable.') ]: """Get the OS platform information, for example: `macOS-13.0-x86_64-i386-64bit`.""" return platform.platform(aliased=True, terse=False) or None @staticmethod def mkdir( - path: Annotated[str | Path, Doc('Path to directory to create.')], + path: Annotated[Union[str, Path], Doc('Path to directory to create.')], ) -> Annotated[Path, Doc('Path to the directory created.')]: """Safely create a directory. @@ -325,10 +301,10 @@ def mkdir( try: path.mkdir(parents=True, exist_ok=True) except PermissionError as err: - raise WriteFileError(f'Directory {path} is not writable') from err - # In case it already exists, check if it is writable + raise WriteFileError(f'Directory {path} is not writeable') from err + # In case it already exists, check if it is writeable if not os.access(path, os.W_OK): - raise WriteFileError(f'Directory {path} is not writable') + raise WriteFileError(f'Directory {path} is not writeable') return path @@ -337,7 +313,7 @@ class FileHelpers: @staticmethod def read_csv( - csv_file: Annotated[str | Path, Doc('Path to CSV file.')], + csv_file: Annotated[Union[str, Path], Doc('Path to CSV file.')], as_dict: Annotated[bool, Doc('Return rows as dictionaries keyed by header.')] = False, single_column: Annotated[bool, Doc('Return only first column values as strings.')] = False, ) -> Annotated[list, Doc('List of rows from the CSV file.')]: @@ -407,7 +383,7 @@ def read_csv( @staticmethod def write_file( to_write: Annotated[str, Doc('Content to write to file.')], - output_directory: Annotated[str | Path, Doc('Directory to write the file into.')], + output_directory: Annotated[Union[str, Path], Doc('Directory to write the file into.')], fname: Annotated[str, Doc('Name of the file to write.')], ) -> Annotated[Path, Doc('Path to the file written.')]: """Write string content to a file. @@ -485,8 +461,8 @@ class Validators: @staticmethod def convert_str_to_list( - value: Annotated[str | list | None, Doc('String or list to convert.')], - ) -> Annotated[list | None, Doc('Converted list with None values removed.')]: + value: Annotated[Union[str, list, None], Doc('String or list to convert.')], + ) -> Annotated[Union[list, None], Doc('Converted list with None values removed.')]: """Convert value from str to list and remove None values.""" if value: value = value if isinstance(value, list) else [value] @@ -506,8 +482,10 @@ def convert_relative_time( @staticmethod def check_uhash_prefix( - value: Annotated[str | list, Doc('String or list of strings to check for uhash prefix.')], - ) -> Annotated[str | list, Doc("String or list with 'uhash:' prefix ensured.")]: + value: Annotated[ + Union[str, list], Doc('String or list of strings to check for uhash prefix.') + ], + ) -> Annotated[Union[str, list], Doc("String or list with 'uhash:' prefix ensured.")]: """Validate that all fields start with 'uhash:' and add it if missing.""" uhash = 'uhash:' if isinstance(value, str): diff --git a/psengine/identity/constants.py b/psengine/identity/constants.py index 5ade6732..49f9861d 100644 --- a/psengine/identity/constants.py +++ b/psengine/identity/constants.py @@ -12,4 +12,4 @@ ############################################################################################## DETECTIONS_PER_PAGE = 20 -MAXIMUM_IDENTITIES = 1000 +MAXIMUM_IDENTITIES = 500 diff --git a/psengine/identity/identity.py b/psengine/identity/identity.py index f9cecb1a..a86b30c9 100644 --- a/psengine/identity/identity.py +++ b/psengine/identity/identity.py @@ -13,7 +13,7 @@ from datetime import datetime from functools import total_ordering -from typing import Annotated +from typing import Annotated, Optional from pydantic import AfterValidator, BeforeValidator, Field, field_validator @@ -64,16 +64,16 @@ class Detection(RFBaseModel): """ id_: str = Field(alias='id') - organization_id: Annotated[list[str] | None, BeforeValidator(Validators.check_uhash_prefix)] = ( - None - ) + organization_id: Annotated[ + Optional[list[str]], BeforeValidator(Validators.check_uhash_prefix) + ] = None novel: bool type_: str = Field(alias='type') subject: str password: Password - authorization_service: AuthorizationService | None = None + authorization_service: Optional[AuthorizationService] = None cookies: list[Cookie] - malware_family: IdName | None = None + malware_family: Optional[IdName] = None dump: DumpSearchOut created: datetime @@ -125,7 +125,7 @@ class CredentialSearch(RFBaseModel): """ login: str - login_sha1: str | None = None # This is used only by CredentialLookupIn.subject_login + login_sha1: Optional[str] = None # This is used only by CredentialLookupIn.subject_login domain: str def __hash__(self): @@ -249,10 +249,10 @@ class Credential(RFBaseModel): first_downloaded: datetime latest_downloaded: datetime exposed_secret: SecretDetails - compromise: dict[str, datetime] | None = None - malware_family: IdName | None = None - authorization_service: AuthorizationService | None = None - cookies: list[Cookie] | None = None + compromise: Optional[dict[str, datetime]] = None + malware_family: Optional[IdName] = None + authorization_service: Optional[AuthorizationService] = None + cookies: Optional[list[Cookie]] = None def __hash__(self): hashes = ', '.join(sorted(a.hash_ or a.hash_prefix for a in self.exposed_secret.hashes)) @@ -316,15 +316,15 @@ class DetectionsIn(RFBaseModel): """Model for payload sent to POST `/identity/detections` endpoint.""" organization_id: Annotated[ - list[str] | None, + Optional[list[str]], BeforeValidator(Validators.convert_str_to_list), AfterValidator(Validators.check_uhash_prefix), ] = [] - include_enterprise_level: bool | None = None - filter: DetectionsFilterIn | None = Field(default_factory=DetectionsFilterIn) + include_enterprise_level: Optional[bool] = None + filter: Optional[DetectionsFilterIn] = Field(default_factory=DetectionsFilterIn) limit: int - offset: str | None = None - created: DetectionsCreated | None = Field(default_factory=DetectionsCreated) + offset: Optional[str] = None + created: Optional[DetectionsCreated] = Field(default_factory=DetectionsCreated) class IncidentReportIn(IdentityOrgIn): @@ -337,7 +337,7 @@ class IncidentReportIn(IdentityOrgIn): class IncidentReportOut(RFBaseModel): """Model for payload received by POST `/identity/incident/report` endpoint.""" - details: IncidentReportDetails | None = None + details: Optional[IncidentReportDetails] = None credentials: list[IncidentReportCredentials] @field_validator('details', mode='before') @@ -359,19 +359,19 @@ class HostnameLookupIn(BaseIdentityIn): class IPLookupIn(BaseIdentityIn): """Model for payload sent to POST `/identity/ip/lookup` endpoint.""" - ip: str | None = None - range_: IPRange | None = Field(alias='range', default=None) + ip: Optional[str] = None + range_: Optional[IPRange] = Field(alias='range', default=None) class CredentialsLookupIn(BaseIdentityIn): """Model for payload sent to POST `/identity/credentials/lookup` endpoint.""" - subjects: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - subjects_sha1: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = ( - None - ) - subjects_login: list[CredentialSearch] | None = None - filter: FilterIn | None = None + subjects: Annotated[Optional[list[str]], BeforeValidator(Validators.convert_str_to_list)] = None + subjects_sha1: Annotated[ + Optional[list[str]], BeforeValidator(Validators.convert_str_to_list) + ] = None + subjects_login: Optional[list[CredentialSearch]] = None + filter: Optional[FilterIn] = None class CredentialsSearchIn(BaseIdentityIn): @@ -379,14 +379,14 @@ class CredentialsSearchIn(BaseIdentityIn): domains: Annotated[list[str], BeforeValidator(Validators.convert_str_to_list)] domain_types: Annotated[ - list[DomainTypes] | None, BeforeValidator(Validators.convert_str_to_list) + Optional[list[DomainTypes]], BeforeValidator(Validators.convert_str_to_list) ] = None - filter: FilterIn | None = None + filter: Optional[FilterIn] = None class DumpSearchIn(RFBaseModel): """Model for payload sent to POST `/identity/metadata/dump/search` endpoint.""" names: Annotated[list[str], BeforeValidator(Validators.convert_str_to_list)] - limit: int | None = None + limit: Optional[int] = None diff --git a/psengine/identity/identity_mgr.py b/psengine/identity/identity_mgr.py index 5810b011..0c441a81 100644 --- a/psengine/identity/identity_mgr.py +++ b/psengine/identity/identity_mgr.py @@ -13,7 +13,7 @@ import logging from contextlib import suppress -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import Field, validate_call from typing_extensions import Doc @@ -61,7 +61,7 @@ class IdentityMgr: def __init__( self, - rf_token: Annotated[str | None, Doc('A Recorded Future API token.')] = None, + rf_token: Annotated[Optional[str], Doc('A Recorded Future API token.')] = None, ) -> None: """Initializes the `IdentityMgr` object. @@ -78,38 +78,38 @@ def __init__( def fetch_detections( self, domains: Annotated[ - str | list[str] | None, Doc('A domain or a list of domains to filter.') + Union[str, list[str], None], Doc('A domain or a list of domains to filter.') ] = None, created_gte: Annotated[ - str | None, + Optional[str], Doc( 'A timestamp to return detections created on or after it (e.g., "7d" or ISO 8601).' ), ] = None, created_lt: Annotated[ - str | None, Doc('A timestamp to return detections created before it.') + Optional[str], Doc('A timestamp to return detections created before it.') ] = None, - cookies: Annotated[str | None, Doc('A filter by cookie type.')] = None, + cookies: Annotated[Optional[str], Doc('A filter by cookie type.')] = None, detection_type: Annotated[ - str | None, Doc('A detection type to filter by ("workforce", "external").') + Optional[str], Doc('A detection type to filter by ("workforce", "external").') ] = None, organization_id: Annotated[ - list[str] | str | None, + Union[list[str], str, None], Doc('Organization ID or a list of IDs for multi-org filtering.'), ] = None, include_enterprise_level: Annotated[ - bool | None, Doc('Whether to include enterprise-level detections.') + Optional[bool], Doc('Whether to include enterprise-level detections.') ] = None, novel_only: Annotated[ - bool | None, Doc('If True, only return novel (previously unseen) detections.') + Optional[bool], Doc('If True, only return novel (previously unseen) detections.') ] = None, max_results: Annotated[ - int | None, Doc('The maximum number of detections returned.') + Optional[int], Doc('The maximum number of detections returned.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DEFAULT_LIMIT), detections_per_page: Annotated[ - int | None, Doc('The number of detections per page for pagination.') + Optional[int], Doc('The number of detections per page for pagination.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DETECTIONS_PER_PAGE), - offset: Annotated[str | None, Doc('An offset token for paginated results.')] = None, + offset: Annotated[Optional[str], Doc('An offset token for paginated results.')] = None, ) -> Annotated[Detections, Doc('A structured response containing the detection records.')]: """Fetch latest detections. @@ -165,44 +165,44 @@ def lookup_hostname( self, hostname: Annotated[str, Doc('The hostname of a compromised machine.')], first_downloaded_gte: Annotated[ - str | None, + Optional[str], Doc('First date when these credentials were received and indexed by Recorded Future.'), ] = None, latest_downloaded_gte: Annotated[ - str | None, + Optional[str], Doc('Latest date when these credentials were received and indexed by Recorded Future.'), ] = None, exfiltration_date_gte: Annotated[ - str | None, + Optional[str], Doc('Date when the infostealer malware exfiltrated data from the victim device.'), ] = None, - properties: Annotated[str | list[str] | None, Doc('Password properties.')] = None, - breach_name: Annotated[str | None, Doc('The name of a breach.')] = None, - breach_date: Annotated[str | None, Doc('The date of a breach.')] = None, - dump_name: Annotated[str | None, Doc('The name of a database dump.')] = None, - dump_date: Annotated[str | None, Doc('The date of a database dump.')] = None, + properties: Annotated[Union[str, list[str], None], Doc('Password properties.')] = None, + breach_name: Annotated[Optional[str], Doc('The name of a breach.')] = None, + breach_date: Annotated[Optional[str], Doc('The date of a breach.')] = None, + dump_name: Annotated[Optional[str], Doc('The name of a database dump.')] = None, + dump_date: Annotated[Optional[str], Doc('The date of a database dump.')] = None, username_properties: Annotated[ - str | list[str] | None, Doc("Username properties. Only valid value is 'Email'.") + Union[str, list[str], None], Doc("Username properties. Only valid value is 'Email'.") ] = None, authorization_technologies: Annotated[ - str | list[str] | None, Doc('Authorization technologies to filter by.') + Union[str, list[str], None], Doc('Authorization technologies to filter by.') ] = None, authorization_protocols: Annotated[ - str | list[str] | None, Doc('Authorization protocols to filter by.') + Union[str, list[str], None], Doc('Authorization protocols to filter by.') ] = None, malware_families: Annotated[ - str | list[str] | None, Doc('Known infostealer malware families.') + Union[str, list[str], None], Doc('Known infostealer malware families.') ] = None, organization_id: Annotated[ - str | None, Doc('An organization ID if utilizing a multi-org setup.') + Optional[str], Doc('An organization ID if utilizing a multi-org setup.') ] = None, max_results: Annotated[ - int | None, Doc('The maximum number of credential records returned.') + Optional[int], Doc('The maximum number of credential records returned.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DEFAULT_LIMIT), identities_per_page: Annotated[ - int | None, Doc('The number of credentials per page for pagination.') + Optional[int], Doc('The number of credentials per page for pagination.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DETECTIONS_PER_PAGE), - offset: Annotated[str | None, Doc('An offset token for paginated results.')] = None, + offset: Annotated[Optional[str], Doc('An offset token for paginated results.')] = None, ) -> Annotated[list[LeakedIdentity], Doc('A list containing the leaked identity records.')]: """Return credentials for a given hostname. @@ -262,11 +262,13 @@ def lookup_hostname( def lookup_password( self, hash_prefix: Annotated[ - str | None, Doc('The prefix of the password hash to be looked up.') + Optional[str], Doc('The prefix of the password hash to be looked up.') + ] = None, + algorithm: Annotated[ + Optional[str], Doc('The algorithm used for the password hash.') ] = None, - algorithm: Annotated[str | None, Doc('The algorithm used for the password hash.')] = None, passwords: Annotated[ - list[tuple[str, str]] | None, + Optional[list[tuple[str, str]]], Doc('A list of tuples containing hash prefixes and their respective algorithms.'), ] = None, ) -> Annotated[list[PasswordLookup], Doc('A list of password lookup results.')]: @@ -330,50 +332,50 @@ def lookup_password( @connection_exceptions(ignore_status_code=[], exception_to_raise=IdentityLookupError) def lookup_ip( self, - ip: Annotated[str | None, Doc('A subject IP address.')] = None, - range_gte: Annotated[str | None, Doc('An IP address lower bound included.')] = None, - range_gt: Annotated[str | None, Doc('An IP address lower bound excluded.')] = None, - range_lte: Annotated[str | None, Doc('An IP address upper bound included.')] = None, - range_lt: Annotated[str | None, Doc('An IP address upper bound excluded.')] = None, + ip: Annotated[Optional[str], Doc('A subject IP address.')] = None, + range_gte: Annotated[Optional[str], Doc('An IP address lower bound included.')] = None, + range_gt: Annotated[Optional[str], Doc('An IP address lower bound excluded.')] = None, + range_lte: Annotated[Optional[str], Doc('An IP address upper bound included.')] = None, + range_lt: Annotated[Optional[str], Doc('An IP address upper bound excluded.')] = None, first_downloaded_gte: Annotated[ - str | None, + Optional[str], Doc('First date when these credentials were received and indexed by Recorded Future.'), ] = None, latest_downloaded_gte: Annotated[ - str | None, + Optional[str], Doc('Latest date when these credentials were received and indexed by Recorded Future.'), ] = None, exfiltration_date_gte: Annotated[ - str | None, + Optional[str], Doc('Date when the infostealer malware exfiltrated data from the victim device.'), ] = None, - properties: Annotated[str | list[str] | None, Doc('Password properties.')] = None, - breach_name: Annotated[str | None, Doc('The name of a breach.')] = None, - breach_date: Annotated[str | None, Doc('The date of a breach.')] = None, - dump_name: Annotated[str | None, Doc('The name of a database dump.')] = None, - dump_date: Annotated[str | None, Doc('The date of a database dump.')] = None, + properties: Annotated[Union[str, list[str], None], Doc('Password properties.')] = None, + breach_name: Annotated[Optional[str], Doc('The name of a breach.')] = None, + breach_date: Annotated[Optional[str], Doc('The date of a breach.')] = None, + dump_name: Annotated[Optional[str], Doc('The name of a database dump.')] = None, + dump_date: Annotated[Optional[str], Doc('The date of a database dump.')] = None, username_properties: Annotated[ - str | list[str] | None, Doc("Username properties. Only valid value is 'Email'.") + Union[str, list[str], None], Doc("Username properties. Only valid value is 'Email'.") ] = None, authorization_technologies: Annotated[ - str | list[str] | None, Doc('Authorization technologies to filter by.') + Union[str, list[str], None], Doc('Authorization technologies to filter by.') ] = None, authorization_protocols: Annotated[ - str | list[str] | None, Doc('Authorization protocols to filter by.') + Union[str, list[str], None], Doc('Authorization protocols to filter by.') ] = None, malware_families: Annotated[ - str | list[str] | None, Doc('Known infostealer malware families.') + Union[str, list[str], None], Doc('Known infostealer malware families.') ] = None, organization_id: Annotated[ - str | None, Doc('An organization ID if utilizing a multi-org setup.') + Optional[str], Doc('An organization ID if utilizing a multi-org setup.') ] = None, max_results: Annotated[ - int | None, Doc('The maximum number of credentials returned.') + Optional[int], Doc('The maximum number of credentials returned.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DEFAULT_LIMIT), identities_per_page: Annotated[ - int | None, Doc('The number of credentials per page for pagination.') + Optional[int], Doc('The number of credentials per page for pagination.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DETECTIONS_PER_PAGE), - offset: Annotated[str | None, Doc('An offset token for paginated results.')] = None, + offset: Annotated[Optional[str], Doc('An offset token for paginated results.')] = None, ) -> Annotated[list[LeakedIdentity], Doc('A list containing the leaked identity records.')]: """Lookup credentials associated with a specified IP address or an IP range. @@ -446,57 +448,57 @@ def lookup_ip( def lookup_credentials( self, subjects: Annotated[ - str | list[str] | None, Doc('An email or a list of emails to be queried.') + Union[str, list[str], None], Doc('An email or a list of emails to be queried.') ] = None, subjects_sha1: Annotated[ - str | list[str] | None, + Union[str, list[str], None], Doc('A SHA1 hash of a username or email to avoid sending the plain subject.'), ] = None, subjects_login: Annotated[ - list[dict[str, str]] | list[CredentialSearch] | None, + Union[list[dict[str, str]], list[CredentialSearch], None], Doc( 'Username details when login is not an email (also requires authorization domain).' ), ] = None, first_downloaded_gte: Annotated[ - str | None, + Optional[str], Doc('First date when these credentials were received and indexed by Recorded Future.'), ] = None, latest_downloaded_gte: Annotated[ - str | None, + Optional[str], Doc('Latest date when these credentials were received and indexed by Recorded Future.'), ] = None, exfiltration_date_gte: Annotated[ - str | None, + Optional[str], Doc('Date when the infostealer malware exfiltrated data from the victim device.'), ] = None, - properties: Annotated[str | list[str] | None, Doc('Password properties.')] = None, - breach_name: Annotated[str | None, Doc('The name of a breach.')] = None, - breach_date: Annotated[str | None, Doc('The date of a breach.')] = None, - dump_name: Annotated[str | None, Doc('The name of a database dump.')] = None, - dump_date: Annotated[str | None, Doc('The date of a database dump.')] = None, + properties: Annotated[Union[str, list[str], None], Doc('Password properties.')] = None, + breach_name: Annotated[Optional[str], Doc('The name of a breach.')] = None, + breach_date: Annotated[Optional[str], Doc('The date of a breach.')] = None, + dump_name: Annotated[Optional[str], Doc('The name of a database dump.')] = None, + dump_date: Annotated[Optional[str], Doc('The date of a database dump.')] = None, username_properties: Annotated[ - str | list[str] | None, Doc("Username properties. Only valid value is 'Email'.") + Union[str, list[str], None], Doc("Username properties. Only valid value is 'Email'.") ] = None, authorization_technologies: Annotated[ - str | list[str] | None, Doc('Authorization technologies to filter by.') + Union[str, list[str], None], Doc('Authorization technologies to filter by.') ] = None, authorization_protocols: Annotated[ - str | list[str] | None, Doc('Authorization protocols to filter by.') + Union[str, list[str], None], Doc('Authorization protocols to filter by.') ] = None, malware_families: Annotated[ - str | list[str] | None, Doc('Known infostealer malware families.') + Union[str, list[str], None], Doc('Known infostealer malware families.') ] = None, organization_id: Annotated[ - str | None, Doc('An organization ID if utilizing a multi-org setup.') + Optional[str], Doc('An organization ID if utilizing a multi-org setup.') ] = None, max_results: Annotated[ - int | None, Doc('The maximum number of credentials returned.') + Optional[int], Doc('The maximum number of credentials returned.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DEFAULT_LIMIT), identities_per_page: Annotated[ - int | None, Doc('The number of credentials per page for pagination.') + Optional[int], Doc('The number of credentials per page for pagination.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DETECTIONS_PER_PAGE), - offset: Annotated[str | None, Doc('An offset token for paginated results.')] = None, + offset: Annotated[Optional[str], Doc('An offset token for paginated results.')] = None, ) -> Annotated[list[LeakedIdentity], Doc('A list containing the leaked identity records.')]: """Lookup credential data for a set of subjects. @@ -570,50 +572,50 @@ def lookup_credentials( @connection_exceptions(ignore_status_code=[], exception_to_raise=IdentitySearchError) def search_credentials( self, - domains: Annotated[str | list[str], Doc('One or more domains to be queried.')], + domains: Annotated[Union[str, list[str]], Doc('One or more domains to be queried.')], domain_types: Annotated[ - str | list[str] | None, + Union[str, list[str], None], Doc("Domain type filter: 'Email', 'Authorization', or both."), ] = None, first_downloaded_gte: Annotated[ - str | None, + Optional[str], Doc('First date when these credentials were received and indexed by Recorded Future.'), ] = None, latest_downloaded_gte: Annotated[ - str | None, + Optional[str], Doc('Latest date when these credentials were received and indexed by Recorded Future.'), ] = None, exfiltration_date_gte: Annotated[ - str | None, + Optional[str], Doc('Date when the infostealer malware exfiltrated data from the victim device.'), ] = None, - properties: Annotated[str | list[str] | None, Doc('Password properties.')] = None, - breach_name: Annotated[str | None, Doc('The name of a breach.')] = None, - breach_date: Annotated[str | None, Doc('The date of a breach.')] = None, - dump_name: Annotated[str | None, Doc('The name of a database dump.')] = None, - dump_date: Annotated[str | None, Doc('The date of a database dump.')] = None, + properties: Annotated[Union[str, list[str], None], Doc('Password properties.')] = None, + breach_name: Annotated[Optional[str], Doc('The name of a breach.')] = None, + breach_date: Annotated[Optional[str], Doc('The date of a breach.')] = None, + dump_name: Annotated[Optional[str], Doc('The name of a database dump.')] = None, + dump_date: Annotated[Optional[str], Doc('The date of a database dump.')] = None, username_properties: Annotated[ - str | list[str] | None, Doc("Username properties. Only valid value is 'Email'.") + Union[str, list[str], None], Doc("Username properties. Only valid value is 'Email'.") ] = None, authorization_technologies: Annotated[ - str | list[str] | None, Doc('Authorization technologies to filter by.') + Union[str, list[str], None], Doc('Authorization technologies to filter by.') ] = None, authorization_protocols: Annotated[ - str | list[str] | None, Doc('Authorization protocols to filter by.') + Union[str, list[str], None], Doc('Authorization protocols to filter by.') ] = None, malware_families: Annotated[ - str | list[str] | None, Doc('Known infostealer malware families.') + Union[str, list[str], None], Doc('Known infostealer malware families.') ] = None, organization_id: Annotated[ - str | None, Doc('An organization ID if utilizing a multi-org setup.') + Optional[str], Doc('An organization ID if utilizing a multi-org setup.') ] = None, max_results: Annotated[ - int | None, Doc('The maximum number of credentials returned.') + Optional[int], Doc('The maximum number of credentials returned.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DEFAULT_LIMIT), identities_per_page: Annotated[ - int | None, Doc('The number of credentials per page for pagination.') + Optional[int], Doc('The number of credentials per page for pagination.') ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DETECTIONS_PER_PAGE), - offset: Annotated[str | None, Doc('An offset token for paginated results.')] = None, + offset: Annotated[Optional[str], Doc('An offset token for paginated results.')] = None, ) -> Annotated[list[CredentialSearch], Doc('A list containing the search results.')]: """Search credential data for a set of domains. @@ -672,9 +674,11 @@ def search_credentials( @connection_exceptions(ignore_status_code=[], exception_to_raise=IdentitySearchError) def search_dump( self, - names: Annotated[str | list[str], Doc('The name(s) of a database dump to search for.')], + names: Annotated[ + Union[str, list[str]], Doc('The name(s) of a database dump to search for.') + ], max_results: Annotated[ - int | None, Doc('Maximum number of dump records to return.') + Optional[int], Doc('Maximum number of dump records to return.') ] = Field(le=MAXIMUM_IDENTITIES, default=DEFAULT_LIMIT), ) -> Annotated[ DumpSearchOut, @@ -720,10 +724,16 @@ def fetch_incident_report( include_details: Annotated[ bool, Doc('Whether to include infected machine details.') ] = True, - organization_id: Annotated[str | None, Doc('The org_id in multi-org setup.')] = None, - max_results: Annotated[int | None, Doc('Maximum number of credentials to return.')] = Field( - ge=1, le=MAXIMUM_IDENTITIES, default=DEFAULT_LIMIT - ), + organization_id: Annotated[ + Union[list[str], str, None], Doc('The org_id(s) in multi-org setup.') + ] = None, + offset: Annotated[Optional[str], Doc('Offset token for paginated results.')] = None, + max_results: Annotated[ + Optional[int], Doc('Maximum number of credentials to return.') + ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DEFAULT_LIMIT), + identities_per_page: Annotated[ + Optional[int], Doc('Number of credentials per page.') + ] = Field(ge=1, le=MAXIMUM_IDENTITIES, default=DETECTIONS_PER_PAGE), ) -> Annotated[ IncidentReportOut, Doc('A detailed incident report from the specified malware source.'), @@ -755,7 +765,8 @@ def fetch_incident_report( 'source': source, 'include_details': include_details, 'organization_id': organization_id, - 'limit': max_results, + 'limit': min(max_results, identities_per_page), + 'offset': offset, } payload = IncidentReportIn.model_validate(data).json() self.log.info(f'Fetching incident report with filters: {payload}') @@ -765,7 +776,6 @@ def fetch_incident_report( data=payload, max_results=max_results or DEFAULT_LIMIT, results_path=['credentials', 'details'], - offset_key='offset', ) return IncidentReportOut.model_validate(resp) @@ -773,18 +783,18 @@ def fetch_incident_report( @debug_call def _lookup_filter( self, - first_downloaded_gte: str | None = None, - latest_downloaded_gte: str | None = None, - exfiltration_date_gte: str | None = None, - properties: str | list[str] | None = None, - breach_name: str | None = None, - breach_date: str | None = None, - dump_name: str | None = None, - dump_date: str | None = None, - username_properties: str | list[str] | None = None, - authorization_technologies: str | list[str] | None = None, - authorization_protocols: str | list[str] | None = None, - malware_families: str | list[str] | None = None, + first_downloaded_gte: Optional[str] = None, + latest_downloaded_gte: Optional[str] = None, + exfiltration_date_gte: Optional[str] = None, + properties: Union[str, list[str], None] = None, + breach_name: Optional[str] = None, + breach_date: Optional[str] = None, + dump_name: Optional[str] = None, + dump_date: Optional[str] = None, + username_properties: Union[str, list[str], None] = None, + authorization_technologies: Union[str, list[str], None] = None, + authorization_protocols: Union[str, list[str], None] = None, + malware_families: Union[str, list[str], None] = None, ) -> FilterIn: """Create a query for filtering identity searches. @@ -814,7 +824,7 @@ def _lookup_filter( return FilterIn.model_validate(query) - def _process_arg(self, attr: str, value: int | str | list) -> tuple[str, str | list]: + def _process_arg(self, attr: str, value: Union[int, str, list]) -> tuple[str, Union[str, list]]: """Return attribute and value normalized based on type of value.""" if attr.startswith(('breach_', 'dump_')): prop_field = attr.split('_')[0] + '_properties' diff --git a/psengine/identity/models/common_models.py b/psengine/identity/models/common_models.py index 1f980434..e1500ab3 100644 --- a/psengine/identity/models/common_models.py +++ b/psengine/identity/models/common_models.py @@ -13,7 +13,7 @@ from datetime import datetime from enum import Enum -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import AfterValidator, BeforeValidator, Field, field_validator, model_validator from pydantic.networks import IPvAnyAddress @@ -21,7 +21,6 @@ from ...common_models import IdName, RFBaseModel from ...constants import DEFAULT_LIMIT from ...helpers import Validators -from ..constants import MAXIMUM_IDENTITIES class DetectionType(Enum): @@ -86,20 +85,21 @@ class Algorithm(Enum): class Technology(IdName): - category: str | None = None + category: Optional[str] = None class PasswordHash(RFBaseModel): algorithm: Algorithm - hash_: str | None = Field(alias='hash', default=None) - hash_prefix: str | None = None + hash_: Optional[str] = Field(alias='hash', default=None) + hash_prefix: Optional[str] = None @model_validator(mode='after') - def check_hash_fields_present(self): + @classmethod + def check_hash_fields_present(cls, data): """Validates at least one of hash or hash_prefix is supplied.""" - if not (self.hash_ or self.hash_prefix): + if not (data.hash_ or data.hash_prefix): raise ValueError('One of `hash` or `hash_prefix` must be supplied') - return self + return data class Cookie(RFBaseModel): @@ -120,13 +120,13 @@ class Country(RFBaseModel): class Location(RFBaseModel): country: Country - postal_code: str | None = None - city: str | None = None - address: str | None = None - address_one: str | None = Field(validation_alias='address1', default=None) - address_two: str | None = Field(validation_alias='address2', default=None) - state: str | None = None - zip: str | None = None + postal_code: Optional[str] = None + city: Optional[str] = None + address: Optional[str] = None + address_one: Optional[str] = Field(validation_alias='address1', default=None) + address_two: Optional[str] = Field(validation_alias='address2', default=None) + state: Optional[str] = None + zip: Optional[str] = None class Infrastructure(RFBaseModel): @@ -135,20 +135,20 @@ class Infrastructure(RFBaseModel): class Compromise(RFBaseModel): exfiltration_date: datetime - os: str | None = None - os_username: str | None = None - malware_file: str | None = None - timezone: str | None = None - computer_name: str | None = None - uac: str | None = None - antivirus: str | list[str] | None = None + os: Optional[str] = None + os_username: Optional[str] = None + malware_file: Optional[str] = None + timezone: Optional[str] = None + computer_name: Optional[str] = None + uac: Optional[str] = None + antivirus: Union[str, list[str], None] = None class Breach(RFBaseModel): name: str domain: str type_: str = Field(alias='type') - breached: datetime | None = None + breached: Optional[datetime] = None start: datetime stop: datetime precision: Precision @@ -162,36 +162,36 @@ class BaseIdentityOut(RFBaseModel): class QueryProperties(RFBaseModel): - name: str | None = None - date: datetime | None = None + name: Optional[str] = None + date: Optional[datetime] = None class FilterIn(RFBaseModel): first_downloaded_gte: Annotated[ - datetime | None, BeforeValidator(Validators.convert_relative_time) + Optional[datetime], BeforeValidator(Validators.convert_relative_time) ] = None latest_downloaded_gte: Annotated[ - datetime | None, BeforeValidator(Validators.convert_relative_time) + Optional[datetime], BeforeValidator(Validators.convert_relative_time) ] = None exfiltration_date_gte: Annotated[ - datetime | None, BeforeValidator(Validators.convert_relative_time) + Optional[datetime], BeforeValidator(Validators.convert_relative_time) ] = None - breach_properties: QueryProperties | None = None - dump_properties: QueryProperties | None = None + breach_properties: Optional[QueryProperties] = None + dump_properties: Optional[QueryProperties] = None properties: Annotated[ - list[Properties] | None, BeforeValidator(Validators.convert_str_to_list) + Optional[list[Properties]], BeforeValidator(Validators.convert_str_to_list) ] = None username_properties: Annotated[ - list[str] | None, BeforeValidator(Validators.convert_str_to_list) + Optional[list[str]], BeforeValidator(Validators.convert_str_to_list) ] = None authorization_technologies: Annotated[ - list[str] | None, BeforeValidator(Validators.convert_str_to_list) + Optional[list[str]], BeforeValidator(Validators.convert_str_to_list) ] = None authorization_protocols: Annotated[ - list[str] | None, BeforeValidator(Validators.convert_str_to_list) + Optional[list[str]], BeforeValidator(Validators.convert_str_to_list) ] = None malware_families: Annotated[ - list[str] | None, BeforeValidator(Validators.convert_str_to_list) + Optional[list[str]], BeforeValidator(Validators.convert_str_to_list) ] = None @field_validator('username_properties', mode='before') @@ -206,22 +206,22 @@ def validate_username_properties(cls, v): class BaseIdentityIn(RFBaseModel): - limit: int | None = Field(default=DEFAULT_LIMIT, gt=0, le=MAXIMUM_IDENTITIES) - offset: str | None = None + limit: Optional[int] = Field(default=DEFAULT_LIMIT, gt=0, le=500) + offset: Optional[str] = None class IdentityOrgIn(BaseIdentityIn): - organization_id: Annotated[str | None, AfterValidator(Validators.check_uhash_prefix)] = None + organization_id: Annotated[Optional[str], AfterValidator(Validators.check_uhash_prefix)] = None class DumpSearchOut(RFBaseModel): """Model for payload received by POST `/identity/metadata/dump/search` endpoint.""" name: str - source: str | None = None - description: str | None = None + source: Optional[str] = None + description: Optional[str] = None downloaded: datetime - breaches: list[Breach] | None = None - compromise: Compromise | None = None - infrastructure: Infrastructure | None = None - location: Location | None = None + breaches: Optional[list[Breach]] = None + compromise: Optional[Compromise] = None + infrastructure: Optional[Infrastructure] = None + location: Optional[Location] = None diff --git a/psengine/identity/models/detections.py b/psengine/identity/models/detections.py index a1f342ff..d3b81f73 100644 --- a/psengine/identity/models/detections.py +++ b/psengine/identity/models/detections.py @@ -12,7 +12,7 @@ ############################################################################################## from datetime import datetime -from typing import Annotated +from typing import Annotated, Optional from pydantic import BeforeValidator, Field @@ -26,29 +26,29 @@ class DetectionsCreated(RFBaseModel): - gte: Annotated[datetime | None, BeforeValidator(Validators.convert_relative_time)] = None - lt: Annotated[datetime | None, BeforeValidator(Validators.convert_relative_time)] = None + gte: Annotated[Optional[datetime], BeforeValidator(Validators.convert_relative_time)] = None + lt: Annotated[Optional[datetime], BeforeValidator(Validators.convert_relative_time)] = None class AuthorizationService(RFBaseModel): - url: str | None = None - domain: str | None = None - fqdn: str | None = None - technology: list[Technology] | None = None - protocols: list[str] | None = None + url: Optional[str] = None + domain: Optional[str] = None + fqdn: Optional[str] = None + technology: Optional[list[Technology]] = None + protocols: Optional[list[str]] = None class Password(RFBaseModel): - type_: str | None = Field(default=None, alias='type') - hashes: list[PasswordHash] | None = None - properties: list[str] | None = None - cleartext_hint: str | None = None - cleartext: str | None = None + type_: Optional[str] = Field(default=None, alias='type') + hashes: Optional[list[PasswordHash]] = None + properties: Optional[list[str]] = None + cleartext_hint: Optional[str] = None + cleartext: Optional[str] = None class DetectionsFilterIn(RFBaseModel): - novel_only: bool | None = None - cookies: str | None = None - domains: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = [] - detection_type: DetectionType | None = None - created: DetectionsCreated | None = None + novel_only: Optional[bool] = None + cookies: Optional[str] = None + domains: Annotated[Optional[list[str]], BeforeValidator(Validators.convert_str_to_list)] = [] + detection_type: Optional[DetectionType] = None + created: Optional[DetectionsCreated] = None diff --git a/psengine/identity/models/incident_report.py b/psengine/identity/models/incident_report.py index 0da1867f..f7020d7c 100644 --- a/psengine/identity/models/incident_report.py +++ b/psengine/identity/models/incident_report.py @@ -11,6 +11,7 @@ # accessed from any third party API. # ############################################################################################## +from typing import Optional from pydantic.networks import IPvAnyAddress @@ -23,8 +24,8 @@ class IncidentReportCredentials(RFBaseModel): email_or_login: str password: str password_sha1: str - domain_category: str | None = None - domain_technology: str | None = None + domain_category: Optional[str] = None + domain_technology: Optional[str] = None contains_high_risk_technologies: bool contains_cookies: bool contains_active_cookies: bool @@ -32,6 +33,6 @@ class IncidentReportCredentials(RFBaseModel): class IncidentReportDetails(Compromise): malware_family: str - ip_address: IPvAnyAddress | None = None - postal_code: str | None = None - country: str | None = None + ip_address: Optional[IPvAnyAddress] = None + postal_code: Optional[str] = None + country: Optional[str] = None diff --git a/psengine/identity/models/lookup.py b/psengine/identity/models/lookup.py index 32193b66..81abba7a 100644 --- a/psengine/identity/models/lookup.py +++ b/psengine/identity/models/lookup.py @@ -11,6 +11,7 @@ # accessed from any third party API. # ############################################################################################## +from typing import Optional from pydantic import AnyUrl, Field, IPvAnyAddress @@ -31,9 +32,9 @@ class AuthorizationService(RFBaseModel): class ExposedSecretDetails(RFBaseModel): properties: list[str] - rank: str | None = None - clear_text_value: ClearTextPassword | None = None - clear_text_hint: str | None = None + rank: Optional[str] = None + clear_text_value: Optional[ClearTextPassword] = None + clear_text_hint: Optional[str] = None class SecretDetails(RFBaseModel): @@ -48,7 +49,7 @@ class IdentityDetails(RFBaseModel): class IPRange(RFBaseModel): - gte: IPvAnyAddress | None = None - gt: IPvAnyAddress | None = None - lte: IPvAnyAddress | None = None - lt: IPvAnyAddress | None = None + gte: Optional[IPvAnyAddress] = None + gt: Optional[IPvAnyAddress] = None + lte: Optional[IPvAnyAddress] = None + lt: Optional[IPvAnyAddress] = None diff --git a/psengine/threat_maps/errors.py b/psengine/links/__init__.py similarity index 63% rename from psengine/threat_maps/errors.py rename to psengine/links/__init__.py index a168f792..e88750cf 100644 --- a/psengine/threat_maps/errors.py +++ b/psengine/links/__init__.py @@ -11,24 +11,26 @@ # accessed from any third party API. # ############################################################################################## -from ..errors import RecordedFutureError - - -class ThreatMapsError(RecordedFutureError): - """Error raised when there was an issue with the threat maps API.""" - - -class ThreatMapFetchError(ThreatMapsError): - """Error raised when there was an issue fetching a threat map.""" - - -class ThreatMapInfoError(ThreatMapsError): - """Error raised when there was an error fetching available threat maps.""" - - -class ThreatMapCategoriesError(ThreatMapsError): - """Error raised when there was an error searching threat categories.""" - - -class ThreatActorSearchError(ThreatMapsError): - """Error raised when there was an error searching threat actors.""" +from .errors import ( + LinksError, + LinksMetadataError, + LinksSearchError, +) +from .links import ( + FilterTechnical, + LinkedEntity, + LinksFilterObjects, + LinksLimitsObjects, + LinksSearchIn, + LinksSearchResponse, + SearchResultSet, +) +from .links_mgr import LinksMgr +from .models import ( + EntityAttribute, + MetadataEntityTypesResponse, + MetadataEvent, + MetadataEventsResponse, + MetadataSection, + MetadataSectionsResponse, +) diff --git a/psengine/malware_intel/constants.py b/psengine/links/errors.py similarity index 76% rename from psengine/malware_intel/constants.py rename to psengine/links/errors.py index a01b661b..2e1b5520 100644 --- a/psengine/malware_intel/constants.py +++ b/psengine/links/errors.py @@ -11,6 +11,16 @@ # accessed from any third party API. # ############################################################################################## -# Default Auto Yara/Sigma polling behavior for long-running jobs. -JOB_POOL_INTERTVAL_SECONDS = 5 -JOB_POOL_RETRIES = 50 +from ..errors import RecordedFutureError + + +class LinksError(RecordedFutureError): + """Base class for all exceptions raised by the Links module.""" + + +class LinksSearchError(LinksError): + """Error raised when a Links search request fails.""" + + +class LinksMetadataError(LinksError): + """Error raised when fetching or validating Links metadata fails.""" diff --git a/psengine/links/links.py b/psengine/links/links.py new file mode 100644 index 00000000..c2449de4 --- /dev/null +++ b/psengine/links/links.py @@ -0,0 +1,83 @@ +##################################### TERMS OF USE ########################################### +# The following code is provided for demonstration purpose only, and should not be used # +# without independent verification. Recorded Future makes no representations or warranties, # +# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # +# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # +# responsibility for any information it may retrieve. Recorded Future shall not be liable # +# for, and you assume all risk of using, the foregoing. By using this code, Customer # +# represents that it is solely responsible for having all necessary licenses, permissions, # +# rights, and/or consents to connect to third party APIs, and that it is solely responsible # +# for having all necessary licenses, permissions, rights, and/or consents to any data # +# accessed from any third party API. # +############################################################################################## + +from typing import Annotated, Literal, Optional + +from pydantic import AfterValidator + +from ..common_models import IdNameType, RFBaseModel +from ..helpers import TimeHelpers +from .models import EntityAttribute, EntitySearchError + + +def _validate_rel_time(v: Optional[str]) -> Optional[str]: + if v is not None and not TimeHelpers.is_rel_time_valid(v): + raise ValueError(f'Invalid relative time: {v}') + return v + + +class FilterTechnical(RFBaseModel): + """Fields in the Technical Object of Filters.""" + + timeframe: Annotated[Optional[str], AfterValidator(_validate_rel_time)] = None + events: Optional[list[str]] = None + connected_entities: Optional[list[str]] = None + + +class LinksFilterObjects(RFBaseModel): + """Objects in the fields data parameter of links.""" + + sections: Optional[list[str]] = None + entity_types: Optional[list[str]] = None + sources: Optional[list[Literal['technical', 'insikt']]] = None + technical: Optional[FilterTechnical] = None + + +class LinksLimitsObjects(RFBaseModel): + """Objects in the limits object fields.""" + + search_scope: Optional[Literal['small', 'medium', 'large']] = None + per_entity_type: Optional[int] = None + + +class LinksSearchIn(RFBaseModel): + """Model for payload sent to POST `/links/search` endpoint.""" + + entities: list[str] + filters: Optional[LinksFilterObjects] = None + limits: Optional[LinksLimitsObjects] = None + + +class LinkedEntity(IdNameType): + """An entity connected to the search target. + + Inherits id_ (alias 'id'), name, and type_ (alias 'type') from IdNameType. + """ + + source: Optional[str] = None + section: Optional[str] = None + attributes: list[EntityAttribute] = [] + + +class SearchResultSet(RFBaseModel): + """The result set for a single entity that was queried.""" + + entity: Optional[IdNameType] = None + links: list[LinkedEntity] = [] + error: Optional[EntitySearchError] = None + + +class LinksSearchResponse(RFBaseModel): + """Response from POST `/links/search` endpoint.""" + + data: list[SearchResultSet] = [] diff --git a/psengine/links/links_mgr.py b/psengine/links/links_mgr.py new file mode 100644 index 00000000..9c40ed65 --- /dev/null +++ b/psengine/links/links_mgr.py @@ -0,0 +1,245 @@ +##################################### TERMS OF USE ########################################### +# The following code is provided for demonstration purpose only, and should not be used # +# without independent verification. Recorded Future makes no representations or warranties, # +# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # +# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # +# responsibility for any information it may retrieve. Recorded Future shall not be liable # +# for, and you assume all risk of using, the foregoing. By using this code, Customer # +# represents that it is solely responsible for having all necessary licenses, permissions, # +# rights, and/or consents to connect to third party APIs, and that it is solely responsible # +# for having all necessary licenses, permissions, rights, and/or consents to any data # +# accessed from any third party API. # +############################################################################################## + +import logging +from typing import Annotated, Optional + +from pydantic import validate_call +from typing_extensions import Doc + +from ..common_models import IdName +from ..endpoints import ( + EP_LINKS_METADATA_ENTITIES, + EP_LINKS_METADATA_EVENTS, + EP_LINKS_METADATA_SECTIONS, + EP_LINKS_SEARCH, +) +from ..helpers import connection_exceptions, debug_call +from ..rf_client import RFClient +from .errors import LinksMetadataError, LinksSearchError +from .links import LinksFilterObjects, LinksLimitsObjects, LinksSearchIn, LinksSearchResponse +from .models import ( + MetadataEntityTypesResponse, + MetadataEvent, + MetadataEventsResponse, + MetadataSection, + MetadataSectionsResponse, +) + + +class LinksMgr: + """Manager for interacting with the Recorded Future Links API.""" + + def __init__( + self, + rf_token: Annotated[Optional[str], Doc('Recorded Future API token.')] = None, + ): + """Initialize the `LinksMgr` object.""" + self.log = logging.getLogger(__name__) + self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient() + + @debug_call + @validate_call + @connection_exceptions(ignore_status_code=[], exception_to_raise=LinksMetadataError) + def list_sections( + self, + ) -> Annotated[list[MetadataSection], Doc('Section objects with id, name, and description.')]: + """List all sections that can be used to filter a Link search. + + Sections are the high-level categories the Links API groups results into, + for example *Actors, Tools & TTPs* or *Indicators & Detection Rules*. + Use the returned `id_` values to populate `LinksFilterObjects.sections`. + + Endpoint: + `/links/metadata/sections` + + Example: + ```python + from psengine.links import LinksMgr + + mgr = LinksMgr() + for section in mgr.list_sections(): + print(f'{section.id_}: {section.name}') + ``` + + Raises: + ValidationError: If any supplied parameter is of incorrect type. + LinksMetadataError: If an API or connection error occurs. + """ + response = self.rf_client.request(method='GET', url=EP_LINKS_METADATA_SECTIONS) + return MetadataSectionsResponse.model_validate(response.json()).data + + @debug_call + @validate_call + @connection_exceptions(ignore_status_code=[], exception_to_raise=LinksMetadataError) + def list_events( + self, + ) -> Annotated[list[MetadataEvent], Doc('Event objects with id, name, and description.')]: + """List all event types that can be used to filter technical Link searches. + + Event types describe the kind of analytical evidence that produced a + technical link (for example `TTPAnalysis` or `InfrastructureAnalysis`). + Use the returned `id_` values to populate `FilterTechnical.events`. + + Endpoint: + `/links/metadata/events` + + Example: + ```python + from psengine.links import LinksMgr + + mgr = LinksMgr() + for event in mgr.list_events(): + print(f'{event.id_}: {event.name}') + ``` + + Raises: + ValidationError: If any supplied parameter is of incorrect type. + LinksMetadataError: If an API or connection error occurs. + """ + response = self.rf_client.request(method='GET', url=EP_LINKS_METADATA_EVENTS) + return MetadataEventsResponse.model_validate(response.json()).data + + @debug_call + @validate_call + @connection_exceptions(ignore_status_code=[], exception_to_raise=LinksMetadataError) + def list_entity_types( + self, + ) -> Annotated[list[IdName], Doc('Entity-type objects with id and name.')]: + """List all entity types that can be used to filter a Link search. + + The returned values are the supported types for connected entities + (for example `Malware`, `Company`, `IpAddress`). Use the `id_` values + to populate `LinksFilterObjects.entity_types`. + + Endpoint: + `/links/metadata/entities` + + Example: + ```python + from psengine.links import LinksMgr + + mgr = LinksMgr() + for entity_type in mgr.list_entity_types(): + print(f'{entity_type.id_}: {entity_type.name}') + ``` + + Raises: + ValidationError: If any supplied parameter is of incorrect type. + LinksMetadataError: If an API or connection error occurs. + """ + response = self.rf_client.request(method='GET', url=EP_LINKS_METADATA_ENTITIES) + return MetadataEntityTypesResponse.model_validate(response.json()).data + + @debug_call + @validate_call + @connection_exceptions(ignore_status_code=[], exception_to_raise=LinksSearchError) + def search( + self, + entities: Annotated[ + list[str], Doc('List of Recorded Future entity IDs to search for links against.') + ], + filters: Annotated[ + Optional[LinksFilterObjects], Doc('Filter objects for the search.') + ] = None, + limits: Annotated[ + Optional[LinksLimitsObjects], Doc('Limits objects for the search.') + ] = None, + ) -> Annotated[LinksSearchResponse, Doc('The structured search results.')]: + """Search for entities connected to one or more target entities. + + Issues a single batched request: the response contains one + `SearchResultSet` per entity in `entities`, in the same order. If the + API failed for a specific entity, that result's `error` is populated + and `links` is empty β€” the rest of the batch still succeeds. + + `filters` narrows the result set: + + - `sections` β€” restrict to specific Links sections (see `list_sections`). + - `entity_types` β€” restrict to specific connected-entity types (see `list_entity_types`). + - `sources` β€” restrict to `technical`, `insikt`, or both. + - `technical` β€” sub-filters that apply only to technical links (timeframe, + event types, connected-entity scope). + + `limits` controls how aggressively the API searches: + + - `search_scope` β€” one of `small`, `medium`, `large`. Larger scopes scan + more references and Insikt notes per query at the cost of latency. + - `per_entity_type` β€” caps how many connected entities of each type + are returned. + + Entities must be supplied as Recorded Future entity IDs; if you only have + a name, resolve it with `EntityMatchMgr` or `LookupMgr` first. + + Endpoint: + `/links/search` + + Example: + ```python + from psengine.links import LinksMgr + + mgr = LinksMgr() + results = mgr.search(entities=['QCwdoU']) + for result in results.data: + if result.error: + continue + for link in result.links: + print(f'{link.name} ({link.type_})') + ``` + + With filters and limits: + ```python + from psengine.links import ( + FilterTechnical, + LinksFilterObjects, + LinksLimitsObjects, + LinksMgr, + ) + + mgr = LinksMgr() + filters = LinksFilterObjects( + sources=['technical'], + entity_types=['Malware'], + technical=FilterTechnical(timeframe='-30d'), + ) + limits = LinksLimitsObjects(search_scope='small', per_entity_type=50) + results = mgr.search( + entities=['QCwdoU'], filters=filters, limits=limits + ) + ``` + + If the API failed for a specific entity in the batch, its result looks like: + ```python + SearchResultSet( + entity=IdNameType(id_='QCwdoU', name='...', type_='...'), + links=[], + error=EntitySearchError(message='...', status_code=404), + ) + ``` + + Raises: + ValidationError: If any supplied parameter is of incorrect type. + LinksSearchError: If an API or connection error occurs at the request level. + """ + payload = LinksSearchIn(entities=entities, filters=filters, limits=limits) + + # Kept: @debug_call logs the args list, but batch size is the operationally + # useful number to surface at INFO. Drop if the reviewer disagrees. + self.log.info(f'Executing links search for {len(entities)} entities.') + + response = self.rf_client.request( + method='POST', + url=EP_LINKS_SEARCH, + data=payload.model_dump(exclude_none=True, by_alias=True), + ) + return LinksSearchResponse.model_validate(response.json()) diff --git a/psengine/links/models.py b/psengine/links/models.py new file mode 100644 index 00000000..4b339e47 --- /dev/null +++ b/psengine/links/models.py @@ -0,0 +1,85 @@ +##################################### TERMS OF USE ########################################### +# The following code is provided for demonstration purpose only, and should not be used # +# without independent verification. Recorded Future makes no representations or warranties, # +# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # +# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # +# responsibility for any information it may retrieve. Recorded Future shall not be liable # +# for, and you assume all risk of using, the foregoing. By using this code, Customer # +# represents that it is solely responsible for having all necessary licenses, permissions, # +# rights, and/or consents to connect to third party APIs, and that it is solely responsible # +# for having all necessary licenses, permissions, rights, and/or consents to any data # +# accessed from any third party API. # +############################################################################################## + +from typing import Any, Literal, Optional, Union + +from pydantic import Field + +from ..common_models import IdName, RFBaseModel + + +class MetadataSection(IdName): + description: Optional[str] = None + + +class MetadataSectionsResponse(RFBaseModel): + """Response from GET `/links/metadata/sections` endpoint.""" + + data: list[MetadataSection] + + +class MetadataEvent(IdName): + description: Optional[str] = None + + +class MetadataEventsResponse(RFBaseModel): + """Response from GET `/links/metadata/events` endpoint.""" + + data: list[MetadataEvent] + + +class MetadataEntityTypesResponse(RFBaseModel): + """Response from GET `/links/metadata/entities` endpoint.""" + + data: list[IdName] + + +class RiskAttribute(RFBaseModel): + id_: Literal['risk_score', 'risk_level'] = Field(alias='id') + value: Optional[Union[float, str]] = None + + +class CriticalityAttribute(RFBaseModel): + id_: Literal['criticality'] = Field(alias='id') + value: Optional[str] = None + + +class MitreNameAttribute(RFBaseModel): + id_: Literal['display_name'] = Field(alias='id') + value: Optional[str] = None + + +class ThreatActorAttribute(RFBaseModel): + id_: Literal['threat_actor'] = Field(alias='id') + value: Optional[bool] = None + + +class GenericAttribute(RFBaseModel): + id_: str = Field(alias='id') + value: Any + + +# Discriminated union for the 'attributes' array. Pydantic matches on the +# Literal 'id' values; unknown ids fall back to GenericAttribute. +EntityAttribute = Union[ + RiskAttribute, + CriticalityAttribute, + MitreNameAttribute, + ThreatActorAttribute, + GenericAttribute, +] + + +class EntitySearchError(RFBaseModel): + message: str + status_code: int diff --git a/psengine/logger/rf_logger.py b/psengine/logger/rf_logger.py index c55403ee..1d55a06c 100644 --- a/psengine/logger/rf_logger.py +++ b/psengine/logger/rf_logger.py @@ -12,8 +12,8 @@ ############################################################################################## import logging +import logging.config import sys -from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Annotated @@ -101,7 +101,7 @@ def __init__( def _create_file_handler(self, output): log_filename = self._setup_output(output) - file_handler = RotatingFileHandler( + file_handler = logging.handlers.RotatingFileHandler( log_filename, maxBytes=MAX_BYTES, backupCount=BACKUP_COUNT, diff --git a/psengine/malware_intel/__init__.py b/psengine/malware_intel/__init__.py index 9d550aa3..49520d03 100644 --- a/psengine/malware_intel/__init__.py +++ b/psengine/malware_intel/__init__.py @@ -11,38 +11,6 @@ # accessed from any third party API. # ############################################################################################## -from .auto_sigma_mgr import AutoSigmaMgr -from .auto_yara_mgr import AutoYaraMgr -from .errors import ( - AutoSigmaFetchJobError, - AutoSigmaFetchJobsError, - AutoSigmaJobCreationError, - AutoSigmaJobDeletionError, - AutoSigmaJobEditError, - AutoSigmaJobRetryError, - AutoYaraFetchJobError, - AutoYaraFetchJobsError, - AutoYaraJobCreationError, - AutoYaraJobDeletionError, - AutoYaraJobEditError, - AutoYaraJobRetryError, - MalwareIntelReportError, -) -from .helpers import save_rules -from .malware_intel import ( - AutoSigmaJobCreateOut, - AutoSigmaJobDeleteOut, - AutoSigmaJobEditOut, - AutoSigmaJobOut, - AutoSigmaJobRetryOut, - AutoSigmaJobsOut, - AutoSigmaRules, - AutoYaraJobCreateOut, - AutoYaraJobDeleteOut, - AutoYaraJobEditOut, - AutoYaraJobOut, - AutoYaraJobRetryOut, - AutoYaraJobsOut, - SandboxReport, -) +from .errors import MalwareIntelReportError +from .malware_intel import SandboxReport from .malware_intel_mgr import MalwareIntelMgr diff --git a/psengine/malware_intel/auto_sigma_mgr.py b/psengine/malware_intel/auto_sigma_mgr.py deleted file mode 100644 index 4b2240ee..00000000 --- a/psengine/malware_intel/auto_sigma_mgr.py +++ /dev/null @@ -1,242 +0,0 @@ -##################################### TERMS OF USE ########################################### -# The following code is provided for demonstration purpose only, and should not be used # -# without independent verification. Recorded Future makes no representations or warranties, # -# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # -# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # -# responsibility for any information it may retrieve. Recorded Future shall not be liable # -# for, and you assume all risk of using, the foregoing. By using this code, Customer # -# represents that it is solely responsible for having all necessary licenses, permissions, # -# rights, and/or consents to connect to third party APIs, and that it is solely responsible # -# for having all necessary licenses, permissions, rights, and/or consents to any data # -# accessed from any third party API. # -############################################################################################## - -import logging -import time -from typing import Annotated - -from pydantic import validate_call -from typing_extensions import Doc - -from ..constants import DEFAULT_LIMIT -from ..endpoints import ( - EP_AUTO_SIGMA_GET_JOBS, - EP_AUTO_SIGMA_JOB_ID, - EP_AUTO_SIGMA_JOB_ID_RETRY, - EP_AUTO_SIGMA_JOB_ID_RULE_ID, - EP_AUTO_SIGMA_JOBS, -) -from ..helpers import debug_call -from ..helpers.helpers import connection_exceptions -from ..rf_client import RFClient -from .constants import JOB_POOL_INTERTVAL_SECONDS, JOB_POOL_RETRIES -from .errors import ( - AutoSigmaFetchJobError, - AutoSigmaFetchJobsError, - AutoSigmaJobCreationError, - AutoSigmaJobDeletionError, - AutoSigmaJobEditError, - AutoSigmaJobRetryError, -) -from .malware_intel import ( - AutoSigmaJobCreateOut, - AutoSigmaJobDeleteOut, - AutoSigmaJobEditOut, - AutoSigmaJobOut, - AutoSigmaJobRetryOut, - AutoSigmaJobsOut, -) - - -class AutoSigmaMgr: - """Manages requests for Recorded Future Malware Intelligence API Auto Sigma feature.""" - - def __init__(self, rf_token: str = None): - """Initializes the `AutoSigmaMgr` object. - - Args: - rf_token (str, optional): Recorded Future API token. - """ - self.log = logging.getLogger(__name__) - self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient() - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoSigmaJobCreationError) - def create_rule_job( - self, - name: Annotated[str, Doc('The name of the Auto Sigma job.')], - query: Annotated[str, Doc('The query used to select files to build rules for.')], - start_date: Annotated[str, Doc('The earliest date to include in the query.')], - end_date: Annotated[str | None, Doc('The latest date to include in the query.')] = None, - ) -> Annotated[AutoSigmaJobCreateOut, Doc('Job creation confirmation containing the job ID.')]: - """Create a new Auto Sigma rule generation job. - - Endpoint: - `/malware-intelligence/v1/auto-sigma/jobs` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoSigmaJobCreationError: If API error occurs. - """ - data = {'name': name, 'query': query, 'start_date': start_date} - if end_date is not None: - data['end_date'] = end_date - - data = self.rf_client.request('post', EP_AUTO_SIGMA_JOBS, data).json() - return AutoSigmaJobCreateOut.model_validate(data) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoSigmaFetchJobsError) - def fetch_rule_jobs( - self, - limit: Annotated[int | None, Doc('Maximum number of jobs to return.')] = DEFAULT_LIMIT, - ) -> Annotated[ - AutoSigmaJobsOut, - Doc('The list of Auto Sigma rule generation jobs created by the user.'), - ]: - """Fetch all Auto Sigma rule generation jobs created by the user. - - Endpoint: - `/malware-intelligence/v1/auto-sigma/get_jobs` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoSigmaFetchJobsError: If API error occurs. - """ - data = {'limit': limit} - jobs = self.rf_client.request('post', EP_AUTO_SIGMA_GET_JOBS, data=data).json() - return AutoSigmaJobsOut.model_validate(jobs) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoSigmaFetchJobError) - def fetch_rule_job_result( - self, - job_id: Annotated[str, Doc('The job ID to fetch.')], - wait_until_finished: Annotated[ - bool, - Doc('When true, keep polling until the job status is FINISHED.'), - ] = False, - ) -> Annotated[AutoSigmaJobOut, Doc('The details of the requested Sigma rule job.')]: - """Fetch the result of a specific Auto Sigma rule generation job. - - A newly created job typically moves through `CREATED` and then `RUNNING` while - Sigma rules and patterns are being generated. - - The terminal statuses are: - - `FAILED`: generation failed. - - `FINISHED`: generation succeeded. - - Endpoint: - `/malware-intelligence/v1/auto-sigma/jobs/{job_id}` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoSigmaFetchJobError: If API error occurs or if polling times out / job fails. - """ - if not wait_until_finished: - data = self.rf_client.request('get', EP_AUTO_SIGMA_JOB_ID.format(job_id)).json() - return AutoSigmaJobOut.model_validate(data) - - status = '' - for _ in range(JOB_POOL_RETRIES): - data = self.rf_client.request('get', EP_AUTO_SIGMA_JOB_ID.format(job_id)).json() - result = AutoSigmaJobOut.model_validate(data) - status = result.status.upper() - - if status == 'FINISHED': - return result - - if status == 'FAILED': - raise AutoSigmaFetchJobError( - message=(f'Auto Sigma job {job_id} failed while waiting for FINISHED status.') - ) - - time.sleep(JOB_POOL_INTERTVAL_SECONDS) - - raise AutoSigmaFetchJobError( - message=( - f'Timed out waiting for Auto Sigma job {job_id} to finish. Last status: {status}' - ) - ) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoSigmaJobEditError) - def edit_rule_str( - self, - job_id: Annotated[str, Doc('The job ID to which the Auto Sigma rule belongs.')], - rule_id: Annotated[str, Doc('The Auto Sigma rule ID to change.')], - yaml_str: Annotated[str | None, Doc('New Sigma rule YAML to apply.')] = None, - status: Annotated[ - str | None, - Doc( - """New Sigma rule status to apply. Supported values: - - True Positive, - - False Positive, - - Benign Behavior, - - No Root Cause, - - Needs Tuning, - - New.""" - ), - ] = None, - ) -> Annotated[AutoSigmaJobEditOut, Doc('Edit confirmation')]: - """Edit an existing Auto Sigma rule within a job by modifying its YAML rule string. - - Endpoint: - `/malware-intelligence/v1/auto-sigma/jobs/{job_id}/{rule_id}` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoSigmaJobEditError: If API error occurs. - """ - data = {} - if yaml_str is not None: - data['rule_yaml'] = yaml_str - if status is not None: - data['status'] = status - - updated = self.rf_client.request( - 'post', EP_AUTO_SIGMA_JOB_ID_RULE_ID.format(job_id, rule_id), data - ).json() - return AutoSigmaJobEditOut(job_id=job_id, rule_id=rule_id, updated=updated) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoSigmaJobDeletionError) - def delete_rule_job( - self, - job_id: Annotated[str, Doc('The job ID to delete.')], - ) -> Annotated[AutoSigmaJobDeleteOut, Doc('A confirmation of deletion.')]: - """Delete a created Auto Sigma job and its generated Sigma rules. - - Endpoint: - `/malware-intelligence/v1/auto-sigma/jobs/{job_id}` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoSigmaJobDeletionError: If API error occurs. - """ - data = self.rf_client.request('delete', EP_AUTO_SIGMA_JOB_ID.format(job_id)).json() - return AutoSigmaJobDeleteOut.model_validate(data) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoSigmaJobRetryError) - def retry_failed_rule_job( - self, - job_id: Annotated[str, Doc('The job ID to retry.')], - ) -> Annotated[AutoSigmaJobRetryOut, Doc('A confirmation of retry.')]: - """Retry a failed Auto Sigma rule generation job. - - Endpoint: - `/malware-intelligence/v1/auto-sigma/jobs/{job_id}/retry` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoSigmaJobRetryError: If API error occurs. - """ - data = self.rf_client.request('post', EP_AUTO_SIGMA_JOB_ID_RETRY.format(job_id)).json() - return AutoSigmaJobRetryOut.model_validate(data) diff --git a/psengine/malware_intel/auto_yara_mgr.py b/psengine/malware_intel/auto_yara_mgr.py deleted file mode 100644 index dae26039..00000000 --- a/psengine/malware_intel/auto_yara_mgr.py +++ /dev/null @@ -1,226 +0,0 @@ -##################################### TERMS OF USE ########################################### -# The following code is provided for demonstration purpose only, and should not be used # -# without independent verification. Recorded Future makes no representations or warranties, # -# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # -# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # -# responsibility for any information it may retrieve. Recorded Future shall not be liable # -# for, and you assume all risk of using, the foregoing. By using this code, Customer # -# represents that it is solely responsible for having all necessary licenses, permissions, # -# rights, and/or consents to connect to third party APIs, and that it is solely responsible # -# for having all necessary licenses, permissions, rights, and/or consents to any data # -# accessed from any third party API. # -############################################################################################## - -import logging -import time -from typing import Annotated - -from pydantic import validate_call -from typing_extensions import Doc - -from ..endpoints import ( - EP_AUTO_YARA_JOB_ID, - EP_AUTO_YARA_JOB_ID_RETRY, - EP_AUTO_YARA_JOBS, - EP_AUTO_YARA_JOBS_EDIT, -) -from ..helpers import debug_call -from ..helpers.helpers import connection_exceptions -from ..rf_client import RFClient -from .constants import JOB_POOL_INTERTVAL_SECONDS, JOB_POOL_RETRIES -from .errors import ( - AutoYaraFetchJobError, - AutoYaraFetchJobsError, - AutoYaraJobCreationError, - AutoYaraJobDeletionError, - AutoYaraJobEditError, - AutoYaraJobRetryError, -) -from .malware_intel import ( - AutoYaraJobCreateOut, - AutoYaraJobDeleteOut, - AutoYaraJobEditOut, - AutoYaraJobOut, - AutoYaraJobRetryOut, - AutoYaraJobsOut, -) - - -class AutoYaraMgr: - """Manages requests for Recorded Future Malware Intelligence API Auto YARA feature.""" - - def __init__(self, rf_token: str = None): - """Initializes the `AutoYaraMgr` object. - - Args: - rf_token (str, optional): Recorded Future API token. - """ - self.log = logging.getLogger(__name__) - self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient() - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoYaraJobCreationError) - def create_rule_job( - self, - hashes: Annotated[list[str], Doc('The list of hashes to use.')], - name: Annotated[str, Doc('The job name.')], - query: Annotated[str | None, Doc('The filtering query to perform.')] = None, - ) -> Annotated[AutoYaraJobCreateOut, Doc('Job creation confirmation containing the job ID.')]: - """Create a new Auto YARA rule generation job based on the hashes and/or query provided. - - Endpoint: - `/malware-intelligence/v1/auto-yara/jobs` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoYaraJobCreationError: If API error occurs. - """ - data = {'hashes': hashes, 'name': name} - if query is not None: - data['query'] = query - - data = self.rf_client.request('post', EP_AUTO_YARA_JOBS, data).json() - return AutoYaraJobCreateOut.model_validate(data) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoYaraFetchJobsError) - def fetch_rule_jobs( - self, - ) -> Annotated[ - AutoYaraJobsOut, Doc('The list of Auto Yara rule generation jobs created by the user.') - ]: - """Fetch all the Auto Yara rule generation jobs created by the user. - - Endpoint: - `/malware-intelligence/v1/auto-yara/jobs` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoYaraFetchJobsError: If API error occurs. - """ - data = self.rf_client.request('get', EP_AUTO_YARA_JOBS).json() - return AutoYaraJobsOut.model_validate(data) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoYaraFetchJobError) - def fetch_rule_job_result( - self, - job_id: Annotated[str, Doc('The job ID to fetch.')], - sanitize: Annotated[ - bool | None, Doc('Return a sanitized version of the rule when true.') - ] = None, - wait_until_finished: Annotated[ - bool, - Doc('When true, keep polling until the job status is FINISHED.'), - ] = False, - ) -> Annotated[AutoYaraJobOut, Doc('The details of the requested YARA rule job.')]: - """Fetch the result of a specific Auto YARA rule generation job. - - A newly created job will typically progress through `CREATED` and then `RUNNING` while - the YARA rule is being generated. During those states, `job.yara_rule_str` is `None`. - - The terminal statuses are: - - `FAILED`: rule generation failed, so `job.yara_rule_str` remains `None`. - - `FINISHED`: rule generation succeeded, and `job.yara_rule_str` is available. - - - Endpoint: - `/malware-intelligence/v1/auto-yara/jobs/{job_id}` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoYaraFetchJobError: If API error occurs or if polling times out / job fails. - """ - kwargs = {'params': {'sanitize': sanitize}} if sanitize is not None else {} - if not wait_until_finished: - data = self.rf_client.request( - 'get', EP_AUTO_YARA_JOB_ID.format(job_id), **kwargs - ).json() - return AutoYaraJobOut.model_validate(data) - - status = '' - for _ in range(JOB_POOL_RETRIES): - data = self.rf_client.request( - 'get', EP_AUTO_YARA_JOB_ID.format(job_id), **kwargs - ).json() - result = AutoYaraJobOut.model_validate(data) - status = result.job.status.upper() - - if status == 'FINISHED': - return result - - if status == 'FAILED': - raise AutoYaraFetchJobError( - message=f'Auto YARA job {job_id} failed while waiting for FINISHED status.' - ) - - time.sleep(JOB_POOL_INTERTVAL_SECONDS) - - raise AutoYaraFetchJobError( - message=( - f'Timed out waiting for Auto YARA job {job_id} to finish. Last status: {status}' - ) - ) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoYaraJobEditError) - def edit_rule_str( - self, - job_id: Annotated[str, Doc('The job ID to which the Auto Yara rule belongs.')], - yara_rule_str: Annotated[str, Doc('The new YARA rule string value to apply.')], - ) -> Annotated[AutoYaraJobEditOut, Doc('Edit confirmation containing the job ID.')]: - """Edit an existing Yara rule job by modifying its YARA rule string. - - Endpoint: - `/malware-intelligence/v1/auto-yara/jobs/edit` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoYaraJobEditError: If API error occurs. - """ - data = self.rf_client.request( - 'post', EP_AUTO_YARA_JOBS_EDIT, {'job_id': job_id, 'yara_rule_str': yara_rule_str} - ).json() - return AutoYaraJobEditOut.model_validate(data) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoYaraJobDeletionError) - def delete_rule_job( - self, - job_id: Annotated[str, Doc('The job ID to delete.')], - ) -> Annotated[AutoYaraJobDeleteOut, Doc('A confirmation of deletion.')]: - """Delete a created Auto Yara job and with it the generated YARA rule. - - Endpoint: - `/malware-intelligence/v1/auto-yara/jobs/{job_id}` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoYaraJobDeletionError: If API error occurs. - """ - data = self.rf_client.request('delete', EP_AUTO_YARA_JOB_ID.format(job_id)).json() - return AutoYaraJobDeleteOut.model_validate(data) - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=AutoYaraJobRetryError) - def retry_failed_rule_job( - self, - job_id: Annotated[str, Doc('The job ID to retry.')], - ) -> Annotated[AutoYaraJobRetryOut, Doc('A confirmation of retry.')]: - """Retry a failed Auto YARA rule generation job. - - Endpoint: - `/malware-intelligence/v1/auto-yara/jobs/{job_id}/retry` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - AutoYaraJobRetryError: If API error occurs. - """ - data = self.rf_client.request('post', EP_AUTO_YARA_JOB_ID_RETRY.format(job_id)).json() - return AutoYaraJobRetryOut.model_validate(data) diff --git a/psengine/malware_intel/errors.py b/psengine/malware_intel/errors.py index 6f85596e..eb9a3793 100644 --- a/psengine/malware_intel/errors.py +++ b/psengine/malware_intel/errors.py @@ -16,51 +16,3 @@ class MalwareIntelReportError(RecordedFutureError): """Error raise when the reports operation fails.""" - - -class AutoYaraJobCreationError(RecordedFutureError): - """Error raise when the auto yara jobs creation fails.""" - - -class AutoYaraJobEditError(RecordedFutureError): - """Error raise when the auto yara jobs edit fails.""" - - -class AutoYaraJobDeletionError(RecordedFutureError): - """Error raise when the auto yara jobs deletion fails.""" - - -class AutoYaraJobRetryError(RecordedFutureError): - """Error raise when the auto yara jobs retry fails.""" - - -class AutoYaraFetchJobsError(RecordedFutureError): - """Error raise when the auto yara fetch of jobs fails.""" - - -class AutoYaraFetchJobError(RecordedFutureError): - """Error raise when the auto yara fetch of job fails.""" - - -class AutoSigmaJobCreationError(RecordedFutureError): - """Error raise when the auto sigma jobs creation fails.""" - - -class AutoSigmaJobEditError(RecordedFutureError): - """Error raise when the auto sigma jobs edit fails.""" - - -class AutoSigmaJobDeletionError(RecordedFutureError): - """Error raise when the auto sigma jobs deletion fails.""" - - -class AutoSigmaJobRetryError(RecordedFutureError): - """Error raise when the auto sigma jobs retry fails.""" - - -class AutoSigmaFetchJobsError(RecordedFutureError): - """Error raise when the auto sigma fetch of jobs fails.""" - - -class AutoSigmaFetchJobError(RecordedFutureError): - """Error raise when the auto sigma fetch of job fails.""" diff --git a/psengine/malware_intel/helpers.py b/psengine/malware_intel/helpers.py deleted file mode 100644 index 158cc6fd..00000000 --- a/psengine/malware_intel/helpers.py +++ /dev/null @@ -1,85 +0,0 @@ -##################################### TERMS OF USE ########################################### -# The following code is provided for demonstration purpose only, and should not be used # -# without independent verification. Recorded Future makes no representations or warranties, # -# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # -# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # -# responsibility for any information it may retrieve. Recorded Future shall not be liable # -# for, and you assume all risk of using, the foregoing. By using this code, Customer # -# represents that it is solely responsible for having all necessary licenses, permissions, # -# rights, and/or consents to connect to third party APIs, and that it is solely responsible # -# for having all necessary licenses, permissions, rights, and/or consents to any data # -# accessed from any third party API. # -############################################################################################## - -import logging -from pathlib import Path -from typing import Annotated - -from pydantic import validate_call -from typing_extensions import Doc - -from ..errors import WriteFileError -from ..helpers import OSHelpers, debug_call -from .malware_intel import AutoSigmaJobOut, AutoYaraJobOut - -LOG = logging.getLogger('psengine.malware_intel.helpers') - - -def _sanitize_name(value: str) -> str: - return value.replace(':', '_').replace('/', '_').replace('\\', '_') - - -def _safe_job_name(name: str | None, fallback: str) -> str: - return _sanitize_name(name.strip()) if name and name.strip() else _sanitize_name(fallback) - - -@debug_call -@validate_call -def save_rules( - rule_job: Annotated[ - AutoYaraJobOut | AutoSigmaJobOut, - Doc('Auto YARA or Auto Sigma job result to write to disk.'), - ], - output_directory: Annotated[ - str | Path | None, - Doc('Path to write to. If not provided, the current working directory will be used.'), - ] = None, -): - """Write Auto YARA or Auto Sigma generated rules to file(s). - - For `AutoYaraJobOut`, writes a single `.yar` file from `job.yara_rule_str`. - For `AutoSigmaJobOut`, writes one `.yml` file per item in `sigma_rules`. - - Raises: - WriteFileError: If write operations fail. - """ - output_directory = Path(output_directory).absolute() if output_directory else Path().cwd() - OSHelpers.mkdir(output_directory) - - if isinstance(rule_job, AutoYaraJobOut): - if not rule_job.job.yara_rule_str: - LOG.info(f'No YARA rule to write for {rule_job.job.job_id}') - return - - file_name = f'{_safe_job_name(rule_job.job.name, rule_job.job.job_id)}.yar' - try: - full_path = output_directory / file_name - full_path.write_text(rule_job.job.yara_rule_str) - LOG.info(f'Wrote: {full_path}') - return - except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as err: # noqa: PERF203 - raise WriteFileError(f"Could not write file '{file_name}': {err}") from err - - if not rule_job.sigma_rules: - LOG.info(f'No Sigma rules to write for {rule_job.job_id}') - return - - job_name = _safe_job_name(rule_job.name, rule_job.job_id) - for i, sigma_rule in enumerate(rule_job.sigma_rules, start=1): - file_name = f'{job_name} - Rule {i}.yml' - try: - full_path = output_directory / file_name - full_path.write_text(sigma_rule.rule) - LOG.info(f'Wrote: {full_path}') - except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as err: # noqa: PERF203 - raise WriteFileError(f"Could not write file '{file_name}': {err}") from err diff --git a/psengine/malware_intel/malware_intel.py b/psengine/malware_intel/malware_intel.py index 147a89ad..fca6a532 100644 --- a/psengine/malware_intel/malware_intel.py +++ b/psengine/malware_intel/malware_intel.py @@ -11,26 +11,14 @@ # accessed from any third party API. # ############################################################################################## -from datetime import datetime from functools import total_ordering -from typing import Annotated +from typing import Annotated, Optional from pydantic import AfterValidator, BeforeValidator, Field from ..common_models import RFBaseModel -from ..constants import TIMESTAMP_STR from ..helpers.helpers import Validators -from .models import ( - AutoSigmaRules, - DynamicInfo, - Metadata, - PEInfo, - SampleInfo, - SigmaPatternItem, - SigmaRuleItem, - StaticInfo, - YaraJob, -) +from .models import DynamicInfo, Metadata, PEInfo, SampleInfo, StaticInfo @total_ordering @@ -67,11 +55,11 @@ class SandboxReport(RFBaseModel): """ file: str - id_: str | None = None + id_: Optional[str] = None task: str dynamic: DynamicInfo metadata: Metadata - pe: PEInfo | None = None + pe: Optional[PEInfo] = None sample: SampleInfo static: StaticInfo @@ -110,142 +98,14 @@ class MalwareReportIn(RFBaseModel): """Validate data sent to the `/v1/reports` endpoint.""" query: str - sha256: str | None = None + sha256: Optional[str] = None start_date: Annotated[ str, BeforeValidator(Validators.convert_relative_time), AfterValidator(_split_time) ] end_date: Annotated[ - str | None, + Optional[str], BeforeValidator(Validators.convert_relative_time), AfterValidator(_split_time), ] my_enterprise: bool limit: int = Field(ge=1, le=10) - - -# weird workaround due to the fact that get_rules needs jobId, while get_rule needs job_id -class AutoSigmaJobOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-sigma/jobs/{job_id}` endpoint.""" - - job_id: str - name: str - created: datetime | None = None - modified: datetime | None = None - status: str = 'CREATED' - sigma_rules: list[SigmaRuleItem] = [] - query: str | None = None - start_date: str | None = None - end_date: str | None = None - n_matched_hashes: int | None = None - family_counts: dict[str, int] = {} - patterns: list[SigmaPatternItem] = Field(default_factory=list) - - def __str__(self): - created = self.created.strftime(TIMESTAMP_STR) if self.created else 'N/A' - matched = self.n_matched_hashes if self.n_matched_hashes is not None else 0 - return ( - f'Name: {self.name}, ID: {self.job_id}, Created: {created}, ' - f'Matched Hashes: {matched}, Sigma Rules: {len(self.sigma_rules)}, ' - f'Patterns: {len(self.patterns)}' - ) - - -class AutoSigmaJobsOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-sigma/get_jobs` endpoint.""" - - jobs: list[AutoSigmaRules] = [] - - def __str__(self): - return '\n'.join(str(j) for j in self.jobs) - - -class AutoSigmaJobCreateOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-sigma/jobs` endpoint.""" - - job_id: str - - def __str__(self): - return f'Job ID: {self.job_id}' - - -class AutoSigmaJobEditOut(RFBaseModel): - """Validate from the `/malware-intelligence/v1/auto-sigma/jobs/{job_id}/{rule_id}` endpoint.""" - - job_id: str - rule_id: str - updated: bool - - def __str__(self): - return f'Job ID: {self.job_id}, Rule ID: {self.rule_id}, Updated: {self.updated}' - - -class AutoSigmaJobDeleteOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-sigma/jobs/{job_id}` endpoint.""" - - deleted: bool - - def __str__(self): - return f'Deleted: {self.deleted}' - - -class AutoSigmaJobRetryOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-sigma/jobs/{job_id}/retry` endpoint.""" - - retried: bool - - def __str__(self): - return f'Retried: {self.retried}' - - -class AutoYaraJobCreateOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-yara/jobs` endpoint.""" - - job_id: str - - def __str__(self): - return f'Job ID: {self.job_id}' - - -class AutoYaraJobEditOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-yara/jobs/edit` endpoint.""" - - job_id: str - - def __str__(self): - return f'Job ID: {self.job_id}' - - -class AutoYaraJobDeleteOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-yara/jobs/{job_id}` endpoint.""" - - deleted: bool - - def __str__(self): - return f'Deleted: {self.deleted}' - - -class AutoYaraJobRetryOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-yara/jobs/{job_id}/retry` endpoint.""" - - retried: bool - - def __str__(self): - return f'Retried: {self.retried}' - - -class AutoYaraJobsOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-yara/jobs` endpoint.""" - - jobs: list[YaraJob] = [] - - def __str__(self): - return '\n'.join(str(j) for j in self.jobs) - - -class AutoYaraJobOut(RFBaseModel): - """Validate data from the `/malware-intelligence/v1/auto-yara/jobs/{job_id}` endpoint.""" - - job: YaraJob - - def __str__(self): - return str(self.job) diff --git a/psengine/malware_intel/malware_intel_mgr.py b/psengine/malware_intel/malware_intel_mgr.py index ec1dfd06..15eec38b 100644 --- a/psengine/malware_intel/malware_intel_mgr.py +++ b/psengine/malware_intel/malware_intel_mgr.py @@ -12,7 +12,7 @@ ############################################################################################## import logging -from typing import Annotated +from typing import Annotated, Optional from pydantic import validate_call from typing_extensions import Doc @@ -49,7 +49,7 @@ def reports( str, Doc('The starting date, format YYYY-MM-DD or relative like -1d.') ], end_date: Annotated[ - str | None, Doc('The ending date, format YYYY-MM-DD or relative like -1d.') + Optional[str], Doc('The ending date, format YYYY-MM-DD or relative like -1d.') ] = None, my_enterprise: Annotated[ bool, Doc('If the report has been submitted by your enterprise.') diff --git a/psengine/malware_intel/models.py b/psengine/malware_intel/models.py index ffe73a61..838f6480 100644 --- a/psengine/malware_intel/models.py +++ b/psengine/malware_intel/models.py @@ -11,347 +11,265 @@ # accessed from any third party API. # ############################################################################################## - -from datetime import datetime +from typing import Optional from pydantic import Field from ..common_models import RFBaseModel -from ..constants import TIMESTAMP_STR - -### MALWARE REPORTS class DumpedItem(RFBaseModel): - md5: str | None = None - name: str | None = None - path: str | None = None - sha1: str | None = None - sha256: str | None = None - sha512: str | None = None - size: int | None = None + md5: Optional[str] = None + name: Optional[str] = None + path: Optional[str] = None + sha1: Optional[str] = None + sha256: Optional[str] = None + sha512: Optional[str] = None + size: Optional[int] = None class Credential(RFBaseModel): - host: str | None = None - password: str | None = None - port: int | None = None - username: str | None = None + host: Optional[str] = None + password: Optional[str] = None + port: Optional[int] = None + username: Optional[str] = None class KeyItem(RFBaseModel): - key: str | None = None - kind: str | None = None - value: str | None = None + key: Optional[str] = None + kind: Optional[str] = None + value: Optional[str] = None class DropperUrl(RFBaseModel): - type: str | None = None - url: str | None = None + type: Optional[str] = None + url: Optional[str] = None class Dropper(RFBaseModel): - deobfuscated: str | None = None - family: str | None = None - language: str | None = None - source: str | None = None - urls: list[DropperUrl] | None = [] + deobfuscated: Optional[str] = None + family: Optional[str] = None + language: Optional[str] = None + source: Optional[str] = None + urls: Optional[list[DropperUrl]] = [] class RansomNote(RFBaseModel): - contact: list[str] | None = [] - emails: list[str] | None = [] - family: str | None = None - note: str | None = None - target: str | None = None - urls: list[str] | None = [] - wallets: list[str] | None = [] + contact: Optional[list[str]] = [] + emails: Optional[list[str]] = [] + family: Optional[str] = None + note: Optional[str] = None + target: Optional[str] = None + urls: Optional[list[str]] = [] + wallets: Optional[list[str]] = [] class ExtractedConfig(RFBaseModel): - botnet: str | None = None - c2: list[str] | None = [] - campaign: str | None = None - credentials: list[Credential] | None = [] - decoy: list[str] | None = [] - dns: list[str] | None = [] - extracted_pe: str | None = None - family: str | None = None - keys: list[KeyItem] | None = [] - listen_addr: str | None = None - listen_for: list[str] | None = [] - listen_port: int | None = None - mutex: list[str] | None = [] - rule: str | None = None - tags: list[str] | None = [] - version: str | None = None - webinject: list[str] | None = [] + botnet: Optional[str] = None + c2: Optional[list[str]] = [] + campaign: Optional[str] = None + credentials: Optional[list[Credential]] = [] + decoy: Optional[list[str]] = [] + dns: Optional[list[str]] = [] + extracted_pe: Optional[str] = None + family: Optional[str] = None + keys: Optional[list[KeyItem]] = [] + listen_addr: Optional[str] = None + listen_for: Optional[list[str]] = [] + listen_port: Optional[int] = None + mutex: Optional[list[str]] = [] + rule: Optional[str] = None + tags: Optional[list[str]] = [] + version: Optional[str] = None + webinject: Optional[list[str]] = [] class ExtractedItem(RFBaseModel): config: ExtractedConfig = Field(default_factory=ExtractedConfig) credentials: Credential = Field(default_factory=Credential) dropper: Dropper = Field(default_factory=Dropper) - dumped_file: str | None = None - path: str | None = None + dumped_file: Optional[str] = None + path: Optional[str] = None ransom_note: RansomNote = Field(default_factory=RansomNote) - resource: str | None = None + resource: Optional[str] = None class DnsRecord(RFBaseModel): - flow_id: int | None = None - request_domain: list[str] | None = [] - request_domain_fld: list[str] | None = [] - request_domain_tld: list[str] | None = [] - request_type: list[str] | None = [] - response_domain: list[str] | None = [] - response_domain_fld: list[str] | None = [] - response_domain_tld: list[str] | None = [] - response_type: list[str] | None = [] - response_value: list[str] | None = [] + flow_id: Optional[int] = None + request_domain: Optional[list[str]] = [] + request_domain_fld: Optional[list[str]] = [] + request_domain_tld: Optional[list[str]] = [] + request_type: Optional[list[str]] = [] + response_domain: Optional[list[str]] = [] + response_domain_fld: Optional[list[str]] = [] + response_domain_tld: Optional[list[str]] = [] + response_type: Optional[list[str]] = [] + response_value: Optional[list[str]] = [] class Flow(RFBaseModel): - dst_ip: str | None = None - dst_port: int | None = None - id: int | None = None - layer_7: list[str] | None = [] - procid: int | None = None - proto: str | None = None + dst_ip: Optional[str] = None + dst_port: Optional[int] = None + id: Optional[int] = None + layer_7: Optional[list[str]] = [] + procid: Optional[int] = None + proto: Optional[str] = None class HttpRequest(RFBaseModel): - headers: list[str] | None = [] - method: str | None = None - request: str | None = None - url: str | None = None + headers: Optional[list[str]] = [] + method: Optional[str] = None + request: Optional[str] = None + url: Optional[str] = None class HttpResponse(RFBaseModel): - headers: list[str] | None = [] - response: str | None = None - status: str | None = None + headers: Optional[list[str]] = [] + response: Optional[str] = None + status: Optional[str] = None class HttpSequenceItem(RFBaseModel): - index: int | None = None + index: Optional[int] = None request: HttpRequest = Field(default_factory=HttpRequest) response: HttpResponse = Field(default_factory=HttpResponse) class HttpEntry(RFBaseModel): - flow: int | None = None - sequence: list[HttpSequenceItem] | None = [] + flow: Optional[int] = None + sequence: Optional[list[HttpSequenceItem]] = [] class IpInfo(RFBaseModel): - asn: str | None = None - cc: str | None = None - ip: str | None = None + asn: Optional[str] = None + cc: Optional[str] = None + ip: Optional[str] = None class NetworkInfo(RFBaseModel): - dns: list[DnsRecord] | None = [] - dns_count: int | None = None - flows: list[Flow] | None = [] - flows_count: int | None = None - http: list[HttpEntry] | None = [] - ips: list[IpInfo] | None = [] - ips_count: int | None = None + dns: Optional[list[DnsRecord]] = [] + dns_count: Optional[int] = None + flows: Optional[list[Flow]] = [] + flows_count: Optional[int] = None + http: Optional[list[HttpEntry]] = [] + ips: Optional[list[IpInfo]] = [] + ips_count: Optional[int] = None class ProcessInfo(RFBaseModel): - cmd: str | None = None - image: str | None = None - pid: int | None = None - ppid: int | None = None - procid: int | None = None - procid_parent: int | None = None + cmd: Optional[str] = None + image: Optional[str] = None + pid: Optional[int] = None + ppid: Optional[int] = None + procid: Optional[int] = None + procid_parent: Optional[int] = None class RegistryEntry(RFBaseModel): - key: str | None = None + key: Optional[str] = None class RegistryInfo(RFBaseModel): - create: list[RegistryEntry] | None = [] - read: list[RegistryEntry] | None = [] - write: list[RegistryEntry] | None = [] + create: Optional[list[RegistryEntry]] = [] + read: Optional[list[RegistryEntry]] = [] + write: Optional[list[RegistryEntry]] = [] class SignatureIndicator(RFBaseModel): - description: str | None = None - ioc: str | None = None - procid: int | None = None + description: Optional[str] = None + ioc: Optional[str] = None + procid: Optional[int] = None class Signature(RFBaseModel): - desc: str | None = None - indicators: list[SignatureIndicator] | None = [] - name: str | None = None - label: str | None = None - score: int | None = None - tags: list[str] | None = [] - ttp: list[str] | None = [] + desc: Optional[str] = None + indicators: Optional[list[SignatureIndicator]] = [] + name: Optional[str] = None + label: Optional[str] = None + score: Optional[int] = None + tags: Optional[list[str]] = [] + ttp: Optional[list[str]] = [] class PEHeader(RFBaseModel): - dll_characteristics: list[str] | None = [] - file_characteristics: list[str] | None = [] + dll_characteristics: Optional[list[str]] = [] + file_characteristics: Optional[list[str]] = [] class PEImport(RFBaseModel): - dll_name: str | None = None - imports: list[str] | None = [] + dll_name: Optional[str] = None + imports: Optional[list[str]] = [] class PESection(RFBaseModel): - characteristics: list[str] | None = [] - name: str | None = None - raw_data_offset: int | None = None - raw_data_size: int | None = None - virtual_size: int | None = None + characteristics: Optional[list[str]] = [] + name: Optional[str] = None + raw_data_offset: Optional[int] = None + raw_data_size: Optional[int] = None + virtual_size: Optional[int] = None class PESignature(RFBaseModel): - issuer: str | None = None - not_after: str | None = None - not_before: str | None = None - serial: str | None = None - subject: str | None = None + issuer: Optional[str] = None + not_after: Optional[str] = None + not_before: Optional[str] = None + serial: Optional[str] = None + subject: Optional[str] = None class PEInfo(RFBaseModel): - exports: list[str] | None = [] + exports: Optional[list[str]] = [] header: PEHeader = Field(default_factory=PEHeader) - imphash: str | None = None - imports: list[PEImport] | None = [] - sections: list[PESection] | None = [] - signatures: list[PESignature] | None = [] - timestamp: int | None = None + imphash: Optional[str] = None + imports: Optional[list[PEImport]] = [] + sections: Optional[list[PESection]] = [] + signatures: Optional[list[PESignature]] = [] + timestamp: Optional[int] = None class Industry(RFBaseModel): - industry_id: str | None = None - industry_name: str | None = None + industry_id: Optional[str] = None + industry_name: Optional[str] = None class Metadata(RFBaseModel): - industries: list[Industry] | None = [] - sectors: list[Industry] | None = [] - source: str | None = None + industries: Optional[list[Industry]] = [] + sectors: Optional[list[Industry]] = [] + source: Optional[str] = None class SampleInfo(RFBaseModel): - completed: str | None = None - created: str | None = None - id: str | None = None - score: int | None = None - tags: list[str] | None = [] + completed: Optional[str] = None + created: Optional[str] = None + id: Optional[str] = None + score: Optional[int] = None + tags: Optional[list[str]] = [] class StaticInfo(RFBaseModel): - extracted: list[ExtractedItem] | None = [] - exts: list[str] | None = None - sha1: str | None = None - md5: str | None = None - sha256: str | None = None - sha512: str | None = None - size: int | None = None - ssdeep: str | None = None - tags: list[str] | None = [] - target: str | None = None + extracted: Optional[list[ExtractedItem]] = [] + exts: Optional[list[str]] = None + sha1: Optional[str] = None + md5: Optional[str] = None + sha256: Optional[str] = None + sha512: Optional[str] = None + size: Optional[int] = None + ssdeep: Optional[str] = None + tags: Optional[list[str]] = [] + target: Optional[str] = None registry: RegistryInfo = Field(default_factory=RegistryInfo) - registry_count: int | None = None - signatures: list[Signature] | None = [] - signatures_count: int | None = None + registry_count: Optional[int] = None + signatures: Optional[list[Signature]] = [] + signatures_count: Optional[int] = None class DynamicInfo(RFBaseModel): - dumped: list[DumpedItem] | None = [] - dumped_count: int | None = None - extracted: list[ExtractedItem] | None = [] + dumped: Optional[list[DumpedItem]] = [] + dumped_count: Optional[int] = None + extracted: Optional[list[ExtractedItem]] = [] network: NetworkInfo = Field(default_factory=NetworkInfo) - processes: list[ProcessInfo] | None = [] + processes: Optional[list[ProcessInfo]] = [] registry: RegistryInfo = Field(default_factory=RegistryInfo) - registry_count: int | None = None - signatures: list[Signature] | None = [] - signatures_count: int | None = None - - -### /MALWARE REPORTS - -### AUTO YARA - - -class Coverage(RFBaseModel): - covered_hashes: list[str] - uncovered_hashes: list[str] - - -class Pattern(RFBaseModel): - ascii: list[str] = [] - matching_hashes: list[str] - pattern: str - - -class YaraJob(RFBaseModel): - coverage: Coverage | None = None - created: datetime | None = None - job_id: str - name: str - patterns: list[Pattern] = [] - status: str = 'CREATED' - query: str | None = None - yara_rule_str: str | None = None - - def __str__(self): - covered = len(self.coverage.covered_hashes) if self.coverage else 0 - uncovered = len(self.coverage.uncovered_hashes) if self.coverage else 0 - created = self.created.strftime(TIMESTAMP_STR) if self.created else 'N/A' - msg = 'ID: {}, Status: {}, Name: {}, Created: {}, Covered Hashes: {}, Uncovered Hashes: {}' - return msg.format(self.job_id, self.status, self.name, created, covered, uncovered) - - -### AUTO SIGMA - - -class SigmaPatternStats(RFBaseModel): - n_hashes: int | float | None = None - overlap: int | float | None = None - family_counts: dict[str, int | float] = Field(default_factory=dict) - - -class SigmaPatternItem(RFBaseModel): - image: str | None = None - cmd_pattern: str | None = None - matched_cmds: list[str] = Field(default_factory=list) - stats: SigmaPatternStats | None = None - - -class SigmaRuleItem(RFBaseModel): - rule: str - rule_id: str - stats: SigmaPatternStats | None = None - status: str - modified: datetime | None = None - - -class AutoSigmaRules(RFBaseModel): - job_id: str = Field(alias='jobId') - name: str - created: datetime | None = None - modified: datetime | None = None - status: str = 'CREATED' - query: str | None = None - start_date: str | None = Field(alias='startDate', default=None) - end_date: str | None = Field(alias='endDate', default=None) - n_matched_hashes: int | None = Field(alias='nMatchedHashes', default=None) - family_counts: dict[str, int] = Field(alias='familyCounts', default_factory=dict) - - def __str__(self): - created = self.created.strftime(TIMESTAMP_STR) if self.created else 'N/A' - matched = self.n_matched_hashes if self.n_matched_hashes is not None else 0 - return ( - f'Name: {self.name}, ID: {self.job_id}, Created: {created}, Matched Hashes: {matched}' - ) + registry_count: Optional[int] = None + signatures: Optional[list[Signature]] = [] + signatures_count: Optional[int] = None diff --git a/psengine/markdown/markdown.py b/psengine/markdown/markdown.py index 407e455a..4dbef278 100644 --- a/psengine/markdown/markdown.py +++ b/psengine/markdown/markdown.py @@ -12,7 +12,7 @@ ############################################################################################## import html -from typing import Annotated +from typing import Annotated, Union import markdown_strings from markdown_strings import header @@ -117,7 +117,7 @@ def add_title(self, title: str) -> None: """Add title to the markdown.""" self.title = title - def validate_section(self, title: str, content: list[dict] | list[str] | str) -> Section: + def validate_section(self, title: str, content: Union[list[dict], list[str], str]) -> Section: """Recursive function to validate a section and its content. Args: @@ -146,7 +146,9 @@ def validate_section(self, title: str, content: list[dict] | list[str] | str) -> def add_section( self, title: Annotated[str, Doc('Title of the section to add.')], - content: Annotated[list[dict] | list[str] | str, Doc('Content to include in the section.')], + content: Annotated[ + Union[list[dict], list[str], str], Doc('Content to include in the section.') + ], ) -> None: """Add a section to the markdown.""" self.sections.append(self.validate_section(title, content)) diff --git a/psengine/markdown/models.py b/psengine/markdown/models.py index 0a7dc853..d09772fa 100644 --- a/psengine/markdown/models.py +++ b/psengine/markdown/models.py @@ -12,9 +12,11 @@ ############################################################################################## +from typing import Union + from ..common_models import RFBaseModel class Section(RFBaseModel): title: str - content: list['Section'] | list[str] + content: Union[list['Section'], list[str]] diff --git a/psengine/playbook_alerts/constants.py b/psengine/playbook_alerts/constants.py index 4ed321de..c9cf9e8f 100644 --- a/psengine/playbook_alerts/constants.py +++ b/psengine/playbook_alerts/constants.py @@ -11,7 +11,7 @@ # accessed from any third party API. # ############################################################################################## from pathlib import Path -from typing import Literal +from typing import Literal, Union from ..constants import ROOT_DIR from ..playbook_alerts.pa_category import PACategory @@ -31,16 +31,15 @@ PLAYBOOK_ALERTS_OUTPUT_FNAME = 'rf_playbook_alerts_' -PLAYBOOK_ALERT_TYPE = ( - PBA_CodeRepoLeakage - | PBA_CyberVulnerability - | PBA_DomainAbuse - | PBA_IdentityNovelExposure - | PBA_ThirdPartyRisk - | PBA_GeopoliticsFacility - | PBA_MalwareReport -) - +PLAYBOOK_ALERT_TYPE = Union[ + PBA_CodeRepoLeakage, + PBA_CyberVulnerability, + PBA_DomainAbuse, + PBA_IdentityNovelExposure, + PBA_ThirdPartyRisk, + PBA_GeopoliticsFacility, + PBA_MalwareReport, +] PLAYBOOK_ALERT_INST = ( PBA_CodeRepoLeakage, PBA_CyberVulnerability, @@ -52,11 +51,10 @@ ) -PBA_WITH_IMAGES_TYPE = PBA_DomainAbuse | PBA_GeopoliticsFacility -PBA_WITH_IMAGES_VALIDATOR = ( - Literal[PACategory.DOMAIN_ABUSE.value] | Literal[PACategory.GEOPOLITICS_FACILITY.value] -) - +PBA_WITH_IMAGES_TYPE = Union[PBA_DomainAbuse, PBA_GeopoliticsFacility] +PBA_WITH_IMAGES_VALIDATOR = Union[ + Literal[PACategory.DOMAIN_ABUSE.value], Literal[PACategory.GEOPOLITICS_FACILITY.value] +] PBA_WITH_IMAGES_INST = (PBA_DomainAbuse, PBA_GeopoliticsFacility) ALERTS_PER_PAGE = 50 diff --git a/psengine/playbook_alerts/helpers.py b/psengine/playbook_alerts/helpers.py index 617c8d79..8c72f9c6 100644 --- a/psengine/playbook_alerts/helpers.py +++ b/psengine/playbook_alerts/helpers.py @@ -12,13 +12,14 @@ ############################################################################################## import logging from pathlib import Path -from typing import Annotated +from typing import Annotated, Union from typing_extensions import Doc from ..errors import WriteFileError from ..helpers import OSHelpers, debug_call -from .constants import DEFAULT_ALERTS_OUTPUT_DIR, PBA_WITH_IMAGES_INST, PBA_WITH_IMAGES_TYPE +from .constants import DEFAULT_ALERTS_OUTPUT_DIR +from .playbook_alerts import PBA_DomainAbuse LOG = logging.getLogger('psengine.playbook_alerts.helpers') @@ -26,25 +27,25 @@ @debug_call def save_pba_images( playbook_alerts: Annotated[ - PBA_WITH_IMAGES_TYPE | list[PBA_WITH_IMAGES_TYPE], - Doc('Single or list of alerts that contains images.'), + Union[PBA_DomainAbuse, list[PBA_DomainAbuse]], + Doc('Domain Abuse alert or a list of Domain Abuse alerts.'), ], output_directory: Annotated[ str, Doc('A directory to save the images to.') ] = DEFAULT_ALERTS_OUTPUT_DIR, ) -> None: - """Save images/screenshots to disk as a `.png` file. + """Save Domain Abuse images/screenshots to disk as a `.png` file. Raises: - TypeError: If alerts are not objects part of the PBA_WITH_IMAGES_TYPE tuple. + TypeError: If alerts are not `PBA_DomainAbuse` objects. WriteFileError: If the image save fails with an `OSError`. """ - if not isinstance(playbook_alerts, (list, *PBA_WITH_IMAGES_INST)): - raise TypeError(f'Image saving is only supported by {PBA_WITH_IMAGES_INST} alerts') + if not isinstance(playbook_alerts, (list, PBA_DomainAbuse)): + raise TypeError('Image saving is only supported by Domain Abuse alerts') playbook_alerts = playbook_alerts if isinstance(playbook_alerts, list) else [playbook_alerts] - if not all(isinstance(alert, PBA_WITH_IMAGES_INST) for alert in playbook_alerts): - raise TypeError(f'Image saving is only supported by {PBA_WITH_IMAGES_INST} alerts') + if not all(isinstance(alert, PBA_DomainAbuse) for alert in playbook_alerts): + raise TypeError('Image saving is only supported by Domain Abuse alerts') for alert in playbook_alerts: LOG.info(f'Saving {len(alert.images)} image(s) to disk for alert {alert.playbook_alert_id}') @@ -60,7 +61,7 @@ def save_pba_images( def _save_image( file_name: str, image_bytes: bytes, - output_directory: str | Path = DEFAULT_ALERTS_OUTPUT_DIR, + output_directory: Union[str, Path] = DEFAULT_ALERTS_OUTPUT_DIR, ) -> None: """Save image to disk as a .png file. diff --git a/psengine/playbook_alerts/markdown/markdown_cyber_vulnerability.py b/psengine/playbook_alerts/markdown/markdown_cyber_vulnerability.py index 3d0878e6..c53715d4 100644 --- a/psengine/playbook_alerts/markdown/markdown_cyber_vulnerability.py +++ b/psengine/playbook_alerts/markdown/markdown_cyber_vulnerability.py @@ -12,7 +12,7 @@ ############################################################################################## import itertools -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union from markdown_strings import bold, unordered_list @@ -47,7 +47,7 @@ def _add_ai_insights( md_maker.add_section('Recorded Future AI Insights', result) -def _format_cvss(cvss: CVSS | CVSSV3, title: str, html_tags: bool) -> str: +def _format_cvss(cvss: Union[CVSS, CVSSV3], title: str, html_tags: bool) -> str: """Extract data from enirched content. Parse timestamps and cvss score.""" content = [] if not cvss: @@ -184,7 +184,7 @@ def _cyber_vulnerability_markdown( pba: 'PBA_CyberVulnerability', md_maker: MarkdownMaker, html_tags: bool, - extra_content: list[EnrichmentData] | list[AnalystNote], + extra_content: Union[list[EnrichmentData], list[AnalystNote]], ) -> str: if extra_content is None: extra_content = [] diff --git a/psengine/playbook_alerts/markdown/markdown_domain_abuse.py b/psengine/playbook_alerts/markdown/markdown_domain_abuse.py index 76eba1a3..6f90c493 100644 --- a/psengine/playbook_alerts/markdown/markdown_domain_abuse.py +++ b/psengine/playbook_alerts/markdown/markdown_domain_abuse.py @@ -101,7 +101,7 @@ def _add_dns_records(pba: 'PBA_DomainAbuse', md_maker: MarkdownMaker): ] for record in pba.panel_evidence_summary.resolved_record_list ] - md_maker.iocs_to_defang.extend(list(zip(*records, strict=False))[0]) + md_maker.iocs_to_defang.extend(list(zip(*records))[0]) records.sort(key=lambda x: x[1], reverse=True) records.insert(0, ['Entity', 'Risk Score', 'Criticality', 'Record Type', 'Context']) diff --git a/psengine/playbook_alerts/markdown/markdown_third_party_risk.py b/psengine/playbook_alerts/markdown/markdown_third_party_risk.py index f5952fa3..8112fc42 100644 --- a/psengine/playbook_alerts/markdown/markdown_third_party_risk.py +++ b/psengine/playbook_alerts/markdown/markdown_third_party_risk.py @@ -13,7 +13,7 @@ import itertools from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union from markdown_strings import bold, unordered_list @@ -76,7 +76,7 @@ def _format_ip_rule_type( *, pba: 'PBA_ThirdPartyRisk', html_tags: bool, - extra_context: list[MDEnrichedIps] | list[AnalystNote], + extra_context: Union[list[MDEnrichedIps], list[AnalystNote]], **kwargs, # noqa: ARG001 ) -> str: """Format assessment with ip_rule type. Enrich IPs with risk score.""" @@ -112,7 +112,7 @@ def _format_hosts_communication_type( assessment: TPRAssessment, *, pba: 'PBA_ThirdPartyRisk', - extra_context: list[AnalystNote] | list[MDEnrichedIps], + extra_context: Union[list[AnalystNote], list[MDEnrichedIps]], html_tags: bool, **kwargs, # noqa: ARG001 ) -> str: @@ -238,7 +238,7 @@ def _add_assessments( pba: 'PBA_ThirdPartyRisk', md_maker: MarkdownMaker, html_tags: bool, - extra_context: list[AnalystNote] | list[MDEnrichedIps], + extra_context: Union[list[AnalystNote], list[MDEnrichedIps]], ) -> None: results = [] err = '\nAssessments unavailable. Consult the Recorded Future Portal.\n\n' @@ -284,7 +284,7 @@ def _third_party_risk_markdown( pba: 'PBA_ThirdPartyRisk', md_maker: MarkdownMaker, html_tags: bool, - extra_context: list[EnrichmentData] | list[AnalystNote] | list[SOAREnrichOut], + extra_context: Union[list[EnrichmentData], list[AnalystNote], list[SOAREnrichOut]], ) -> str: # noqa: ARG001 if extra_context is None: extra_context = [] diff --git a/psengine/playbook_alerts/models/common_models.py b/psengine/playbook_alerts/models/common_models.py index 6bc71d1d..64895cfd 100644 --- a/psengine/playbook_alerts/models/common_models.py +++ b/psengine/playbook_alerts/models/common_models.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional from pydantic import Field @@ -24,13 +25,13 @@ class ResolvedEntity(RFBaseModel): class PBAInsiktNote(RFBaseModel): id_: str = Field(alias='id') - title: str | None = None - published: datetime | None = None - topic: str | None = None - fragment: str | None = None + title: Optional[str] = None + published: Optional[datetime] = None + topic: Optional[str] = None + fragment: Optional[str] = None class AlertRule(RFBaseModel): id_: str = Field(alias='id') label: str - name: str | None = None + name: Optional[str] = None diff --git a/psengine/playbook_alerts/models/panel_log.py b/psengine/playbook_alerts/models/panel_log.py index c47956bc..3bdc6010 100644 --- a/psengine/playbook_alerts/models/panel_log.py +++ b/psengine/playbook_alerts/models/panel_log.py @@ -12,8 +12,9 @@ ############################################################################################## from datetime import datetime +from typing import Optional -from pydantic import Field, model_validator +from pydantic import Field, HttpUrl, model_validator from ...common_models import IdOptionalNameType, RFBaseModel @@ -43,8 +44,8 @@ class OldNewOptionalType(ChangeType): """ - old: str | None = None - new: str | None = None + old: Optional[str] = None + new: Optional[str] = None class AddedRemovedTypeEntities(ChangeType): @@ -54,13 +55,13 @@ class AddedRemovedTypeEntities(ChangeType): - `RelatedEntityChangeV2` """ - removed: list[IdOptionalNameType] | None = [] - added: list[IdOptionalNameType] | None = [] + removed: Optional[list[IdOptionalNameType]] = [] + added: Optional[list[IdOptionalNameType]] = [] class AddedRemovedList(ChangeType): - removed: list[str] | None = [] - added: list[str] | None = [] + removed: Optional[list[str]] = [] + added: Optional[list[str]] = [] class CommentChange(ChangeType): @@ -73,13 +74,13 @@ class Assignee(RFBaseModel): class AssigneeChange(ChangeType): - old: Assignee | None = None - new: Assignee | None = None + old: Optional[Assignee] = None + new: Optional[Assignee] = None class DnsRecord(RFBaseModel): - type_: str | None = Field(alias='type', default=None) - entity: IdOptionalNameType | None = None + type_: Optional[str] = Field(alias='type', default=None) + entity: Optional[IdOptionalNameType] = None class DomainAbuseDnsChange(ChangeType): @@ -89,77 +90,77 @@ class DomainAbuseDnsChange(ChangeType): class WhoisRecord(RFBaseModel): - status: str | None = None - registrar_name: str | None = None - private_registration: bool | None = None - name_servers: list[str] | None = [] - contact_email: str | None = None - created: datetime | None = None + status: Optional[str] = None + registrar_name: Optional[str] = None + private_registration: Optional[bool] = None + name_servers: Optional[list[str]] = [] + contact_email: Optional[str] = None + created: Optional[datetime] = None class WhoisContactRecord(ChangeType): - telephone: str | None = None - street1: str | None = None - state: str | None = None - postal_code: str | None = None - organization: str | None = None - name: str | None = None - fax: str | None = None - email: str | None = None - country_code: str | None = None - country: str | None = None - city: str | None = None - created: datetime | None = None + telephone: Optional[str] = None + street1: Optional[str] = None + state: Optional[str] = None + postal_code: Optional[str] = None + organization: Optional[str] = None + name: Optional[str] = None + fax: Optional[str] = None + email: Optional[str] = None + country_code: Optional[str] = None + country: Optional[str] = None + city: Optional[str] = None + created: Optional[datetime] = None class DomainAbuseWhoisChange(ChangeType): domain: str - old_record: WhoisRecord | None = None - new_record: WhoisRecord | None = None + old_record: Optional[WhoisRecord] = None + new_record: Optional[WhoisRecord] = None removed_contacts: list[WhoisContactRecord] added_contacts: list[WhoisContactRecord] class LogotypeInScreenshot(RFBaseModel): - logotype_id: str | None = None - screenshot_id: str | None = None - url: str + logotype_id: Optional[str] = None + screenshot_id: Optional[str] = None + url: HttpUrl class DomainAbuseLogoTypeChange(ChangeType): domain: str - removed: list[LogotypeInScreenshot] | None = [] - added: list[LogotypeInScreenshot] | None = [] + removed: Optional[list[LogotypeInScreenshot]] = [] + added: Optional[list[LogotypeInScreenshot]] = [] class MaliciousAssessment(RFBaseModel): id_: str = Field(alias='id') level: int - title: str | None = None + title: Optional[str] = None class MaliciousDnsRecord(RFBaseModel): - id_: str | None = Field(alias='id', default=None) + id_: Optional[str] = Field(alias='id', default=None) assessments: list[MaliciousAssessment] class DomainAbuseMaliciousDnsChange(ChangeType): domain: str - removed: list[MaliciousDnsRecord] | None = [] - added: list[MaliciousDnsRecord] | None = [] + removed: Optional[list[MaliciousDnsRecord]] = [] + added: Optional[list[MaliciousDnsRecord]] = [] class ReregistrationRecord(RFBaseModel): - registrar: str | None = None - registrar_name: str | None = None - iana_id: int | None = None - expiration: datetime | None = None + registrar: Optional[str] = None + registrar_name: Optional[str] = None + iana_id: Optional[int] = None + expiration: Optional[datetime] = None class DomainAbuseReregistrationRecordChange(ChangeType): domain: str - removed: ReregistrationRecord | None = None - added: ReregistrationRecord | None = None + removed: Optional[ReregistrationRecord] = None + added: Optional[ReregistrationRecord] = None class Source(RFBaseModel): @@ -172,24 +173,24 @@ class UrlAssessment(MaliciousAssessment): class MaliciousUrlRecord(RFBaseModel): - url: str | None = None + url: Optional[HttpUrl] = None assessments: list[UrlAssessment] class DomainAbuseMaliciousUrlChange(ChangeType): domain: str - removed: list[MaliciousUrlRecord] | None = [] - added: list[MaliciousUrlRecord] | None = [] + removed: Optional[list[MaliciousUrlRecord]] = [] + added: Optional[list[MaliciousUrlRecord]] = [] class MentionedEntity(RFBaseModel): entity: IdOptionalNameType - reference: str | None = None - fragment: str | None = None + reference: Optional[str] = None + fragment: Optional[str] = None class ScreenshotMention(RFBaseModel): - url: str + url: HttpUrl screenshot_id: str document: str analyzed: datetime @@ -204,43 +205,43 @@ class DomainAbuseScreenshotMentions(ChangeType): class VulnerabilityAssessment(RFBaseModel): id_: str = Field(alias='id') level: int - title: str | None = None + title: Optional[str] = None class TriggeredRiskRule(RFBaseModel): id_: str = Field(alias='id') - name: str | None = None - description: str | None = None - evidence_string: str | None = None - machine_name: str | None = None - timestamp: datetime | None = None + name: Optional[str] = None + description: Optional[str] = None + evidence_string: Optional[str] = None + machine_name: Optional[str] = None + timestamp: Optional[datetime] = None class VulnerabilityLifecycleChange(ChangeType): - added: VulnerabilityAssessment | None = None - removed: VulnerabilityAssessment | None = None - triggered_by_risk_rule: TriggeredRiskRule | None = None + added: Optional[VulnerabilityAssessment] = None + removed: Optional[VulnerabilityAssessment] = None + triggered_by_risk_rule: Optional[TriggeredRiskRule] = None class Document(RFBaseModel): id_: str = Field(alias='id') content: str owner_id: str - owner_name: str | None = None + owner_name: Optional[str] = None published: datetime class WatchList(RFBaseModel): id_: str = Field(alias='id') - name: str | None = None + name: Optional[str] = None class RepoAssessment(RFBaseModel): id_: str = Field(alias='id') level: int - title: str | None = None - text_indicator: str | None = None - entity: IdOptionalNameType | None = None + title: Optional[str] = None + text_indicator: Optional[str] = None + entity: Optional[IdOptionalNameType] = None class CodeRepoLeakageEvidence(RFBaseModel): @@ -256,14 +257,14 @@ class CodeRepoLeakageEvidenceChange(ChangeType): class TPRRiskEvidence(RFBaseModel): level: int - evidence_string: str | None = None - timestamp: datetime | None = None + evidence_string: Optional[str] = None + timestamp: Optional[datetime] = None class ThirdPartyAssessmentChange(ChangeType): risk_attribute: str - added: TPRRiskEvidence | None = None - removed: TPRRiskEvidence | None = None + added: Optional[TPRRiskEvidence] = None + removed: Optional[TPRRiskEvidence] = None class Assessment(RFBaseModel): @@ -274,8 +275,8 @@ class Assessment(RFBaseModel): class AssessmentChange(ChangeType): risk_attribute: str - removed: Assessment | None = None - added: Assessment | None = None + removed: Optional[Assessment] = None + added: Optional[Assessment] = None TYPE_MAPPING = { @@ -307,8 +308,8 @@ class AssessmentChange(ChangeType): class PanelLogV2(RFBaseModel): id_: str = Field(alias='id') - author_id: str | None = None - author_name: str | None = None + author_id: Optional[str] = None + author_name: Optional[str] = None created: datetime changes: list diff --git a/psengine/playbook_alerts/models/panel_status.py b/psengine/playbook_alerts/models/panel_status.py index 5fe86365..66682122 100644 --- a/psengine/playbook_alerts/models/panel_status.py +++ b/psengine/playbook_alerts/models/panel_status.py @@ -13,6 +13,7 @@ import contextlib from datetime import datetime +from typing import Optional, Union from pydantic import model_validator @@ -35,21 +36,21 @@ class OwnerOrganisationDetails(RFBaseModel): class PanelStatus(RFBaseModel): status: str priority: str - reopen: str | None = None - assignee_name: str | None = None - assignee_id: str | None = None + reopen: Optional[str] = None + assignee_name: Optional[str] = None + assignee_id: Optional[str] = None created: datetime updated: datetime - case_rule_id: str | None = None - case_rule_label: str | None = None + case_rule_id: Optional[str] = None + case_rule_label: Optional[str] = None alert_rule: AlertRule - creator_name: str | None = None - creator_id: str | None = None - owner_organisation_details: OwnerOrganisationDetails | None = None - entity_id: str | None = None - entity_name: str | None = None + creator_name: Optional[str] = None + creator_id: Optional[str] = None + owner_organisation_details: Optional[OwnerOrganisationDetails] = None + entity_id: Optional[str] = None + entity_name: Optional[str] = None actions_taken: list[str] - targets: list[ResolvedEntity | str] | None = [] + targets: Optional[list[Union[ResolvedEntity, str]]] = [] @model_validator(mode='before') @classmethod @@ -62,10 +63,10 @@ def rm_deprecated(cls, data): class PanelAction(RFBaseModel): - action: str | None = None - updated: datetime | None = None - assignee_name: str | None = None - assignee_id: str | None = None - status: str | None = None - description: str | None = None - link: str | None = None + action: Optional[str] = None + updated: Optional[datetime] = None + assignee_name: Optional[str] = None + assignee_id: Optional[str] = None + status: Optional[str] = None + description: Optional[str] = None + link: Optional[str] = None diff --git a/psengine/playbook_alerts/models/pba_code_repo_leak.py b/psengine/playbook_alerts/models/pba_code_repo_leak.py index d4fba343..41bee3b4 100644 --- a/psengine/playbook_alerts/models/pba_code_repo_leak.py +++ b/psengine/playbook_alerts/models/pba_code_repo_leak.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional from pydantic import Field, HttpUrl @@ -21,31 +22,31 @@ class CodeRepoPanelStatus(PanelStatus): - risk_score: int | None = None - entity_criticality: str | None = None - targets: list[ResolvedEntity] | None = [] + risk_score: Optional[int] = None + entity_criticality: Optional[str] = None + targets: Optional[list[ResolvedEntity]] = [] class Repository(RFBaseModel): id_: str = Field(alias='id', default=None) - name: str | None = None - owner: ResolvedEntity | None = None + name: Optional[str] = None + owner: Optional[ResolvedEntity] = None class Assessment(RFBaseModel): id_: str = Field(alias='id') - title: str | None = None - value: str | None = None + title: Optional[str] = None + value: Optional[str] = None class Evidence(RFBaseModel): assessments: list[Assessment] targets: list[ResolvedEntity] - url: HttpUrl | None = None + url: Optional[HttpUrl] = None content: str published: datetime class CodeRepoPanelEvidence(RFBaseModel): - repository: Repository | None = Field(default_factory=Repository) - evidence: list[Evidence] | None = [] + repository: Optional[Repository] = Field(default_factory=Repository) + evidence: Optional[list[Evidence]] = [] diff --git a/psengine/playbook_alerts/models/pba_cyber_vulnerability.py b/psengine/playbook_alerts/models/pba_cyber_vulnerability.py index e4db0d3c..ca1c75a3 100644 --- a/psengine/playbook_alerts/models/pba_cyber_vulnerability.py +++ b/psengine/playbook_alerts/models/pba_cyber_vulnerability.py @@ -11,6 +11,7 @@ # accessed from any third party API. # ############################################################################################## +from typing import Optional from pydantic import Field @@ -20,10 +21,10 @@ class CyberVulnerabilityPanelStatus(PanelStatus): - risk_score: int | None = None - entity_criticality: str | None = None - targets: list[ResolvedEntity] | None = [] - lifecycle_stage: str | None = None + risk_score: Optional[int] = None + entity_criticality: Optional[str] = None + targets: Optional[list[ResolvedEntity]] = [] + lifecycle_stage: Optional[str] = None class VulnerabilityRiskRules(RFBaseModel): @@ -32,12 +33,12 @@ class VulnerabilityRiskRules(RFBaseModel): class VulnerabilitySummary(RFBaseModel): - targets: list[ResolvedEntity] | None = [] - lifecycle_stage: str | None = None - risk_rules: list[VulnerabilityRiskRules] | None = [] + targets: Optional[list[ResolvedEntity]] = [] + lifecycle_stage: Optional[str] = None + risk_rules: Optional[list[VulnerabilityRiskRules]] = [] class CyberVulnerabilityPanelEvidence(RFBaseModel): - summary: VulnerabilitySummary | None = Field(default_factory=VulnerabilitySummary) - affected_products: list[ResolvedEntity] | None = [] - insikt_notes: list[PBAInsiktNote] | None = [] + summary: Optional[VulnerabilitySummary] = Field(default_factory=VulnerabilitySummary) + affected_products: Optional[list[ResolvedEntity]] = [] + insikt_notes: Optional[list[PBAInsiktNote]] = [] diff --git a/psengine/playbook_alerts/models/pba_domain_abuse.py b/psengine/playbook_alerts/models/pba_domain_abuse.py index 9e8f967d..2c3fa8c9 100644 --- a/psengine/playbook_alerts/models/pba_domain_abuse.py +++ b/psengine/playbook_alerts/models/pba_domain_abuse.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional, Union from pydantic import Field @@ -24,115 +25,115 @@ class Context(RFBaseModel): class DomainAbusePanelStatus(PanelStatus): - entity_criticality: str | None = None - risk_score: int | None = None - context_list: list[Context] | None = [] - targets: list[str] | None = [] + entity_criticality: Optional[str] = None + risk_score: Optional[int] = None + context_list: Optional[list[Context]] = [] + targets: Optional[list[str]] = [] class ResolvedRecord(RFBaseModel): - entity: str | None = None - record: str | None = None - risk_score: int | None = None - criticality: str | None = None - record_type: str | None = None - context_list: list[Context] | None = [] + entity: Optional[str] = None + record: Optional[str] = None + risk_score: Optional[int] = None + criticality: Optional[str] = None + record_type: Optional[str] = None + context_list: Optional[list[Context]] = [] class Reregistration(RFBaseModel): - registrar: str | None = None - registrar_name: str | None = None - expiration: datetime | None = None + registrar: Optional[str] = None + registrar_name: Optional[str] = None + expiration: Optional[datetime] = None class MentionedEntity(RFBaseModel): - entity: IdNameType | None = Field(default_factory=IdNameType) + entity: Optional[IdNameType] = Field(default_factory=IdNameType) reference: str fragment: str class MentionedKeyword(RFBaseModel): - entity: IdNameType | None = Field(default_factory=IdNameType) + entity: Optional[IdNameType] = Field(default_factory=IdNameType) reference: str fragment: str keyword: str class ScreenshotMention(RFBaseModel): - url: str | None = None - screenshot: str | None = None - document: str | None = None - analyzed: str | None = None - mentioned_entities: list[MentionedEntity] | None = [] - mentioned_custom_keywords: list[MentionedKeyword] | None = [] + url: Optional[str] = None + screenshot: Optional[str] = None + document: Optional[str] = None + analyzed: Optional[str] = None + mentioned_entities: Optional[list[MentionedEntity]] = [] + mentioned_custom_keywords: Optional[list[MentionedKeyword]] = [] class KeywordInDomain(RFBaseModel): - word: str | None = None - domain: str | None = None + word: Optional[str] = None + domain: Optional[str] = None class Keywords(RFBaseModel): - security_keywords_in_domain_name: list[KeywordInDomain] | None = [] - payment_keywords_in_domain_name: list[KeywordInDomain] | None = [] + security_keywords_in_domain_name: Optional[list[KeywordInDomain]] = [] + payment_keywords_in_domain_name: Optional[list[KeywordInDomain]] = [] class Screenshot(RFBaseModel): description: str image_id: str created: datetime - tag: str | None = None + tag: Optional[str] = None class DomainAbusePanelEvidenceSummary(RFBaseModel): - explanation: str | None = None - resolved_record_list: list[ResolvedRecord] | None = [] - screenshots: list[Screenshot] | None = [] - reregistration: Reregistration | None = Field(default_factory=Reregistration) - screenshot_mentions: list[ScreenshotMention] | None = [] - keywords_in_domain_name: Keywords | None = Field(default_factory=Keywords) + explanation: Optional[str] = None + resolved_record_list: Optional[list[ResolvedRecord]] = [] + screenshots: Optional[list[Screenshot]] = [] + reregistration: Optional[Reregistration] = Field(default_factory=Reregistration) + screenshot_mentions: Optional[list[ScreenshotMention]] = [] + keywords_in_domain_name: Optional[Keywords] = Field(default_factory=Keywords) class DomainAbusePanelEvidenceDns(RFBaseModel): - ip_list: list[ResolvedRecord] | None = [] - mx_list: list[ResolvedRecord] | None = [] - ns_list: list[ResolvedRecord] | None = [] + ip_list: Optional[list[ResolvedRecord]] = [] + mx_list: Optional[list[ResolvedRecord]] = [] + ns_list: Optional[list[ResolvedRecord]] = [] class ValueServer(RFBaseModel): - status: str | None = None - registrar_name: str | None = Field(alias='registrarName', default=None) - private_registration: bool | None = Field(alias='privateRegistration', default=None) - name_servers: list[str] | None = Field(alias='nameServers', default=[]) - contact_email: str | None = Field(alias='contactEmail', default=None) - created_date: datetime | None = Field(alias='createdDate', default=None) - updated_date: datetime | None = Field(alias='updatedDate', default=None) - expires_date: datetime | None = Field(alias='expiresDate', default=None) + status: Optional[str] = None + registrar_name: Optional[str] = Field(alias='registrarName', default=None) + private_registration: Optional[bool] = Field(alias='privateRegistration', default=None) + name_servers: Optional[list[str]] = Field(alias='nameServers', default=[]) + contact_email: Optional[str] = Field(alias='contactEmail', default=None) + created_date: Optional[datetime] = Field(alias='createdDate', default=None) + updated_date: Optional[datetime] = Field(alias='updatedDate', default=None) + expires_date: Optional[datetime] = Field(alias='expiresDate', default=None) class ValueLocation(RFBaseModel): type_: str = Field(alias='type', default=None) - telephone: str | None = None - street1: str | None = None - state: str | None = None - postal_code: str | None = Field(alias='postalCode', default=None) - organization: str | None = None - name: str | None = None - fax: str | None = None - email: str | None = None - country_code: str | None = Field(alias='countryCode', default=None) - country: str | None = None - city: str | None = None + telephone: Optional[str] = None + street1: Optional[str] = None + state: Optional[str] = None + postal_code: Optional[str] = Field(alias='postalCode', default=None) + organization: Optional[str] = None + name: Optional[str] = None + fax: Optional[str] = None + email: Optional[str] = None + country_code: Optional[str] = Field(alias='countryCode', default=None) + country: Optional[str] = None + city: Optional[str] = None class WhoisAttribute(RFBaseModel): provider: str entity: str attribute: str - value: ValueServer | ValueLocation + value: Union[ValueServer, ValueLocation] added: datetime = None - removed: datetime | None = None + removed: Optional[datetime] = None class DomainAbusePanelEvidenceWhois(RFBaseModel): - body: list[WhoisAttribute] | None = [] + body: Optional[list[WhoisAttribute]] = [] diff --git a/psengine/playbook_alerts/models/pba_geopolitics_facility.py b/psengine/playbook_alerts/models/pba_geopolitics_facility.py index b5ee11c3..7ac7ea18 100644 --- a/psengine/playbook_alerts/models/pba_geopolitics_facility.py +++ b/psengine/playbook_alerts/models/pba_geopolitics_facility.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional from pydantic import Field @@ -31,7 +32,7 @@ class Event(RFBaseModel): assessments: list[Assessment] = [] document_id: str = None time: datetime = None - images: list[str] | None = [] + images: Optional[list[str]] = [] class GeopolPanelEvidence(RFBaseModel): @@ -65,8 +66,8 @@ class GeopolPanelOverview(RFBaseModel): class GeopolPanelStatus(PanelStatus): - risk_score: int | None = None - entity_criticality: str | None = None + risk_score: Optional[int] = None + entity_criticality: Optional[str] = None class GeopolEvent(RFBaseModel): @@ -77,7 +78,7 @@ class GeopolEvent(RFBaseModel): document_id: str = None time: datetime = None assessments: list[Assessment] = [] - images: list[str] | None = None + images: Optional[list[str]] = None class GeopolPanelEvents(RFBaseModel): diff --git a/psengine/playbook_alerts/models/pba_identity_exposures.py b/psengine/playbook_alerts/models/pba_identity_exposures.py index cc9d7fa8..d2e0810d 100644 --- a/psengine/playbook_alerts/models/pba_identity_exposures.py +++ b/psengine/playbook_alerts/models/pba_identity_exposures.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional from pydantic import Field, IPvAnyAddress @@ -26,67 +27,67 @@ class Assessment(RFBaseModel): class PasswordDetails(RFBaseModel): - properties: list[str] | None = [] - rank: list[str] | None = [] - clear_text_value: str | None = None - clear_text_hint: str | None = None + properties: Optional[list[str]] = [] + rank: Optional[list[str]] = [] + clear_text_value: Optional[str] = None + clear_text_hint: Optional[str] = None class PasswordHash(RFBaseModel): algorithm: str - hash_: str | None = Field(alias='hash', default=None) - hash_prefix: str | None = None + hash_: Optional[str] = Field(alias='hash', default=None) + hash_prefix: Optional[str] = None class ExposedSecret(RFBaseModel): type_: str = Field(alias='type', default=None) - effectively_clear: bool | None = None - hashes: list[PasswordHash] | None = [] - details: PasswordDetails | None = Field(default_factory=PasswordDetails) + effectively_clear: Optional[bool] = None + hashes: Optional[list[PasswordHash]] = [] + details: Optional[PasswordDetails] = Field(default_factory=PasswordDetails) class Dump(RFBaseModel): - name: str | None = None - description: str | None = None + name: Optional[str] = None + description: Optional[str] = None class MalwareFamily(RFBaseModel): id_: str = Field(alias='id', default=None) - name: str | None = None + name: Optional[str] = None class Infrastructure(RFBaseModel): - ip: IPvAnyAddress | None = None + ip: Optional[IPvAnyAddress] = None class Technology(RFBaseModel): name: str - id_: str | None = Field(alias='id', default=None) - category: str | None = None + id_: Optional[str] = Field(alias='id', default=None) + category: Optional[str] = None class CompromisedHost(RFBaseModel): - exfiltration_date: datetime | None = None - os: str | None = None - os_username: str | None = None - malware_file: str | None = None - timezone: str | None = None - computer_name: str | None = None - uac: str | None = None - antivirus: list[str] | None = [] + exfiltration_date: Optional[datetime] = None + os: Optional[str] = None + os_username: Optional[str] = None + malware_file: Optional[str] = None + timezone: Optional[str] = None + computer_name: Optional[str] = None + uac: Optional[str] = None + antivirus: Optional[list[str]] = [] class IdentityPanelStatus(PanelStatus): - targets: list[ResolvedEntity] | None = [] + targets: Optional[list[ResolvedEntity]] = [] class IdentityPanelEvidence(RFBaseModel): - assessments: list[Assessment] | None = [] - subject: str | None = None - exposed_secret: ExposedSecret | None = Field(default_factory=ExposedSecret) - dump: Dump | None = Field(default_factory=Dump) - authorization_url: str | None = None - compromised_host: CompromisedHost | None = Field(default_factory=CompromisedHost) - malware_family: MalwareFamily | None = Field(default_factory=MalwareFamily) - infrastructure: Infrastructure | None = Field(default_factory=Infrastructure) - technologies: list[Technology] | None = [] + assessments: Optional[list[Assessment]] = [] + subject: Optional[str] = None + exposed_secret: Optional[ExposedSecret] = Field(default_factory=ExposedSecret) + dump: Optional[Dump] = Field(default_factory=Dump) + authorization_url: Optional[str] = None + compromised_host: Optional[CompromisedHost] = Field(default_factory=CompromisedHost) + malware_family: Optional[MalwareFamily] = Field(default_factory=MalwareFamily) + infrastructure: Optional[Infrastructure] = Field(default_factory=Infrastructure) + technologies: Optional[list[Technology]] = [] diff --git a/psengine/playbook_alerts/models/pba_malware_report.py b/psengine/playbook_alerts/models/pba_malware_report.py index dd78728a..b2f8a232 100644 --- a/psengine/playbook_alerts/models/pba_malware_report.py +++ b/psengine/playbook_alerts/models/pba_malware_report.py @@ -11,6 +11,7 @@ # accessed from any third party API. # ############################################################################################## +from typing import Optional from pydantic import Field @@ -19,7 +20,7 @@ class MalwareReportPanelStatus(PanelStatus): - actions_taken: list[str] | None = Field(default=None, exclude=True) + actions_taken: Optional[list[str]] = Field(default=None, exclude=True) class ReportOverview(RFBaseModel): @@ -46,9 +47,9 @@ class SandboxScore(RFBaseModel): class MalwareReportPanelEvidence(RFBaseModel): - notification_title: str | None = None - report_limit_reached: bool | None = None - number_of_reports: int | None = None - matched_hashes: list[MatchedHash] | None = [] - detected_malwares: list[DetectedMalware] | None = [] - sandbox_scores: list[SandboxScore] | None = [] + notification_title: Optional[str] = None + report_limit_reached: Optional[bool] = None + number_of_reports: Optional[int] = None + matched_hashes: Optional[list[MatchedHash]] = [] + detected_malwares: Optional[list[DetectedMalware]] = [] + sandbox_scores: Optional[list[SandboxScore]] = [] diff --git a/psengine/playbook_alerts/models/pba_third_party_risk.py b/psengine/playbook_alerts/models/pba_third_party_risk.py index cfce3aa7..c32c2c84 100644 --- a/psengine/playbook_alerts/models/pba_third_party_risk.py +++ b/psengine/playbook_alerts/models/pba_third_party_risk.py @@ -12,6 +12,7 @@ ############################################################################################## from datetime import datetime +from typing import Optional from pydantic import Field, model_validator @@ -21,16 +22,16 @@ class TPRPanelStatus(PanelStatus): - risk_score: int | None = None - entity_criticality: str | None = None - targets: list[ResolvedEntity] | None = [] + risk_score: Optional[int] = None + entity_criticality: Optional[str] = None + targets: Optional[list[ResolvedEntity]] = [] class ObservedNetworkTraffic(RFBaseModel): recent_timestamp: datetime - malware_family: str | None = None - client_ip_address: str | None = None - malware_ip_address: str | None = None + malware_family: Optional[str] = None + client_ip_address: Optional[str] = None + malware_ip_address: Optional[str] = None class SummaryString(RFBaseModel): @@ -39,11 +40,11 @@ class SummaryString(RFBaseModel): class Reference(RFBaseModel): - title: str | None = None - fragment: str | None = None + title: Optional[str] = None + fragment: Optional[str] = None published: datetime - document_url: str | None = None - source: str | None = None + document_url: Optional[str] = None + source: Optional[str] = None class IpRule(RFBaseModel): @@ -64,7 +65,8 @@ class Evidence(RFBaseModel): data: list @model_validator(mode='after') - def check_data_type(self): + @classmethod + def check_data_type(cls, evidence: 'Evidence'): """Check if evidence type is supported and validate it.""" type_mapping = { 'ip_rule': IpRule, @@ -74,12 +76,12 @@ def check_data_type(self): 'hosts_communication': ObservedNetworkTraffic, 'summary_string': SummaryString, } - self.data = [ + evidence.data = [ model.model_validate(obj) - for obj in self.data - if (model := type_mapping.get(self.type_)) + for obj in evidence.data + if (model := type_mapping.get(evidence.type_)) ] - return self + return evidence class TPRAssessment(RFBaseModel): @@ -90,4 +92,4 @@ class TPRAssessment(RFBaseModel): class TPRPanelEvidence(RFBaseModel): - assessments: list[TPRAssessment] | None = [] + assessments: Optional[list[TPRAssessment]] = [] diff --git a/psengine/playbook_alerts/models/search_endpoint.py b/psengine/playbook_alerts/models/search_endpoint.py index 8f364b89..b7b39021 100644 --- a/psengine/playbook_alerts/models/search_endpoint.py +++ b/psengine/playbook_alerts/models/search_endpoint.py @@ -13,6 +13,7 @@ import contextlib from datetime import datetime +from typing import Optional from pydantic import Field, model_validator @@ -22,8 +23,8 @@ class DatetimeRange(RFBaseModel): - from_: datetime | None = Field(alias='from', default=None) - until: datetime | None = None + from_: Optional[datetime] = Field(alias='from', default=None) + until: Optional[datetime] = None class SearchStatus(RFBaseModel): @@ -36,14 +37,14 @@ class SearchData(RFBaseModel): alert_rule: AlertRule status: str priority: str - reopen: str | None = None + reopen: Optional[str] = None created: datetime updated: datetime category: str title: str - assignee_name: str | None = None - assignee_id: str | None = None - owner_organisation_details: OwnerOrganisationDetails | None = None + assignee_name: Optional[str] = None + assignee_id: Optional[str] = None + owner_organisation_details: Optional[OwnerOrganisationDetails] = None actions_taken: list[str] @model_validator(mode='before') diff --git a/psengine/playbook_alerts/playbook_alert_mgr.py b/psengine/playbook_alerts/playbook_alert_mgr.py index 14de6d30..c34d3bed 100644 --- a/psengine/playbook_alerts/playbook_alert_mgr.py +++ b/psengine/playbook_alerts/playbook_alert_mgr.py @@ -12,7 +12,7 @@ ############################################################################################## import logging -from typing import Annotated +from typing import Annotated, Optional, Union import pydantic import requests @@ -59,7 +59,7 @@ class PlaybookAlertMgr: def __init__( self, - rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None, + rf_token: Annotated[Optional[str], Doc('Recorded Future API token.')] = None, ): """Initialize the `PlaybookAlertMgr` object.""" self.log = logging.getLogger(__name__) @@ -72,17 +72,17 @@ def fetch( self, alert_id: Annotated[str, Doc('Alert ID to fetch.')], category: Annotated[ - PACategory | None, + Optional[PACategory], Doc( 'Category to fetch. If not given, `playbook-alert/common` is used to determine it.' ), ] = None, panels: Annotated[ - list[str] | None, + Optional[list[str]], Doc('Panels to fetch. The `status` panel is always fetched for ADT initialization.'), ] = None, fetch_images: Annotated[ - bool | None, Doc('Fetch images for Domain Abuse & Geopol alerts.') + Optional[bool], Doc('Fetch images for Domain Abuse & Geopol alerts.') ] = True, ) -> Annotated[ PLAYBOOK_ALERT_TYPE, @@ -127,54 +127,54 @@ def fetch( def fetch_bulk( self, alerts: Annotated[ - list[tuple[str, PACategory]] | None, + Optional[list[tuple[str, PACategory]]], Doc('List of (alert_id, category) tuples to fetch. Overrides search parameters.'), ] = None, panels: Annotated[ - list[str] | None, + Optional[list[str]], Doc('Panels to fetch for each alert. The `status` panel is always fetched.'), ] = None, fetch_images: Annotated[ - bool | None, Doc('Whether to fetch images for supported alert types.') + Optional[bool], Doc('Whether to fetch images for supported alert types.') ] = False, alerts_per_page: Annotated[ - int | None, Doc('Number of alerts per page (pagination).') + Optional[int], Doc('Number of alerts per page (pagination).') ] = Field(ge=1, le=10000, default=ALERTS_PER_PAGE), - max_results: Annotated[int | None, Doc('Maximum number of alerts to fetch.')] = Field( + max_results: Annotated[Optional[int], Doc('Maximum number of alerts to fetch.')] = Field( ge=1, le=10_000, default=DEFAULT_LIMIT ), order_by: Annotated[ - str | None, Doc('Field to order alerts by, e.g. `created` or `updated`.') + Optional[str], Doc('Field to order alerts by, e.g. `created` or `updated`.') ] = None, - direction: Annotated[str | None, Doc('Sort direction: `asc` or `desc`.')] = None, + direction: Annotated[Optional[str], Doc('Sort direction: `asc` or `desc`.')] = None, entity: Annotated[ - str | list | None, Doc('Entity or list of entities to filter alerts by.') + Union[str, list, None], Doc('Entity or list of entities to filter alerts by.') ] = None, statuses: Annotated[ - str | list | None, + Union[str, list, None], Doc("Status or list of statuses to filter alerts by, e.g. `['New', 'Closed']`."), ] = None, priority: Annotated[ - str | list | None, Doc("Priority or list of priorities, e.g. `['High', 'Low']`.") + Union[str, list, None], Doc("Priority or list of priorities, e.g. `['High', 'Low']`.") ] = None, category: Annotated[ - PACategory | list[PACategory] | None, + Union[PACategory, list[PACategory], None], Doc('Category or list of categories to filter alerts by.'), ] = None, assignee: Annotated[ - str | list | None, Doc('Assignee or list of uhashes to filter by.') + Union[str, list, None], Doc('Assignee or list of uhashes to filter by.') ] = None, created_from: Annotated[ - str | None, Doc('Start of created date range (ISO or relative, e.g. `-3d`).') + Optional[str], Doc('Start of created date range (ISO or relative, e.g. `-3d`).') ] = None, created_until: Annotated[ - str | None, Doc('End of created date range (ISO or relative).') + Optional[str], Doc('End of created date range (ISO or relative).') ] = None, updated_from: Annotated[ - str | None, Doc('Start of updated date range (ISO or relative).') + Optional[str], Doc('Start of updated date range (ISO or relative).') ] = None, updated_until: Annotated[ - str | None, Doc('End of updated date range (ISO or relative).') + Optional[str], Doc('End of updated date range (ISO or relative).') ] = None, ) -> Annotated[ list[PLAYBOOK_ALERT_TYPE], @@ -225,48 +225,44 @@ def fetch_bulk( @connection_exceptions(ignore_status_code=[], exception_to_raise=PlaybookAlertSearchError) def search( self, - alerts_per_page: Annotated[int | None, Doc('Number of alerts per page.')] = Field( - ge=1, le=10_000, default=ALERTS_PER_PAGE - ), - max_results: Annotated[int | None, Doc('Maximum total number of alerts to fetch.')] = Field( - ge=1, le=10_000, default=DEFAULT_LIMIT + alerts_per_page: Annotated[Optional[int], Doc('Number of alerts per page.')] = Field( + ge=1, le=10000, default=ALERTS_PER_PAGE ), + max_results: Annotated[ + Optional[int], Doc('Maximum total number of alerts to fetch.') + ] = Field(ge=1, le=10_000, default=DEFAULT_LIMIT), order_by: Annotated[ - str | None, Doc('Field to order alerts by, e.g. `created` or `updated`.') + Optional[str], Doc('Field to order alerts by, e.g. `created` or `updated`.') ] = None, - direction: Annotated[str | None, Doc('Sort direction, either `asc` or `desc`.')] = None, + direction: Annotated[Optional[str], Doc('Sort direction, either `asc` or `desc`.')] = None, entity: Annotated[ - str | list | None, Doc('Entity or list of entities to filter alerts by.') + Union[str, list, None], Doc('Entity or list of entities to filter alerts by.') ] = None, statuses: Annotated[ - str | list | None, Doc('Status or list of statuses to filter alerts by.') + Union[str, list, None], Doc('Status or list of statuses to filter alerts by.') ] = None, priority: Annotated[ - str | list | None, Doc('Priority or list of priorities to filter alerts by.') + Union[str, list, None], Doc('Priority or list of priorities to filter alerts by.') ] = None, category: Annotated[ - PACategory | list[PACategory] | None, + Union[PACategory, list[PACategory], None], Doc('Category or list of categories to filter alerts by.'), ] = None, assignee: Annotated[ - str | list | None, + Union[str, list, None], Doc('Assignee or list of assignees (uhashes) to filter alerts by.'), ] = None, - organisation: Annotated[ - str | list | None, - Doc('Org or list of Orgs (uhashes) to filter alerts by.'), - ] = None, created_from: Annotated[ - str | None, Doc('Start of created date range (ISO or relative, e.g. `-7d`).') + Optional[str], Doc('Start of created date range (ISO or relative, e.g. `-7d`).') ] = None, created_until: Annotated[ - str | None, Doc('End of created date range (ISO or relative).') + Optional[str], Doc('End of created date range (ISO or relative).') ] = None, updated_from: Annotated[ - str | None, Doc('Start of updated date range (ISO or relative).') + Optional[str], Doc('Start of updated date range (ISO or relative).') ] = None, updated_until: Annotated[ - str | None, Doc('End of updated date range (ISO or relative).') + Optional[str], Doc('End of updated date range (ISO or relative).') ] = None, ) -> Annotated[SearchResponse, Doc('Search results matching the alert query.')]: """Search for playbook alerts using filters. @@ -312,15 +308,19 @@ def search( def update( self, alert: Annotated[ - PLAYBOOK_ALERT_TYPE | str, Doc('Playbook alert ADT or alert ID to update.') + Union[PLAYBOOK_ALERT_TYPE, str], Doc('Playbook alert ADT or alert ID to update.') ], - priority: Annotated[str | None, Doc("Updated alert priority (e.g. 'High', 'Low').")] = None, + priority: Annotated[ + Optional[str], Doc("Updated alert priority (e.g. 'High', 'Low').") + ] = None, status: Annotated[ - str | None, Doc("Updated alert status (e.g. 'New', 'InProgress').") + Optional[str], Doc("Updated alert status (e.g. 'New', 'InProgress').") + ] = None, + assignee: Annotated[Optional[str], Doc('Assignee uhash for the alert.')] = None, + log_entry: Annotated[Optional[str], Doc('Text for the alert log entry.')] = None, + reopen_strategy: Annotated[ + Optional[str], Doc('Strategy for reopening closed alerts.') ] = None, - assignee: Annotated[str | None, Doc('Assignee uhash for the alert.')] = None, - log_entry: Annotated[str | None, Doc('Text for the alert log entry.')] = None, - reopen_strategy: Annotated[str | None, Doc('Strategy for reopening closed alerts.')] = None, ) -> Annotated[requests.Response, Doc('API response object for the update operation.')]: """Update a playbook alert. @@ -356,20 +356,19 @@ def update( @validate_call def _prepare_query( self, - alerts_per_page: int | None = ALERTS_PER_PAGE, - max_results: int | None = DEFAULT_LIMIT, - order_by: str | None = None, - direction: str | None = None, - entity: str | list | None = None, - statuses: str | list | None = None, - priority: str | list | None = None, - category: str | list | None = None, - assignee: str | list | None = None, - organisation: str | list | None = None, - created_from: str | None = None, - created_until: str | None = None, - updated_from: str | None = None, - updated_until: str | None = None, + alerts_per_page: Optional[int] = ALERTS_PER_PAGE, + max_results: Optional[int] = DEFAULT_LIMIT, + order_by: Optional[str] = None, + direction: Optional[str] = None, + entity: Union[str, list, None] = None, + statuses: Union[str, list, None] = None, + priority: Union[str, list, None] = None, + category: Union[str, list, None] = None, + assignee: Union[str, list, None] = None, + created_from: Optional[str] = None, + created_until: Optional[str] = None, + updated_from: Optional[str] = None, + updated_until: Optional[str] = None, ) -> SearchIn: """Create a query for searching playbook alerts. @@ -381,6 +380,7 @@ def _prepare_query( Returns: SearchIn: Validated search query """ + params = {key: val for key, val in locals().items() if val and key != 'self'} query = { 'created_range': {}, 'updated_range': {}, @@ -392,16 +392,12 @@ def _prepare_query( if not category: query['category'] = [cat.value for cat in PACategory] - params = {key: val for key, val in locals().items() if val and key != 'self'} - for arg, val in params.items(): - if arg in ['created_from', 'created_until', 'updated_from', 'updated_until']: - key, value = self._process_time_arg(arg, val) - if isinstance(value, dict): - query[key].update(value) - else: - query[key] = value + for arg in params: + key, value = self._process_arg(arg, params[arg]) + if isinstance(value, dict): + query[key].update(value) else: - query[arg] = val + query[key] = value query = { key: val @@ -418,8 +414,8 @@ def _prepare_query( ) def fetch_one_image( self, - alert_id: Annotated[str | None, Doc('Alert ID corresponding to the image ID.')] = None, - image_id: Annotated[str | None, Doc('ID of the image to retrieve.')] = None, + alert_id: Annotated[Optional[str], Doc('Alert ID corresponding to the image ID.')] = None, + image_id: Annotated[Optional[str], Doc('ID of the image to retrieve.')] = None, alert_category: Annotated[ PBA_WITH_IMAGES_VALIDATOR, Doc("Category of the alert (e.g., 'domain_abuse', 'geopolitics_facility')."), @@ -598,11 +594,11 @@ def _do_bulk( return p_alerts - def _process_time_arg( + def _process_arg( self, attr: str, - value: int | str | list, - ) -> tuple[str, str | list]: + value: Union[int, str, list], + ) -> tuple[str, Union[str, list]]: """Return attribute and value normalized based on type of value. Args: @@ -612,8 +608,14 @@ def _process_time_arg( Returns: tuple (str, Union[str, list]): canonicalized query attributes """ - range_field = attr.split('_')[0] + '_range' - query_key = 'from' if attr.endswith('from') else 'until' - if TimeHelpers.is_rel_time_valid(value): - return range_field, {query_key: TimeHelpers.rel_time_to_date(value)} - return range_field, {query_key: value} + list_or_str_args = ['entity', 'statuses', 'priority', 'category', 'assignee'] + if attr in ['created_from', 'created_until', 'updated_from', 'updated_until']: + range_field = attr.split('_')[0] + '_range' + query_key = 'from' if attr.endswith('from') else 'until' + if TimeHelpers.is_rel_time_valid(value): + return range_field, {query_key: TimeHelpers.rel_time_to_date(value)} + return range_field, {query_key: value} + if attr in list_or_str_args and isinstance(value, str): + return attr, [value] + + return attr, value diff --git a/psengine/playbook_alerts/playbook_alerts.py b/psengine/playbook_alerts/playbook_alerts.py index 95af8d81..0d887924 100644 --- a/psengine/playbook_alerts/playbook_alerts.py +++ b/psengine/playbook_alerts/playbook_alerts.py @@ -15,21 +15,13 @@ from collections import defaultdict from functools import total_ordering from itertools import chain -from typing import Annotated - -from pydantic import ( - AfterValidator, - BeforeValidator, - Field, - NonNegativeInt, - PositiveInt, - model_validator, -) +from typing import Annotated, Optional + +from pydantic import Field, NonNegativeInt, PositiveInt, model_validator from typing_extensions import Doc from ..common_models import RFBaseModel from ..constants import DEFAULT_LIMIT, TIMESTAMP_STR -from ..helpers.helpers import Validators from ..playbook_alerts.markdown.markdown import _markdown_playbook_alert from .models import ( CodeRepoPanelEvidence, @@ -103,8 +95,8 @@ class PBA_Generic(RFBaseModel): """ playbook_alert_id: str - panel_log_v2: list[PanelLogV2] | None = [] - panel_status: PanelStatus | None = Field(default_factory=PanelStatus) + panel_log_v2: Optional[list[PanelLogV2]] = [] + panel_status: Optional[PanelStatus] = Field(default_factory=PanelStatus) category: str = 'unmapped_alert' @@ -143,12 +135,12 @@ def markdown( self, html_tags: Annotated[bool, Doc('Include HTML tags in the markdown output.')] = False, character_limit: Annotated[ - int | None, + Optional[int], Doc('Character limit for the markdown output.'), ] = None, defang_iocs: Annotated[bool, Doc('Defang IOCs in markdown output.')] = False, extra_context: Annotated[ - list | None, + Optional[list], Doc( """ List of context models used by supported PBA classes when rendering markdown. @@ -191,8 +183,8 @@ class PBA_CodeRepoLeakage(PBA_Generic): category: str = PACategory.CODE_REPO_LEAKAGE.value - panel_status: CodeRepoPanelStatus | None = Field(default_factory=CodeRepoPanelStatus) - panel_evidence_summary: CodeRepoPanelEvidence | None = Field( + panel_status: Optional[CodeRepoPanelStatus] = Field(default_factory=CodeRepoPanelStatus) + panel_evidence_summary: Optional[CodeRepoPanelEvidence] = Field( default_factory=CodeRepoPanelEvidence ) @@ -216,8 +208,8 @@ class PBA_ThirdPartyRisk(PBA_Generic): category: str = PACategory.THIRD_PARTY_RISK.value - panel_status: TPRPanelStatus | None = Field(default_factory=TPRPanelStatus) - panel_evidence_summary: TPRPanelEvidence | None = Field(default_factory=TPRPanelEvidence) + panel_status: Optional[TPRPanelStatus] = Field(default_factory=TPRPanelStatus) + panel_evidence_summary: Optional[TPRPanelEvidence] = Field(default_factory=TPRPanelEvidence) @property def log_third_party_assessment_changes(self) -> list: @@ -323,10 +315,10 @@ class PBA_CyberVulnerability(PBA_Generic): category: str = PACategory.CYBER_VULNERABILITY.value - panel_status: CyberVulnerabilityPanelStatus | None = Field( + panel_status: Optional[CyberVulnerabilityPanelStatus] = Field( default_factory=CyberVulnerabilityPanelStatus ) - panel_evidence_summary: CyberVulnerabilityPanelEvidence | None = Field( + panel_evidence_summary: Optional[CyberVulnerabilityPanelEvidence] = Field( default_factory=CyberVulnerabilityPanelEvidence ) @@ -357,8 +349,8 @@ class PBA_IdentityNovelExposure(PBA_Generic): category: str = PACategory.IDENTITY_NOVEL_EXPOSURES.value - panel_status: IdentityPanelStatus | None = Field(default_factory=IdentityPanelStatus) - panel_evidence_summary: IdentityPanelEvidence | None = Field( + panel_status: Optional[IdentityPanelStatus] = Field(default_factory=IdentityPanelStatus) + panel_evidence_summary: Optional[IdentityPanelEvidence] = Field( default_factory=IdentityPanelEvidence ) @@ -382,19 +374,19 @@ class PBA_DomainAbuse(PBA_Generic): __doc__ = __doc__ + '\n\n' + PBA_Generic.__doc__ # noqa: A003 - _images: dict | None = {} + _images: Optional[dict] = {} category: str = PACategory.DOMAIN_ABUSE.value - panel_action: list[PanelAction] | None = [] - panel_status: DomainAbusePanelStatus | None = Field(default_factory=DomainAbusePanelStatus) - panel_evidence_summary: DomainAbusePanelEvidenceSummary | None = Field( + panel_action: Optional[list[PanelAction]] = [] + panel_status: Optional[DomainAbusePanelStatus] = Field(default_factory=DomainAbusePanelStatus) + panel_evidence_summary: Optional[DomainAbusePanelEvidenceSummary] = Field( default_factory=DomainAbusePanelEvidenceSummary ) - panel_evidence_dns: DomainAbusePanelEvidenceDns | None = Field( + panel_evidence_dns: Optional[DomainAbusePanelEvidenceDns] = Field( default_factory=DomainAbusePanelEvidenceDns ) - panel_evidence_whois: DomainAbusePanelEvidenceWhois | None = Field( + panel_evidence_whois: Optional[DomainAbusePanelEvidenceWhois] = Field( default_factory=DomainAbusePanelEvidenceWhois ) @@ -492,13 +484,15 @@ class PBA_GeopoliticsFacility(PBA_Generic): __doc__ = __doc__ + '\n\n' + PBA_Generic.__doc__ # noqa: A003 - _images: dict | None = {} + _images: Optional[dict] = {} category: str = PACategory.GEOPOLITICS_FACILITY.value - panel_status: GeopolPanelStatus | None = Field(default_factory=GeopolPanelStatus) - panel_evidence_summary: GeopolPanelEvidence | None = Field(default_factory=GeopolPanelEvidence) - panel_overview: GeopolPanelOverview | None = Field(default_factory=GeopolPanelOverview) - panel_events_summary: GeopolPanelEvents | None = Field(default_factory=GeopolPanelEvents) + panel_status: Optional[GeopolPanelStatus] = Field(default_factory=GeopolPanelStatus) + panel_evidence_summary: Optional[GeopolPanelEvidence] = Field( + default_factory=GeopolPanelEvidence + ) + panel_overview: Optional[GeopolPanelOverview] = Field(default_factory=GeopolPanelOverview) + panel_events_summary: Optional[GeopolPanelEvents] = Field(default_factory=GeopolPanelEvents) @property def image_ids(self) -> list[str]: @@ -562,12 +556,14 @@ class PBA_MalwareReport(PBA_Generic): __doc__ = __doc__ + '\n\n' + PBA_Generic.__doc__ # noqa: A003 - _images: dict | None = {} + _images: Optional[dict] = {} category: str = PACategory.MALWARE_REPORT.value - panel_status: MalwareReportPanelStatus | None = Field(default_factory=MalwareReportPanelStatus) - panel_evidence_summary: MalwareReportPanelEvidence | None = Field( + panel_status: Optional[MalwareReportPanelStatus] = Field( + default_factory=MalwareReportPanelStatus + ) + panel_evidence_summary: Optional[MalwareReportPanelEvidence] = Field( default_factory=MalwareReportPanelEvidence ) @@ -575,22 +571,17 @@ class PBA_MalwareReport(PBA_Generic): class SearchIn(RFBaseModel): """Model for payload sent to `/search` endpoint.""" - from_: NonNegativeInt | None = Field(alias='from', default=None) - limit: PositiveInt | None = DEFAULT_LIMIT - order_by: str | None = None - direction: str | None = None - entity: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - statuses: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - priority: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - category: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - assignee: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - organisation: Annotated[ - list[str] | None, - BeforeValidator(Validators.convert_str_to_list), - AfterValidator(Validators.check_uhash_prefix), - ] = None - created_range: DatetimeRange | None = None - updated_range: DatetimeRange | None = None + from_: Optional[NonNegativeInt] = Field(alias='from', default=None) + limit: Optional[PositiveInt] = DEFAULT_LIMIT + order_by: Optional[str] = None + direction: Optional[str] = None + entity: Optional[list] = None + statuses: Optional[list[str]] = None + priority: Optional[list[str]] = None + category: Optional[list[str]] = None + assignee: Optional[list[str]] = None + created_range: Optional[DatetimeRange] = None + updated_range: Optional[DatetimeRange] = None class PreviewAlertOut(PanelStatus): @@ -604,10 +595,10 @@ class PreviewAlertOut(PanelStatus): class UpdateAlertIn(RFBaseModel): """Model for payload sent to PUT `/common/{playbook_alert_id}` endpoint.""" - priority: str | None = None - status: str | None = None - assignee: str | None = None - log_entry: str | None = None - reopen: str | None = None - added_actions_taken: list[str] | None = None - removed_actions_taken: list[str] | None = None + priority: Optional[str] = None + status: Optional[str] = None + assignee: Optional[str] = None + log_entry: Optional[str] = None + reopen: Optional[str] = None + added_actions_taken: Optional[list[str]] = None + removed_actions_taken: Optional[list[str]] = None diff --git a/psengine/rf_client.py b/psengine/rf_client.py index 39493f43..08b4f598 100644 --- a/psengine/rf_client.py +++ b/psengine/rf_client.py @@ -15,7 +15,7 @@ from collections import defaultdict from contextlib import suppress from json.decoder import JSONDecodeError -from typing import Annotated +from typing import Annotated, Optional, Union import jsonpath_ng from jsonpath_ng.exceptions import JsonPathParserError @@ -45,16 +45,16 @@ class RFClient(BaseHTTPClient): def __init__( self, api_token: Annotated[ - str | None, Doc('An RF API token. Defaults to RF_TOKEN environment variable.') + Union[str, None], Doc('An RF API token. Defaults to RF_TOKEN environment variable.') ] = None, http_proxy: Annotated[str, Doc('An HTTP proxy URL.')] = None, https_proxy: Annotated[str, Doc('An HTTPS proxy URL.')] = None, verify: Annotated[ - str | bool, + Union[str, bool], Doc('An SSL verification flag or path to CA bundle.'), ] = None, auth: Annotated[tuple[str, str], Doc('Basic Auth credentials.')] = None, - cert: Annotated[str | tuple[str, str] | None, Doc('Client certificates.')] = None, + cert: Annotated[Union[str, tuple[str, str], None], Doc('Client certificates.')] = None, timeout: Annotated[int, Doc('A request timeout. Defaults to 120.')] = None, retries: Annotated[int, Doc('A number of retries. Defaults to 5.')] = None, backoff_factor: Annotated[int, Doc('A backoff factor. Defaults to 1.')] = None, @@ -95,15 +95,15 @@ def request( str, Doc('An HTTP method, one of GET, PUT, POST, DELETE, HEAD, OPTIONS, PATCH.') ], url: Annotated[str, Doc('A URL to make the request to.')], - data: Annotated[dict | list[dict] | bytes | None, Doc('A request body.')] = None, + data: Annotated[Union[dict, list[dict], bytes, None], Doc('A request body.')] = None, *, - params: Annotated[dict | None, Doc('HTTP query parameters.')] = None, + params: Annotated[Optional[dict], Doc('HTTP query parameters.')] = None, headers: Annotated[ - dict | None, + Optional[dict], Doc('If specified, it overrides default headers and does not set the token.'), ] = None, content_type_header: Annotated[ - str | None, Doc('Content-Type header value.') + Optional[str], Doc('Content-Type header value.') ] = 'application/json', **kwargs, ) -> Annotated[Response, Doc('A requests.Response object.')]: @@ -243,15 +243,15 @@ def request_paged( method: Annotated[str, Doc('An HTTP method: GET or POST.')], url: Annotated[str, Doc('A URL to make the request to.')], max_results: Annotated[int, Doc('The maximum number of results to return.')] = 1000, - data: Annotated[dict | list[dict] | None, Doc('A request body.')] = None, + data: Annotated[Union[dict, list[dict], None], Doc('A request body.')] = None, *, - params: Annotated[dict | None, Doc('HTTP query parameters.')] = None, + params: Annotated[Union[dict, None], Doc('HTTP query parameters.')] = None, headers: Annotated[ - dict | None, + Union[dict, None], Doc('If specified, it overrides default headers and does not set the token.'), ] = None, results_path: Annotated[ - str | list[str], Doc('Path to extract paged results from.') + Union[str, list[str]], Doc('Path to extract paged results from.') ] = 'data', offset_key: Annotated[str, Doc("Key to use for paging. Defaults to 'offset'.")] = 'offset', **kwargs, @@ -408,7 +408,9 @@ def _get_root_key(self, path: jsonpath_ng.jsonpath.Child) -> str: except AttributeError: return str(path) - def _get_matches(self, results_expr: jsonpath_ng.jsonpath.Fields, results: list | dict) -> list: + def _get_matches( + self, results_expr: jsonpath_ng.jsonpath.Fields, results: Union[list, dict] + ) -> list: """Get matches from results. Args: diff --git a/psengine/risk_history/models.py b/psengine/risk_history/models.py index bd6077c0..285d2a44 100644 --- a/psengine/risk_history/models.py +++ b/psengine/risk_history/models.py @@ -12,7 +12,7 @@ ############################################################################################## from datetime import datetime -from typing import Annotated +from typing import Annotated, Optional from pydantic import BeforeValidator, Field @@ -22,37 +22,37 @@ class RiskScoreHistory(RFBaseModel): score: int - added: datetime | None = None - removed: datetime | None = None + added: Optional[datetime] = None + removed: Optional[datetime] = None class RiskLevelHistory(RFBaseModel): criticality: int - added: datetime | None = None - removed: datetime | None = None + added: Optional[datetime] = None + removed: Optional[datetime] = None class RiskRuleHistory(RFBaseModel): - risk_id: str | None = None + risk_id: Optional[str] = None risk_name: str criticality: int evidence: str - added: datetime | None = None - removed: datetime | None = None + added: Optional[datetime] = None + removed: Optional[datetime] = None class Entity(RFBaseModel): id: str - provided_id: str | None = None + provided_id: Optional[str] = None type: str name: str class RiskHistory(RFBaseModel): - entity: Entity | None = None - scores: list[RiskScoreHistory] | None = None - levels: list[RiskLevelHistory] | None = None - risk_rules: list[RiskRuleHistory] | None = None + entity: Optional[Entity] = None + scores: Optional[list[RiskScoreHistory]] = None + levels: Optional[list[RiskLevelHistory]] = None + risk_rules: Optional[list[RiskRuleHistory]] = None def __str__(self) -> str: return 'Entity {}: Risk Score Changes: {}, Risk Rule Changes: {}'.format( # noqa: UP032 @@ -62,7 +62,7 @@ def __str__(self) -> str: class RiskHistoryIn(RFBaseModel): entities: Annotated[list[str], BeforeValidator(Validators.convert_str_to_list)] - from_: Annotated[datetime | None, BeforeValidator(Validators.convert_relative_time)] = Field( + from_: Annotated[Optional[datetime], BeforeValidator(Validators.convert_relative_time)] = Field( None, alias='from' ) - to: Annotated[datetime | None, BeforeValidator(Validators.convert_relative_time)] = None + to: Annotated[Optional[datetime], BeforeValidator(Validators.convert_relative_time)] = None diff --git a/psengine/risk_history/risk_history_mgr.py b/psengine/risk_history/risk_history_mgr.py index 5c3f8df1..1483a836 100644 --- a/psengine/risk_history/risk_history_mgr.py +++ b/psengine/risk_history/risk_history_mgr.py @@ -12,7 +12,7 @@ ############################################################################################## import logging -from typing import Annotated +from typing import Annotated, Optional, Union from pydantic import validate_call from typing_extensions import Doc @@ -42,9 +42,9 @@ def __init__(self, rf_token: str = None): @connection_exceptions(ignore_status_code=[], exception_to_raise=RiskHistoryError) def search( self, - entities: Annotated[str | list[str], Doc('Entities to search.')], - from_: Annotated[str | None, Doc('ISO8691 date or relative date like -1d')] = None, - to: Annotated[str | None, Doc('ISO8691 date or relative date like -1d')] = None, + entities: Annotated[Union[str, list[str]], Doc('Entities to search.')], + from_: Annotated[Optional[str], Doc('ISO8691 date or relative date like -1d')] = None, + to: Annotated[Optional[str], Doc('ISO8691 date or relative date like -1d')] = None, ) -> Annotated[list[RiskHistory], Doc('A list of history information.')]: """Search for the risk history of one or more entities. diff --git a/psengine/stix2/complex_entity.py b/psengine/stix2/complex_entity.py index 480802e7..e9c2b3c9 100644 --- a/psengine/stix2/complex_entity.py +++ b/psengine/stix2/complex_entity.py @@ -11,7 +11,7 @@ # accessed from any third party API. # ############################################################################################## from datetime import datetime -from typing import Annotated +from typing import Annotated, Union import stix2 from typing_extensions import Doc @@ -225,7 +225,7 @@ def __init__( """ if not create_indicator and not create_obs: raise STIX2TransformError( - 'Indicator must create at least one of "Observable" or "Indicator"', + 'Inidcator must create at least one of "Observable" or "Indicator"', ) type_ = CONVERTED_TYPES.get(type_, type_) @@ -324,7 +324,7 @@ def _generate_external_references(self): def _generate_observable( self, - ) -> stix2.IPv6Address | stix2.IPv4Address | stix2.DomainName | stix2.File | stix2.URL: + ) -> Union[stix2.IPv6Address, stix2.IPv4Address, stix2.DomainName, stix2.File, stix2.URL]: """Creates stix2 observable.""" uuid = generate_uuid(name=self.name) if self.type == 'IpAddress': diff --git a/psengine/stix2/enriched_indicator.py b/psengine/stix2/enriched_indicator.py index 02083226..4c5e137a 100644 --- a/psengine/stix2/enriched_indicator.py +++ b/psengine/stix2/enriched_indicator.py @@ -126,7 +126,7 @@ def _relate(self, obj: BaseStixEntity) -> None: """Creates relationship between object and indicator/observabe. Raises: - STIX2TransformError: Generic transform error + STIX2TransformError: Generic transofmr error """ if isinstance(obj, IndicatorEntity): sources = [] diff --git a/psengine/stix2/helpers.py b/psengine/stix2/helpers.py index ee509221..e9d2ada4 100644 --- a/psengine/stix2/helpers.py +++ b/psengine/stix2/helpers.py @@ -11,7 +11,7 @@ # accessed from any third party API. # ############################################################################################## -from typing import Annotated +from typing import Annotated, Union from typing_extensions import Doc @@ -41,7 +41,7 @@ def convert_entity( ] = False, **kwargs, ) -> Annotated[ - IndicatorEntity | Identity | Malware | Vulnerability | TTP, + Union[IndicatorEntity, Identity, Malware, Vulnerability, TTP], Doc('An instance of a corresponding STIX2 entity based on the provided entity type.'), ]: """Convert an RF entity to STIX2. diff --git a/psengine/threat_maps/models.py b/psengine/threat_maps/models.py deleted file mode 100644 index 8a5884fb..00000000 --- a/psengine/threat_maps/models.py +++ /dev/null @@ -1,56 +0,0 @@ -##################################### TERMS OF USE ########################################### -# The following code is provided for demonstration purpose only, and should not be used # -# without independent verification. Recorded Future makes no representations or warranties, # -# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # -# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # -# responsibility for any information it may retrieve. Recorded Future shall not be liable # -# for, and you assume all risk of using, the foregoing. By using this code, Customer # -# represents that it is solely responsible for having all necessary licenses, permissions, # -# rights, and/or consents to connect to third party APIs, and that it is solely responsible # -# for having all necessary licenses, permissions, rights, and/or consents to any data # -# accessed from any third party API. # -############################################################################################## - -from datetime import datetime -from enum import Enum - -from ..common_models import IdName, RFBaseModel - - -class ThreatMapAxis(Enum): - opportunity = 'opportunity' - intent = 'intent' - - -class ThreatMapType(Enum): - actors = 'actors' - malware = 'malware' - - @property - def category_slug(self) -> str: - """Return the URL slug used by the categories endpoint. - - The map endpoint uses `actors`/`malware` (this enum's value), but the - categories endpoint uses the singular `actor`/`malware` β€” see api.md. - """ - return 'actor' if self is ThreatMapType.actors else 'malware' - - -class LogEntry(RFBaseModel): - watchlist: IdName | None = None - entity: IdName - severity: int - axis: str - date: datetime - - -class EntityAttributes(RFBaseModel): - name: str - alias: list[str] = [] - - -class ThreatActorAttributes(RFBaseModel): - name: str - common_names: list[str] = [] - alias: list[str] = [] - categories: list[IdName] = [] diff --git a/psengine/threat_maps/threat_map.py b/psengine/threat_maps/threat_map.py deleted file mode 100644 index b415d7e0..00000000 --- a/psengine/threat_maps/threat_map.py +++ /dev/null @@ -1,134 +0,0 @@ -##################################### TERMS OF USE ########################################### -# The following code is provided for demonstration purpose only, and should not be used # -# without independent verification. Recorded Future makes no representations or warranties, # -# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # -# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # -# responsibility for any information it may retrieve. Recorded Future shall not be liable # -# for, and you assume all risk of using, the foregoing. By using this code, Customer # -# represents that it is solely responsible for having all necessary licenses, permissions, # -# rights, and/or consents to connect to third party APIs, and that it is solely responsible # -# for having all necessary licenses, permissions, rights, and/or consents to any data # -# accessed from any third party API. # -############################################################################################## - -from datetime import datetime -from functools import total_ordering -from typing import Annotated - -from pydantic import BeforeValidator, Field - -from ..common_models import IdName, RFBaseModel -from ..helpers.helpers import Validators -from .models import EntityAttributes, LogEntry, ThreatActorAttributes - - -@total_ordering -class ThreatMapEntity(RFBaseModel): - """Model to validate data received from the `threat/map/{type}` endpoint. - - This class supports string representation, equality comparison, and hashing of `ThreatMapEntity` - instances. - - Hashing: - Defines uniqueness of a `ThreatMapEntity` object by the entity ID. - - Equality: - Validates equality between two `ThreatMapEntity` objects based on the entity ID. - - Greater-than Comparison: - Defines a greater-than comparison between two `ThreatMapEntity` instances based on - `opportunity`, `intent` and `prevalence`. Lastly on `id_` - - String Representation: - Returns a string representation of the `ThreatMapEntity` instance including the - entity match name, ID, opportunity, and intent or prevalence depending on category. - - ```python - >>> print(entity) - Entity Name: BlueDelta, ID: L37nw-, Opportunity: 65, Intent: 65' - ``` - Ordering: - The ordering of `ThreatMapEntity` instances is determined primarily by the `opportunity` - score followed by the `intent` and `prevalence`. - If two instances have the same scores, the `id_` is used as a last criterion. - """ - - id_: str = Field(alias='id') - name: str - alias: list[str] - categories: list[IdName] - intent: int | None = None - prevalence: int | None = None - opportunity: int - log_entries: list[LogEntry] - - def __hash__(self): - return hash(self.id_) - - def __eq__(self, other: 'ThreatMapEntity'): - return self.id_ == other.id_ - - def __gt__(self, other: 'ThreatMapEntity'): - return (self.opportunity, self.intent or 0, self.prevalence or 0, self.id_) > ( - other.opportunity or 0, - other.intent or 0, - other.prevalence or 0, - other.id_, - ) - - def __str__(self): - key = 'intent' if self.intent is not None else 'prevalence' - score = getattr(self, key) - return ( - f'Entity Name: {self.name}, ID: {self.id_}, ' - f'Opportunity: {self.opportunity}, {key.capitalize()}: {score}' - ) - - -class ThreatMap(RFBaseModel): - """Model for payload received by POST `/threat/map/{type}` endpoint.""" - - threat_map: list[ThreatMapEntity] - date: datetime = Field(description='Threat map generation timestamp') - - def __str__(self): - return '\n'.join(str(entity) for entity in sorted(self.threat_map)) - - -class ThreatMapInfo(RFBaseModel): - """Model for payload received by GET `/threat/maps` endpoint.""" - - name: str - type_: str = Field(alias='type') - organization: IdName - url: str - - -class EntityCategory(RFBaseModel): - """Model for payload received by GET `threat/{type}/categories` endpoint.""" - - id_: str = Field(alias='id') - type_: str = Field(alias='type') - attributes: EntityAttributes - - -class ThreatActorProfile(RFBaseModel): - """Model for payload received by POST `threat/actor/search` endpoint.""" - - id_: str = Field(alias='id') - type_: str = Field(alias='type') - attributes: ThreatActorAttributes - - def __str__(self): - attr = self.attributes - common = f', Common Names: {", ".join(attr.common_names)}' if attr.common_names else '' - return f'ID: {self.id_} Name: {attr.name}' + common - - -class ThreatMapFetchIn(RFBaseModel): - """Model to validate `threat/map/{org}/{type}` endpoint payload sent.""" - - malware: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - actors: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - categories: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None - watchlists: Annotated[list[str] | None, BeforeValidator(Validators.convert_str_to_list)] = None diff --git a/psengine/threat_maps/threat_map_mgr.py b/psengine/threat_maps/threat_map_mgr.py deleted file mode 100644 index cb18ba79..00000000 --- a/psengine/threat_maps/threat_map_mgr.py +++ /dev/null @@ -1,173 +0,0 @@ -##################################### TERMS OF USE ########################################### -# The following code is provided for demonstration purpose only, and should not be used # -# without independent verification. Recorded Future makes no representations or warranties, # -# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # -# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # -# responsibility for any information it may retrieve. Recorded Future shall not be liable # -# for, and you assume all risk of using, the foregoing. By using this code, Customer # -# represents that it is solely responsible for having all necessary licenses, permissions, # -# rights, and/or consents to connect to third party APIs, and that it is solely responsible # -# for having all necessary licenses, permissions, rights, and/or consents to any data # -# accessed from any third party API. # -############################################################################################## - -import logging -from typing import Annotated, Literal - -from pydantic import Field, validate_call -from typing_extensions import Doc - -from ..constants import DEFAULT_LIMIT -from ..endpoints import ( - EP_ACTOR_SEARCH, - EP_CATEGORIES, - EP_THREAT_MAP, - EP_THREAT_MAP_ORG, - EP_THREAT_MAPS_LIST, -) -from ..helpers import debug_call -from ..helpers.helpers import connection_exceptions -from ..rf_client import RFClient -from .errors import ( - ThreatActorSearchError, - ThreatMapCategoriesError, - ThreatMapFetchError, - ThreatMapInfoError, -) -from .models import ThreatMapType -from .threat_map import ( - EntityCategory, - ThreatActorProfile, - ThreatMap, - ThreatMapFetchIn, - ThreatMapInfo, -) - -MAP_TYPE = Literal['actors', 'malware'] - - -class ThreatMapMgr: - """Manages requests for Recorded Future Threat Maps API.""" - - def __init__( - self, - rf_token: Annotated[str | None, Doc('Recorded Future API token.')] = None, - ): - """Initialize the `ThreatMapMgr` object.""" - self.log = logging.getLogger(__name__) - self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient() - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=ThreatMapInfoError) - def fetch_available_maps( - self, - ) -> Annotated[list[ThreatMapInfo], Doc('A list of available threat maps.')]: - """Fetch available threat maps for the organization. - - Endpoint: - `threat/maps` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - ThreatMapInfoError: If connection error occurs. - """ - maps_response = self.rf_client.request(method='get', url=EP_THREAT_MAPS_LIST).json()['data'] - return [ThreatMapInfo.model_validate(entry) for entry in maps_response] - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=ThreatMapCategoriesError) - def fetch_entity_categories( - self, - map_type: Annotated[MAP_TYPE, Doc('Type of threat map.')], - ) -> Annotated[list[EntityCategory], Doc('A list of threat map taxonomy categories.')]: - """Fetch the entity category taxonomy used to filter threat maps. - - Endpoint: - `threat/{type}/categories` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - ThreatMapCategoriesError: If connection error occurs. - """ - map_type = ThreatMapType(map_type) - url = EP_CATEGORIES.format(map_type.category_slug) - cat_response = self.rf_client.request(method='get', url=url).json()['data'] - return [EntityCategory.model_validate(ent) for ent in cat_response] - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=ThreatActorSearchError) - def search_threat_actor( - self, - name: Annotated[ - str | None, Doc('Free text search of threat actor names, common names, or aliases.') - ] = None, - max_results: Annotated[ - int | None, Doc('Limit the total number of results returned.') - ] = DEFAULT_LIMIT, - actors_per_page: Annotated[ - int | None, Doc('The number of threat actors per page for pagination.') - ] = Field(ge=1, le=10_000, default=DEFAULT_LIMIT), - ) -> Annotated[ - list[ThreatActorProfile], Doc('A list of threat actors matching the search criteria.') - ]: - """Search Recorded Future's threat actor database by name, alias, or classification. - - Endpoint: - `threat/actor/search` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - ThreatActorSearchError: If connection error occurs. - """ - data = { - 'name': name, - 'limit': min(max_results or DEFAULT_LIMIT, actors_per_page or DEFAULT_LIMIT), - } - search_response = self.rf_client.request_paged( - method='post', - url=EP_ACTOR_SEARCH, - data=data, - results_path='data', - offset_key='offset', - max_results=max_results or DEFAULT_LIMIT, - ) - return [ThreatActorProfile.model_validate(ta) for ta in search_response] - - @debug_call - @validate_call - @connection_exceptions(ignore_status_code=[], exception_to_raise=ThreatMapFetchError) - def fetch_map( - self, - map_type: Annotated[MAP_TYPE, Doc('Type of threat map.')], - org_id: Annotated[str | None, Doc('Organization ID.')] = None, - malware: Annotated[str | list[str] | None, Doc('Filter by malware entity ID(s).')] = None, - actors: Annotated[str | list[str] | None, Doc('Filter by threat actor ID(s).')] = None, - categories: Annotated[str | list[str] | None, Doc('Filter by category ID(s).')] = None, - watchlists: Annotated[str | list[str] | None, Doc('Filter by watch list ID(s).')] = None, - ) -> Annotated[ThreatMap, Doc('Threat map with entities matching filter criteria.')]: - """Fetch a threat map with optional entity, category, and watchlist filters. - - Endpoint: - `threat/map/{type}` or `threat/map/{org_id}/{type}` - - Raises: - ValidationError: If any supplied parameter is of incorrect type. - ThreatMapFetchError: If connection error occurs. - """ - body = {'categories': categories, 'watchlists': watchlists} - map_type = ThreatMapType(map_type).value - if map_type is ThreatMapType.actors: - body['actors'] = actors - else: - body['malware'] = malware - - url = ( - EP_THREAT_MAP_ORG.format(org_id, map_type) if org_id else EP_THREAT_MAP.format(map_type) - ) - - data = ThreatMapFetchIn.model_validate(body).json() - map_response = self.rf_client.request(method='post', url=url, data=data).json()['data'] - return ThreatMap.model_validate(map_response) diff --git a/pyproject.toml b/pyproject.toml index b37e6637..ac767cc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "psengine" -version = "2.7.0" +version = "2.5.1" readme = "README.md" license = "MIT" -requires-python = ">=3.10, <3.15" +requires-python = ">=3.9, <3.15" description = "psengine is a simple, yet elegant, library for rapid development of integrations with Recorded Future." authors = [ @@ -19,6 +19,7 @@ classifiers=[ "Intended Audience :: Developers", "Topic :: Security", "Topic :: Software Development :: Libraries", + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -26,105 +27,80 @@ classifiers=[ "Programming Language :: Python :: 3.14" ] -dependencies = [ - "typing_extensions >= 4.8.0", + +dependencies = [ + "typing_extensions >= 4.8.0", "requests>=2.27.1", - "jsonpath_ng>=1.5.3, <=1.8.0", + "jsonpath_ng>=1.5.3, <=1.6.1", "stix2~=3.0.1", "python-dateutil>=2.7.0", - "more-itertools>=9.0.0, <=11.0.2", + "more-itertools>=9.0.0, <=10.2.0", "pydantic>=2.7, <3.0.0", - "pydantic-settings[toml]>=2.12.2,<2.14.1", - "markdown-strings==3.4.0" + "pydantic-settings[toml]>=2.5.2,<2.11.0", + "markdown-strings==3.4.0", ] - - -[project.urls] -Homepage = "https://recordedfuture-professionalservices.github.io/psengine/latest/" -Changelog = "https://recordedfuture-professionalservices.github.io/psengine/latest/CHANGELOG/" - -[dependency-groups] +[project.optional-dependencies] dev = [ - "pytest==9.0.3", - "pytest-cov==7.1.0", - "pytest-mock==3.15.1", + "pytest==8.3.4", + "pytest-cov==6.0.0", + "pytest-mock==3.14.0", "pytest-md==0.2.0", - "pytest-random-order==1.2.0", - "pytest-httpdbg==0.10.2", - "ruff~=0.15.8", - "mimesis>=19.1.0", - "freezegun>=1.5.5", + "pytest-random-order==1.1.1", + "pytest-httpdbg==0.9.0", + "ruff~=0.11.0", + "mimesis>=12.1.0", + "build==1.3.0", + "wheel==0.45.1", + "setuptools==80.9.0", ] docs = [ - "mike>=2.1.4,<2.3.0", + "ruff~=0.11.0", + "mike~=2.1.3", "mkdocs~=1.6.1", - "mkdocs-material>=9.6.18,<9.8.0", + "mkdocs-material~=9.6.18", "mkdocstrings[python]>=0.18", - "griffe-typingdoc>=0.2.8,<0.4.0", + "griffe-typingdoc~=0.2.8", "mkdocs-codeinclude-plugin~=0.2.1", "markdown-include~=0.8.1", "mkdocs-exclude~=1.0.2", # Tools for examples - "rich==15.0.0", - "logging-tree==1.10", + "rich", + "logging-tree", ] [build-system] -requires = ["uv_build>=0.11.7,<0.12"] -build-backend = "uv_build" - -[tool.uv] -default-groups = "all" -exclude-newer = "7 days" - -[tool.uv.build-backend] -module-root = "" -module-name = "psengine" -source-exclude = [ - # Hidden directories - ".git/**", - ".github/**", - ".pytest_cache/**", - ".ruff_cache/**", - ".venv/**", - - # Hidden files - ".coverage", - ".env", - ".gitignore", - ".python-version", +requires = [ + "setuptools>=80", + "wheel", +] +build-backend = "setuptools.build_meta" - # Build and test artifacts - "dist/**", - "build/**", - "htmlcov/**", - "*.egg-info/**", +[project.urls] +Homepage = "https://recordedfuture-professionalservices.github.io/psengine/latest/" +Changelog = "https://recordedfuture-professionalservices.github.io/psengine/latest/CHANGELOG/" - # Development/local files - "local/**", - "logs/**", - "docs/**", - "out/**", - "data/**", - "input/**", - "workdir/**", +[tool.setuptools] +packages = [ + "psengine", +] - # Tests - "tests/**", +[tool.setuptools.package-data] +"*" = [ + "**/*", +] +psengine = ["py.typed"] - # Release artifacts - "CHANGELOG.md", +[tool.setuptools.exclude-package-data] +"*" = [ + "**/*.pyc", +] - # Lock and cache files - "__pycache__/**", - "uv.lock", - "*.pyc", - "*.pyo", - "*.log", - "*.log.*", +[dependency-groups] +dev = [ + "pytest-mock>=3.14.0", ] diff --git a/ruff.toml b/ruff.toml index 4231b654..8587a292 100644 --- a/ruff.toml +++ b/ruff.toml @@ -17,7 +17,7 @@ exclude = [ line-length = 100 # Used by both linter and E501 indent-width = 4 output-format = "grouped" -target-version = "py310" +target-version = "py39" [lint] select = [ diff --git a/tests/asi/test_asi_mgr.py b/tests/asi/test_asi_mgr.py index d2a47864..26cb81fe 100644 --- a/tests/asi/test_asi_mgr.py +++ b/tests/asi/test_asi_mgr.py @@ -1,5 +1,6 @@ from copy import deepcopy from pathlib import Path +from typing import Optional import pytest from pydantic import ValidationError @@ -26,7 +27,7 @@ PROJECT_ID = '7c2d06d7-0c4b-4d0d-bc97-f81dcdc276de' -def _meta_payload(*, limit: int = 1, total: int = 1, next_cursor: str | None = None) -> dict: +def _meta_payload(*, limit: int = 1, total: int = 1, next_cursor: Optional[str] = None) -> dict: return {'pagination': {'limit': limit, 'total': total, 'next_cursor': next_cursor}} diff --git a/tests/asi/test_client.py b/tests/asi/test_client.py index d9d7c507..f56699ba 100644 --- a/tests/asi/test_client.py +++ b/tests/asi/test_client.py @@ -493,7 +493,7 @@ def test_prepare_headers_with_token(asi_client, mocker): headers = asi_client._prepare_headers() assert headers == { - 'User-Agent': 'app_id/0.0.0 (Linux) SDK_ID', + 'User-Agent': 'app_id unknown (Linux) SDK_ID platform_id unknown', 'Content-Type': 'application/json', 'accept': 'application/json', 'apikey': 'a' * 32, @@ -509,7 +509,7 @@ def test_prepare_headers_without_token_logs_warning(asi_client, mocker, caplog): headers = asi_client._prepare_headers() assert headers == { - 'User-Agent': 'app_id/0.0.0 (Linux) SDK_ID', + 'User-Agent': 'app_id unknown (Linux) SDK_ID platform_id unknown', 'Content-Type': 'application/json', 'accept': 'application/json', } diff --git a/tests/classic_alerts/test_classic_alerts.py b/tests/classic_alerts/test_classic_alerts.py index 564344f3..30ee80c1 100644 --- a/tests/classic_alerts/test_classic_alerts.py +++ b/tests/classic_alerts/test_classic_alerts.py @@ -82,57 +82,8 @@ ('0oxjSZ', {'ai_insights': False, 'owner_org': False, 'fragment_entities': False}), ] -IMAGE_BYTES_BY_ID = { - 'img:ZzD761qE1lG7NQ78y1YvZZYh3aIUMnm3WNJGFaQn': b'abcd', - 'img:KR15PL4OH8khYa9cP5jeYV64clkBAXmkep2bxLa7': b'abcde', -} - class Test_ClassicAlert: - def test_fetch_downloads_images_when_requested( - self, ca_mgr: ClassicAlertMgr, mocker, mock_request, make_binary_response - ): - mocks = [ - mock_request(MOCK_DIR / 'test_fetch_all_images_finds_images.json'), - *[ - make_binary_response(bytes_, {'Content-Disposition': 'filename=abc.png'}) - for bytes_ in IMAGE_BYTES_BY_ID.values() - ], - ] - request_mock = mocker.patch.object(ca_mgr.rf_client, 'request', side_effect=mocks) - - alert = ca_mgr.fetch('xOTsae', fetch_images=True) - - assert alert.images == IMAGE_BYTES_BY_ID - assert request_mock.call_count == len(mocks) - assert [call_[1]['params']['id'] for call_ in request_mock.call_args_list[1:]] == list( - IMAGE_BYTES_BY_ID - ) - - def test_fetch_bulk_downloads_images_when_requested( - self, ca_mgr: ClassicAlertMgr, mocker, mock_request, make_binary_response - ): - mocks = [ - mock_request(MOCK_DIR / 'test_fetch_all_images_finds_images.json'), - *[ - make_binary_response(bytes_, {'Content-Disposition': 'filename=abc.png'}) - for bytes_ in IMAGE_BYTES_BY_ID.values() - ], - mock_request(MOCK_DIR / 'test_fetch_all_images_finds_no_images.json'), - ] - request_mock = mocker.patch.object(ca_mgr.rf_client, 'request', side_effect=mocks) - - alerts = ca_mgr.fetch_bulk(['xOTsae', 'xPtXPZ'], fetch_images=True) - - assert alerts[0].images == IMAGE_BYTES_BY_ID - assert alerts[1].images == {} - assert request_mock.call_count == len(mocks) - assert [ - call_[1]['params']['id'] - for call_ in request_mock.call_args_list - if call_[1].get('params', {}).get('id') - ] == list(IMAGE_BYTES_BY_ID) - @pytest.mark.parametrize('alert_id', ALERT_IDS) def test_markdown(self, ca_mgr: ClassicAlertMgr, alert_id: str, request, mocker, mock_request): nodeid = request.node.nodeid diff --git a/tests/client/test_base_http_client.py b/tests/client/test_base_http_client.py index 41379fae..51f0efe7 100644 --- a/tests/client/test_base_http_client.py +++ b/tests/client/test_base_http_client.py @@ -177,13 +177,13 @@ def test_get_user_agent_header(self, mocker: mock, base_client): mocker.patch('psengine.base_http_client.SDK_ID', sdk_id) user_agent = base_client._get_user_agent_header() - assert user_agent == 'app_id/0.0.0 (Linux) SDK_ID' + assert user_agent == 'app_id unknown (Linux) SDK_ID platform_id unknown' mocker.patch('psengine.helpers.OSHelpers.os_platform', return_value=None) mocker.patch('psengine.base_http_client.SDK_ID', sdk_id) user_agent = base_client._get_user_agent_header() - assert user_agent == 'app_id/0.0.0 SDK_ID' + assert user_agent == 'app_id unknown SDK_ID platform_id unknown' def test_cert_auth(self, tests_dir, mocker, mock_request): client = BaseHTTPClient( diff --git a/tests/client/test_rf_client.py b/tests/client/test_rf_client.py index a436b297..10000658 100644 --- a/tests/client/test_rf_client.py +++ b/tests/client/test_rf_client.py @@ -612,7 +612,7 @@ def test_prepare_headers_with_token(self, rf_token, mocker: mock): headers = client._prepare_headers() expected_headers = { - 'User-Agent': 'app_id/0.0.0 (Linux) SDK_ID', + 'User-Agent': 'app_id unknown (Linux) SDK_ID platform_id unknown', 'Content-Type': 'application/json', 'accept': 'application/json', 'X-RFToken': rf_token, @@ -629,7 +629,7 @@ def test_prepare_headers_without_token(self, caplog, rfc, mocker: mock): headers = rfc._prepare_headers() expected_headers = { - 'User-Agent': 'app_id/0.0.0 (Linux) SDK_ID', + 'User-Agent': 'app_id unknown (Linux) SDK_ID platform_id unknown', 'Content-Type': 'application/json', 'accept': 'application/json', } diff --git a/tests/collective_insights/test_collective_insights.py b/tests/collective_insights/test_collective_insights.py index 44e4c666..a3d5e563 100644 --- a/tests/collective_insights/test_collective_insights.py +++ b/tests/collective_insights/test_collective_insights.py @@ -87,7 +87,7 @@ def test_submit_raise_CollectiveInsightsError_2( with pytest.raises(CollectiveInsightsError): ci.submit(insight) - @pytest.mark.parametrize('value', [['uhash:1234'], None, ['a', 'b']], ids=str) + @pytest.mark.parametrize('value', [['uhash:1234'], None, ['a', 'b']], ids=lambda v: str(v)) def test_prepare_ci_request_organization_ids( self, value, diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 3acde1dc..dc4f4e4f 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -74,7 +74,7 @@ def test_config_from_json(self, tmp_path): def test_config_from_dotenv(self, tmp_path): Config.reset_instance() path = tmp_path / '.env' - dotenv_data = """rf_client_retries = 2 + dotenv_data = """client_retries = 2 moise = 'moise' """ path.write_text(dotenv_data) diff --git a/tests/helpers/test_helpers.py b/tests/helpers/test_helpers.py index fe50f65c..5b7ce930 100644 --- a/tests/helpers/test_helpers.py +++ b/tests/helpers/test_helpers.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest -from freezegun import freeze_time from requests.exceptions import ( ConnectionError, # noqa: A004 ConnectTimeout, @@ -35,7 +34,7 @@ class Test_TimeHelpers: - @pytest.mark.parametrize('times', ['1h', '7D', '1m', '70M']) + @pytest.mark.parametrize('times', ['1h', '7D']) def test_rel_time_to_date(self, times): date = TimeHelpers.rel_time_to_date(times) match = bool(re.match(r'(^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$)', date)) @@ -60,8 +59,8 @@ def test_rel_time_to_date(self, times): ('30H', True), ('-300h', True), ('58H', True), - ('+1D', True), - ('+7d', True), + ('+1D', False), + ('+7d', False), ('1hour', False), ('1HOUR', False), ('tendays', False), @@ -76,42 +75,6 @@ def test_rel_time_to_date(self, times): def test_is_rel_time_valid(self, time, expected): assert TimeHelpers.is_rel_time_valid(time) is expected - def test_rel_time_to_date_start_time(self): - new_time = TimeHelpers.rel_time_to_date('1h', '2024-01-22 13:55:21') - assert new_time == '2024-01-22T12:55' - - @pytest.mark.parametrize( - ('rel_time', 'start', 'expected'), - [ - ('1h', '2022-12-31 00:00:00', '2022-12-30T23:00'), - ('-1h', '2022-12-31 00:00:00', '2022-12-30T23:00'), - ('+1h', '2022-12-31 23:00:00', '2023-01-01T00:00'), - ('+1d', '2023-12-31 23:20:00', '2024-01-01T23:20'), - ('+1m', '2023-12-31 23:20:00', '2023-12-31T23:21'), - ('1m', '2023-12-31 23:20:00', '2023-12-31T23:19'), - ], - ) - def test_rel_time_to_date_start_time_operations(self, rel_time, start, expected): - new_time = TimeHelpers.rel_time_to_date(rel_time, start) - assert new_time == expected - - @freeze_time('2026-01-01 12:00:00') - @pytest.mark.parametrize( - ('rel_time', 'expected'), - [ - ('1h', '2026-01-01T11:00'), - ('-1h', '2026-01-01T11:00'), - ('+1h', '2026-01-01T13:00'), - ('+1d', '2026-01-02T12:00'), - ('+1m', '2026-01-01T12:01'), - ('1m', '2026-01-01T11:59'), - ('-1m', '2026-01-01T11:59'), - ], - ) - def test_rel_time_to_date_operations(self, rel_time, expected): - new_time = TimeHelpers.rel_time_to_date(rel_time) - assert new_time == expected - @pytest.mark.parametrize( ('time', 'error'), [ @@ -129,7 +92,7 @@ def test_rel_time_to_date_raises_ValueError(self, time, error): with pytest.raises( error, match=re.escape( - f"Invalid relative time '{time}'. Accepted format: [-|+]?[integer][h|d|m]" + f"Invalid relative time '{time}'. Accepted format: [-|][integer][h|d]" ), ): TimeHelpers.rel_time_to_date(time) @@ -168,7 +131,7 @@ def test_is_valid_time_range(self, time_range, expected): class Test_OSHelper: def test_os_platform(self): - # Unfortunately, this will return a different value depending on the OS it runs on + # Unfortunatelly, this will return a different value depending on the OS it runs on # so can not be properly tested, hence we make sure it returns something os_info = OSHelpers.os_platform() diff --git a/tests/identity/test_identity.py b/tests/identity/test_identity.py index aab2d858..73e13c22 100644 --- a/tests/identity/test_identity.py +++ b/tests/identity/test_identity.py @@ -135,7 +135,7 @@ def test_search_credential_adt(self, identity_mgr: IdentityMgr, mocker, mock_req search = search1 + search2 search = set(search) assert len(search) == len(search1) - assert all(a == b for a, b in zip(search1, search2, strict=False)) + assert all(a == b for a, b in zip(search1, search2)) assert search1[0] > search1[1] assert ( str(search1[0]) diff --git a/tests/links/conftest.py b/tests/links/conftest.py new file mode 100644 index 00000000..67f903c3 --- /dev/null +++ b/tests/links/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from psengine.links import LinksMgr + + +@pytest.fixture +def links_mgr(): + return LinksMgr() diff --git a/tests/links/test_links_mgr.py b/tests/links/test_links_mgr.py new file mode 100644 index 00000000..e9b67c61 --- /dev/null +++ b/tests/links/test_links_mgr.py @@ -0,0 +1,194 @@ +##################################### TERMS OF USE ########################################### +# The following code is provided for demonstration purpose only, and should not be used # +# without independent verification. Recorded Future makes no representations or warranties, # +# express, implied, statutory, or otherwise, regarding any aspect of this code or of the # +# information it may retrieve, and provides it both strictly β€œas-is” and without assuming # +# responsibility for any information it may retrieve. Recorded Future shall not be liable # +# for, and you assume all risk of using, the foregoing. By using this code, Customer # +# represents that it is solely responsible for having all necessary licenses, permissions, # +# rights, and/or consents to connect to third party APIs, and that it is solely responsible # +# for having all necessary licenses, permissions, rights, and/or consents to any data # +# accessed from any third party API. # +############################################################################################## + +import pytest +from pydantic import ValidationError +from requests import Response +from requests.exceptions import HTTPError + +from psengine.links.errors import LinksMetadataError, LinksSearchError +from psengine.links.links import LinksFilterObjects, LinksLimitsObjects +from psengine.links.models import ( + CriticalityAttribute, + GenericAttribute, + MitreNameAttribute, + RiskAttribute, + ThreatActorAttribute, +) + + +def test_list_sections(links_mgr, mocker, make_response): + mock_data = { + 'data': [ + {'id': 's1', 'name': 'Section 1', 'description': 'Desc 1'}, + {'id': 's2', 'name': 'Section 2', 'description': 'Desc 2'}, + ] + } + mocker.patch.object(links_mgr.rf_client, 'request', return_value=make_response(mock_data)) + + sections = links_mgr.list_sections() + assert len(sections) == 2 + assert sections[0].id_ == 's1' + assert sections[1].name == 'Section 2' + + +def test_list_events(links_mgr, mocker, make_response): + mock_data = { + 'data': [ + {'id': 'e1', 'name': 'Event 1', 'description': 'Desc 1'}, + {'id': 'e2', 'name': 'Event 2', 'description': 'Desc 2'}, + ] + } + mocker.patch.object(links_mgr.rf_client, 'request', return_value=make_response(mock_data)) + + events = links_mgr.list_events() + assert len(events) == 2 + assert events[0].id_ == 'e1' + assert events[1].name == 'Event 2' + + +def test_list_entity_types(links_mgr, mocker, make_response): + mock_data = { + 'data': [ + {'id': 'Type1', 'name': 'Type 1'}, + {'id': 'Type2', 'name': 'Type 2'}, + ] + } + mocker.patch.object(links_mgr.rf_client, 'request', return_value=make_response(mock_data)) + + entity_types = links_mgr.list_entity_types() + assert len(entity_types) == 2 + assert entity_types[0].id_ == 'Type1' + assert entity_types[1].name == 'Type 2' + + +def test_search_basic(links_mgr, mocker, make_response): + mock_data = { + 'data': [ + { + 'entity': {'id': 'ent1', 'name': 'Entity 1', 'type': 'Type1'}, + 'links': [ + { + 'id': 'link1', + 'name': 'Link 1', + 'type': 'Type2', + 'source': 'technical', + 'section': 's1', + 'attributes': [{'id': 'risk_score', 'value': 50}], + } + ], + } + ] + } + mocker.patch.object(links_mgr.rf_client, 'request', return_value=make_response(mock_data)) + + results = links_mgr.search(entities=['ent1']) + assert len(results.data) == 1 + assert results.data[0].entity.id_ == 'ent1' + assert len(results.data[0].links) == 1 + assert results.data[0].links[0].id_ == 'link1' + + +def test_filter_objects_invalid_source(): + with pytest.raises(ValidationError, match='sources'): + LinksFilterObjects(sources=['invalid_source']) + + +def test_metadata_error(links_mgr, mocker): + mock_resp = mocker.Mock(spec=Response) + mock_resp.status_code = 500 + mock_resp.text = 'Internal Server Error' + mocker.patch.object( + links_mgr.rf_client, + 'request', + side_effect=HTTPError('500 Server Error', response=mock_resp), + ) + + with pytest.raises(LinksMetadataError, match='500'): + links_mgr.list_sections() + + +def test_search_error(links_mgr, mocker): + mock_resp = mocker.Mock(spec=Response) + mock_resp.status_code = 400 + mock_resp.text = 'Bad Request' + mocker.patch.object( + links_mgr.rf_client, + 'request', + side_effect=HTTPError('400 Client Error', response=mock_resp), + ) + + with pytest.raises(LinksSearchError, match='400'): + links_mgr.search(entities=['ent1']) + + +def test_search_complex_attributes(links_mgr, mocker, make_response): + mock_data = { + 'data': [ + { + 'entity': {'id': 'ent1', 'name': 'Entity 1', 'type': 'Type1'}, + 'links': [ + { + 'id': 'link1', + 'name': 'Link 1', + 'type': 'Type2', + 'attributes': [ + {'id': 'risk_score', 'value': 75.0}, + {'id': 'risk_level', 'value': 'High'}, + {'id': 'criticality', 'value': 'Critical'}, + {'id': 'display_name', 'value': 'T1234'}, + {'id': 'threat_actor', 'value': True}, + {'id': 'unknown_attr', 'value': 'some value'}, + ], + } + ], + } + ] + } + mocker.patch.object(links_mgr.rf_client, 'request', return_value=make_response(mock_data)) + + results = links_mgr.search(entities=['ent1']) + attrs = results.data[0].links[0].attributes + assert len(attrs) == 6 + + assert isinstance(attrs[0], RiskAttribute) + assert attrs[0].id_ == 'risk_score' + assert attrs[0].value == 75.0 + + assert isinstance(attrs[1], RiskAttribute) + assert attrs[1].id_ == 'risk_level' + assert attrs[1].value == 'High' + + assert isinstance(attrs[2], CriticalityAttribute) + assert attrs[2].value == 'Critical' + + assert isinstance(attrs[3], MitreNameAttribute) + assert attrs[3].value == 'T1234' + + assert isinstance(attrs[4], ThreatActorAttribute) + assert attrs[4].value is True + + assert isinstance(attrs[5], GenericAttribute) + assert attrs[5].id_ == 'unknown_attr' + assert attrs[5].value == 'some value' + + +def test_search_with_limits(links_mgr, mocker, make_response): + limits = LinksLimitsObjects(search_scope='small', per_entity_type=10) + mocker.patch.object(links_mgr.rf_client, 'request', return_value=make_response({'data': []})) + + links_mgr.search(entities=['ent1'], limits=limits) + + _, kwargs = links_mgr.rf_client.request.call_args + assert kwargs['data']['limits']['search_scope'] == 'small' + assert kwargs['data']['limits']['per_entity_type'] == 10 diff --git a/psengine/threat_maps/__init__.py b/tests/links/test_links_models.py similarity index 73% rename from psengine/threat_maps/__init__.py rename to tests/links/test_links_models.py index b206567b..eafdca0c 100644 --- a/psengine/threat_maps/__init__.py +++ b/tests/links/test_links_models.py @@ -11,20 +11,17 @@ # accessed from any third party API. # ############################################################################################## -from .errors import ( - ThreatActorSearchError, - ThreatMapCategoriesError, - ThreatMapFetchError, - ThreatMapInfoError, - ThreatMapsError, -) -from .threat_map import ( - EntityCategory, - ThreatActorAttributes, - ThreatActorProfile, - ThreatMap, - ThreatMapEntity, - ThreatMapFetchIn, - ThreatMapInfo, -) -from .threat_map_mgr import ThreatMapMgr +import pytest +from pydantic import ValidationError + +from psengine.links.links import FilterTechnical, LinksSearchIn + + +def test_filter_technical_timeframe_invalid_format(): + with pytest.raises(ValidationError, match='Invalid relative time'): + FilterTechnical(timeframe='not-a-time') + + +def test_links_search_in_entities_required(): + with pytest.raises(ValidationError, match='entities'): + LinksSearchIn() diff --git a/tests/logger/test_logger.py b/tests/logger/test_logger.py index 86cae116..a1e485b1 100644 --- a/tests/logger/test_logger.py +++ b/tests/logger/test_logger.py @@ -134,7 +134,7 @@ def test_log_setlevel(self, capfd, rf_logger): 'level must be one of: NOTSET, DEBUG, INFO, WARNING, ERROR, CRITICAL', ), ('50', ValueError, 'level must be one of: NOTSET, DEBUG, INFO, WARNING, ERROR, CRITICAL'), - (['list'], TypeError, 'level must be a string or int'), + (['list'], TypeError, ''), (999, ValueError, 'level must be one of: NOTSET, DEBUG, INFO, WARNING, ERROR, CRITICAL'), ] diff --git a/tests/malware_intel/conftest.py b/tests/malware_intel/conftest.py index 8d526fb1..8b0f543f 100644 --- a/tests/malware_intel/conftest.py +++ b/tests/malware_intel/conftest.py @@ -1,18 +1,8 @@ import pytest -from psengine.malware_intel import AutoSigmaMgr, AutoYaraMgr, MalwareIntelMgr +from psengine.malware_intel import MalwareIntelMgr @pytest.fixture def mal_mgr(): return MalwareIntelMgr() - - -@pytest.fixture -def auto_yara_mgr(): - return AutoYaraMgr() - - -@pytest.fixture -def auto_sigma_mgr(): - return AutoSigmaMgr() diff --git a/tests/malware_intel/mocks/auto_sigma_job.json b/tests/malware_intel/mocks/auto_sigma_job.json deleted file mode 100644 index e7240544..00000000 --- a/tests/malware_intel/mocks/auto_sigma_job.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "job_id": "auto-sigma-job-1", - "name": "WannaCry Sigma Monitoring", - "created": "2025-03-05T12:34:56Z", - "modified": "2025-03-05T13:00:00Z", - "status": "COMPLETED", - "query": "sample.tags == \"family:wannacry\"", - "start_date": "2025-03-01", - "end_date": "2025-03-05", - "n_matched_hashes": 2, - "family_counts": { - "family:wannacry": 2 - }, - "sigma_rules": [ - { - "rule": "title: Suspicious WannaCry Command\ndetection:\n selection:\n CommandLine|contains: whoami\n condition: selection", - "rule_id": "sigma-rule-1", - "stats": { - "n_hashes": 2, - "overlap": 2, - "family_counts": { - "family:wannacry": 2 - } - }, - "status": "New", - "modified": "2025-03-05T13:00:00Z" - } - ], - "patterns": [ - { - "image": "C:\\Windows\\System32\\cmd.exe", - "cmd_pattern": "cmd.exe /c *", - "matched_cmds": [ - "cmd.exe /c whoami" - ], - "stats": { - "n_hashes": 2, - "overlap": 2, - "family_counts": { - "family:wannacry": 2 - } - } - } - ] -} diff --git a/tests/malware_intel/mocks/auto_sigma_jobs.json b/tests/malware_intel/mocks/auto_sigma_jobs.json deleted file mode 100644 index aeb8b1ce..00000000 --- a/tests/malware_intel/mocks/auto_sigma_jobs.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "jobs": [ - { - "jobId": "auto-sigma-job-1", - "name": "WannaCry Sigma Monitoring", - "created": "2025-03-05T12:34:56Z", - "modified": "2025-03-05T13:00:00Z", - "status": "FINISHED", - "query": "sample.tags == \"family:wannacry\"", - "startDate": "2025-03-01", - "endDate": "2025-03-05", - "nMatchedHashes": 2, - "familyCounts": { - "family:wannacry": 2 - } - }, - { - "jobId": "auto-sigma-job-2", - "name": "Emotet Sigma Monitoring", - "created": "2025-03-06T08:15:30Z", - "modified": "2025-03-06T08:15:30Z", - "status": "CREATED", - "query": "sample.tags == \"family:emotet\"", - "startDate": "2025-03-01", - "endDate": "2025-03-06", - "nMatchedHashes": 1, - "familyCounts": { - "family:emotet": 1 - } - } - ] -} diff --git a/tests/malware_intel/mocks/auto_yara_job.json b/tests/malware_intel/mocks/auto_yara_job.json deleted file mode 100644 index 04f3a050..00000000 --- a/tests/malware_intel/mocks/auto_yara_job.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "job": { - "coverage": { - "covered_hashes": [ - "ed01ebfbc9eb5bbea545af4d01bf5f1071661840480439c6e5babe8e080e41aa", - "be22645c61949ad6a077373a7d6cd85e3fae44315632f161adc4c99d5a8e6844" - ], - "uncovered_hashes": [ - "892f11af94dea87bc8a85acdb092c74541b0ab63c8fcc1823ba7987c82c6e9ba" - ] - }, - "created": "2025-03-05T12:34:56Z", - "job_id": "auto-yara-job-1", - "name": "WannaCry Monitoring", - "patterns": [ - { - "ascii": [ - "$family_name", - "$mutex" - ], - "matching_hashes": [ - "ed01ebfbc9eb5bbea545af4d01bf5f1071661840480439c6e5babe8e080e41aa", - "be22645c61949ad6a077373a7d6cd85e3fae44315632f161adc4c99d5a8e6844" - ], - "pattern": "$family_name = \"WannaCry\" ascii" - } - ], - "query": "sample.tags == \"family:wannacry\"", - "status": "FINISHED", - "yara_rule_str": "rule WannaCry_Monitoring { condition: any of them }" - } -} diff --git a/tests/malware_intel/mocks/auto_yara_jobs.json b/tests/malware_intel/mocks/auto_yara_jobs.json deleted file mode 100644 index 55629ff7..00000000 --- a/tests/malware_intel/mocks/auto_yara_jobs.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "jobs": [ - { - "coverage": { - "covered_hashes": [ - "ed01ebfbc9eb5bbea545af4d01bf5f1071661840480439c6e5babe8e080e41aa", - "be22645c61949ad6a077373a7d6cd85e3fae44315632f161adc4c99d5a8e6844" - ], - "uncovered_hashes": [ - "892f11af94dea87bc8a85acdb092c74541b0ab63c8fcc1823ba7987c82c6e9ba" - ] - }, - "created": "2025-03-05T12:34:56Z", - "job_id": "auto-yara-job-1", - "name": "WannaCry Monitoring", - "patterns": [ - { - "ascii": [ - "$family_name", - "$mutex" - ], - "matching_hashes": [ - "ed01ebfbc9eb5bbea545af4d01bf5f1071661840480439c6e5babe8e080e41aa", - "be22645c61949ad6a077373a7d6cd85e3fae44315632f161adc4c99d5a8e6844" - ], - "pattern": "$family_name = \"WannaCry\" ascii" - } - ], - "query": "sample.tags == \"family:wannacry\"", - "status": "FINISHED", - "yara_rule_str": "rule WannaCry_Monitoring { condition: any of them }" - }, - { - "coverage": { - "covered_hashes": [], - "uncovered_hashes": [ - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - ] - }, - "created": "2025-03-06T08:15:30Z", - "job_id": "auto-yara-job-2", - "name": "Emotet Monitoring", - "patterns": [], - "query": "sample.tags == \"family:emotet\"", - "status": "CREATED" - } - ] -} diff --git a/tests/malware_intel/test_auto_sigma.py b/tests/malware_intel/test_auto_sigma.py deleted file mode 100644 index 02dc9ed0..00000000 --- a/tests/malware_intel/test_auto_sigma.py +++ /dev/null @@ -1,280 +0,0 @@ -from pathlib import Path - -import pytest -from pydantic import ValidationError - -from psengine.malware_intel.auto_sigma_mgr import ( - AutoSigmaJobCreateOut, - AutoSigmaJobDeleteOut, - AutoSigmaJobEditOut, - AutoSigmaJobOut, - AutoSigmaJobRetryOut, - AutoSigmaJobsOut, - AutoSigmaMgr, -) -from psengine.malware_intel.constants import JOB_POOL_INTERTVAL_SECONDS -from psengine.malware_intel.errors import AutoSigmaFetchJobError -from psengine.malware_intel.models import ( - AutoSigmaRules, - SigmaPatternItem, - SigmaPatternStats, - SigmaRuleItem, -) - -MOCK_DIR = Path(__file__).parent / 'mocks' - -AUTO_SIGMA_JOBS = 'https://api.recordedfuture.com/malware-intelligence/v1/auto-sigma/jobs' -AUTO_SIGMA_GET_JOBS = 'https://api.recordedfuture.com/malware-intelligence/v1/auto-sigma/get_jobs' -JOB_ID = 'auto-sigma-job-1' -RULE_ID = 'sigma-rule-1' -QUERY = 'sample.tags == "family:wannacry"' -START_DATE = '2025-03-01' -END_DATE = '2025-03-05' -SIGMA_RULE = ( - 'title: Suspicious WannaCry Command\n' - 'detection:\n' - ' selection:\n' - ' CommandLine|contains: whoami\n' - ' condition: selection' -) -AUTO_SIGMA_CREATE_RESPONSE = {'job_id': 'auto-sigma-job-3'} -AUTO_SIGMA_DELETE_RESPONSE = {'deleted': True} -AUTO_SIGMA_RETRY_RESPONSE = {'retried': True} - - -class Test_AutoSigmaModels: - def test_validate_jobs_output(self, mock_request): - mock = mock_request(MOCK_DIR / 'auto_sigma_jobs.json') - - jobs = AutoSigmaJobsOut.model_validate(mock.json()) - - assert len(jobs.jobs) == 2 - assert all(isinstance(job, AutoSigmaRules) for job in jobs.jobs) - - job = jobs.jobs[0] - assert job.created.year == 2025 - assert job.job_id == JOB_ID - assert job.family_counts == {'family:wannacry': 2} - assert job.n_matched_hashes == 2 - assert ( - str(job) - == 'Name: WannaCry Sigma Monitoring, ID: auto-sigma-job-1, Created: 2025-03-05 12:34:56, Matched Hashes: 2' - ) - assert str(jobs).splitlines() == [str(job), str(jobs.jobs[1])] - - def test_validate_job_output(self, mock_request): - mock = mock_request(MOCK_DIR / 'auto_sigma_job.json') - - job = AutoSigmaJobOut.model_validate(mock.json()) - - assert job.name == 'WannaCry Sigma Monitoring' - assert job.status == 'COMPLETED' - assert len(job.sigma_rules) == 1 - assert len(job.patterns) == 1 - assert isinstance(job.sigma_rules[0], SigmaRuleItem) - assert isinstance(job.sigma_rules[0].stats, SigmaPatternStats) - assert isinstance(job.patterns[0], SigmaPatternItem) - assert job.sigma_rules[0].rule == SIGMA_RULE - assert job.sigma_rules[0].rule_id == RULE_ID - assert job.sigma_rules[0].status == 'New' - assert job.sigma_rules[0].stats.family_counts == {'family:wannacry': 2} - assert job.patterns[0].cmd_pattern == 'cmd.exe /c *' - assert job.patterns[0].matched_cmds == ['cmd.exe /c whoami'] - assert ( - str(job) - == 'Name: WannaCry Sigma Monitoring, ID: auto-sigma-job-1, Created: 2025-03-05 12:34:56, Matched Hashes: 2, Sigma Rules: 1, Patterns: 1' - ) - - def test_sigma_job_defaults(self): - job = AutoSigmaJobOut.model_validate( - { - 'job_id': 'auto-sigma-job-pending', - 'name': 'Pending Sigma Monitoring', - } - ) - - assert job.created is None - assert job.modified is None - assert job.status == 'CREATED' - assert job.family_counts == {} - assert job.sigma_rules == [] - assert job.patterns == [] - assert ( - str(job) - == 'Name: Pending Sigma Monitoring, ID: auto-sigma-job-pending, Created: N/A, Matched Hashes: 0, Sigma Rules: 0, Patterns: 0' - ) - - def test_jobs_output_defaults(self): - jobs = AutoSigmaJobsOut.model_validate({}) - - assert jobs.jobs == [] - - -class Test_AutoSigmaMgr: - def test_create_rule(self, auto_sigma_mgr: AutoSigmaMgr, mocker, make_response): - mock = make_response(AUTO_SIGMA_CREATE_RESPONSE) - mocked_request = mocker.patch.object(auto_sigma_mgr.rf_client, 'request', return_value=mock) - - result = auto_sigma_mgr.create_rule_job( - name='WannaCry Sigma Monitoring', - query=QUERY, - start_date=START_DATE, - end_date=END_DATE, - ) - - assert isinstance(result, AutoSigmaJobCreateOut) - assert result.job_id == 'auto-sigma-job-3' - assert mocked_request.call_args[0] == ( - 'post', - AUTO_SIGMA_JOBS, - { - 'name': 'WannaCry Sigma Monitoring', - 'query': QUERY, - 'start_date': START_DATE, - 'end_date': END_DATE, - }, - ) - - def test_create_rule_without_end_date( - self, auto_sigma_mgr: AutoSigmaMgr, mocker, make_response - ): - mock = make_response(AUTO_SIGMA_CREATE_RESPONSE) - mocked_request = mocker.patch.object(auto_sigma_mgr.rf_client, 'request', return_value=mock) - - result = auto_sigma_mgr.create_rule_job( - name='WannaCry Sigma Monitoring', - query=QUERY, - start_date=START_DATE, - ) - - assert isinstance(result, AutoSigmaJobCreateOut) - assert result.job_id == 'auto-sigma-job-3' - assert mocked_request.call_args[0] == ( - 'post', - AUTO_SIGMA_JOBS, - { - 'name': 'WannaCry Sigma Monitoring', - 'query': QUERY, - 'start_date': START_DATE, - }, - ) - - def test_fetch_rules(self, auto_sigma_mgr: AutoSigmaMgr, mocker, mock_request): - mock = mock_request(MOCK_DIR / 'auto_sigma_jobs.json') - mocked_request = mocker.patch.object(auto_sigma_mgr.rf_client, 'request', return_value=mock) - - jobs = auto_sigma_mgr.fetch_rule_jobs() - - assert isinstance(jobs, AutoSigmaJobsOut) - assert len(jobs.jobs) == 2 - assert jobs.jobs[0].job_id == JOB_ID - assert mocked_request.call_args[0] == ('post', AUTO_SIGMA_GET_JOBS) - assert mocked_request.call_args[1] == {'data': {'limit': 10}} - - def test_fetch_rule(self, auto_sigma_mgr: AutoSigmaMgr, mocker, mock_request): - mock = mock_request(MOCK_DIR / 'auto_sigma_job.json') - mocked_request = mocker.patch.object(auto_sigma_mgr.rf_client, 'request', return_value=mock) - - job = auto_sigma_mgr.fetch_rule_job_result(JOB_ID) - - assert isinstance(job, AutoSigmaJobOut) - assert job.job_id == JOB_ID - assert mocked_request.call_args[0] == ('get', f'{AUTO_SIGMA_JOBS}/{JOB_ID}') - - def test_fetch_rule_wait_until_finished( - self, auto_sigma_mgr: AutoSigmaMgr, mocker, make_response - ): - sleep_mock = mocker.patch('psengine.malware_intel.auto_sigma_mgr.time.sleep') - created = make_response( - {'job_id': JOB_ID, 'name': 'WannaCry Sigma Monitoring', 'status': 'CREATED'} - ) - running = make_response( - {'job_id': JOB_ID, 'name': 'WannaCry Sigma Monitoring', 'status': 'RUNNING'} - ) - finished = make_response( - {'job_id': JOB_ID, 'name': 'WannaCry Sigma Monitoring', 'status': 'FINISHED'} - ) - mocked_request = mocker.patch.object( - auto_sigma_mgr.rf_client, - 'request', - side_effect=[created, running, finished], - ) - - job = auto_sigma_mgr.fetch_rule_job_result(JOB_ID, wait_until_finished=True) - - assert isinstance(job, AutoSigmaJobOut) - assert job.status == 'FINISHED' - assert mocked_request.call_count == 3 - assert sleep_mock.call_count == 2 - sleep_mock.assert_called_with(JOB_POOL_INTERTVAL_SECONDS) - - def test_fetch_rule_wait_until_finished_failed_raises( - self, auto_sigma_mgr: AutoSigmaMgr, mocker, make_response - ): - mock = make_response( - {'job_id': JOB_ID, 'name': 'WannaCry Sigma Monitoring', 'status': 'FAILED'} - ) - mocked_request = mocker.patch.object(auto_sigma_mgr.rf_client, 'request', return_value=mock) - - with pytest.raises( - AutoSigmaFetchJobError, - match='failed while waiting for FINISHED status', - ): - auto_sigma_mgr.fetch_rule_job_result(JOB_ID, wait_until_finished=True) - - assert mocked_request.call_count == 1 - - def test_edit_rule(self, auto_sigma_mgr: AutoSigmaMgr, mocker, make_response): - mock = make_response(True) - mocked_request = mocker.patch.object(auto_sigma_mgr.rf_client, 'request', return_value=mock) - - result = auto_sigma_mgr.edit_rule_str(JOB_ID, RULE_ID, status='False Positive') - - assert isinstance(result, AutoSigmaJobEditOut) - assert result.job_id == JOB_ID - assert result.rule_id == RULE_ID - assert result.updated is True - assert mocked_request.call_args[0] == ( - 'post', - f'{AUTO_SIGMA_JOBS}/{JOB_ID}/{RULE_ID}', - {'status': 'False Positive'}, - ) - - def test_delete_rule(self, auto_sigma_mgr: AutoSigmaMgr, mocker, make_response): - mock = make_response(AUTO_SIGMA_DELETE_RESPONSE) - mocked_request = mocker.patch.object(auto_sigma_mgr.rf_client, 'request', return_value=mock) - - result = auto_sigma_mgr.delete_rule_job(JOB_ID) - - assert isinstance(result, AutoSigmaJobDeleteOut) - assert result.deleted is True - assert mocked_request.call_args[0] == ('delete', f'{AUTO_SIGMA_JOBS}/{JOB_ID}') - - def test_retry_rule_creation(self, auto_sigma_mgr: AutoSigmaMgr, mocker, make_response): - mock = make_response(AUTO_SIGMA_RETRY_RESPONSE) - mocked_request = mocker.patch.object(auto_sigma_mgr.rf_client, 'request', return_value=mock) - - result = auto_sigma_mgr.retry_failed_rule_job(JOB_ID) - - assert isinstance(result, AutoSigmaJobRetryOut) - assert result.retried is True - assert mocked_request.call_args[0] == ('post', f'{AUTO_SIGMA_JOBS}/{JOB_ID}/retry') - - @pytest.mark.parametrize( - ('method_name', 'args'), - [ - ('create_rule_job', (['not-a-string'], QUERY, START_DATE)), - ('create_rule_job', ('WannaCry Sigma Monitoring', QUERY, ['not-a-string'])), - ('fetch_rule_jobs', (['not-an-int'],)), - ('fetch_rule_job_result', (123,)), - ('fetch_rule_job_result', (JOB_ID, 'not-bool')), - ('edit_rule_str', (JOB_ID, RULE_ID, None, ['not-a-string'])), - ('delete_rule_job', (123,)), - ('retry_failed_rule_job', (123,)), - ], - ) - def test_methods_raise_validation_error(self, auto_sigma_mgr: AutoSigmaMgr, method_name, args): - method = getattr(auto_sigma_mgr, method_name) - - with pytest.raises(ValidationError): - method(*args) diff --git a/tests/malware_intel/test_auto_yara.py b/tests/malware_intel/test_auto_yara.py deleted file mode 100644 index 283b4270..00000000 --- a/tests/malware_intel/test_auto_yara.py +++ /dev/null @@ -1,239 +0,0 @@ -from pathlib import Path - -import pytest -from pydantic import ValidationError - -from psengine.malware_intel.auto_yara_mgr import ( - AutoYaraJobCreateOut, - AutoYaraJobDeleteOut, - AutoYaraJobEditOut, - AutoYaraJobOut, - AutoYaraJobRetryOut, - AutoYaraJobsOut, - AutoYaraMgr, -) -from psengine.malware_intel.errors import AutoYaraFetchJobError -from psengine.malware_intel.models import Coverage, YaraJob - -MOCK_DIR = Path(__file__).parent / 'mocks' - -AUTO_YARA_JOBS = 'https://api.recordedfuture.com/malware-intelligence/v1/auto-yara/jobs' -RULE_ID = 'auto-yara-job-1' -QUERY = 'sample.tags == "family:wannacry"' -HASHES = [ - 'ed01ebfbc9eb5bbea545af4d01bf5f1071661840480439c6e5babe8e080e41aa', - 'be22645c61949ad6a077373a7d6cd85e3fae44315632f161adc4c99d5a8e6844', -] -YARA_RULE = 'rule WannaCry_Monitoring { condition: any of them }' -AUTO_YARA_CREATE_RESPONSE = {'job_id': 'auto-yara-job-3'} -AUTO_YARA_EDIT_RESPONSE = {'job_id': RULE_ID} -AUTO_YARA_DELETE_RESPONSE = {'deleted': True} -AUTO_YARA_RETRY_RESPONSE = {'retried': True} - - -class Test_AutoYaraModels: - def test_validate_jobs_output(self, mock_request): - mock = mock_request(MOCK_DIR / 'auto_yara_jobs.json') - - jobs = AutoYaraJobsOut.model_validate(mock.json()) - - assert len(jobs.jobs) == 2 - assert all(isinstance(job, YaraJob) for job in jobs.jobs) - - job = jobs.jobs[0] - assert isinstance(job.coverage, Coverage) - assert job.created.year == 2025 - assert job.job_id == RULE_ID - assert job.patterns[0].ascii == ['$family_name', '$mutex'] - assert job.patterns[0].matching_hashes == HASHES - assert job.yara_rule_str == YARA_RULE - assert ( - str(job) - == 'ID: auto-yara-job-1, Status: FINISHED, Name: WannaCry Monitoring, Created: 2025-03-05 12:34:56, Covered Hashes: 2, Uncovered Hashes: 1' - ) - assert str(jobs).splitlines() == [str(job), str(jobs.jobs[1])] - - def test_validate_job_output(self, mock_request): - mock = mock_request(MOCK_DIR / 'auto_yara_job.json') - - job = AutoYaraJobOut.model_validate(mock.json()) - - assert job.job.name == 'WannaCry Monitoring' - assert job.job.status == 'FINISHED' - assert str(job) == str(job.job) - - def test_yara_job_defaults(self): - job = YaraJob.model_validate( - { - 'job_id': 'auto-yara-job-pending', - 'name': 'Pending Monitoring', - } - ) - - assert job.coverage is None - assert job.created is None - assert job.patterns == [] - assert job.status == 'CREATED' - assert ( - str(job) - == 'ID: auto-yara-job-pending, Status: CREATED, Name: Pending Monitoring, Created: N/A, Covered Hashes: 0, Uncovered Hashes: 0' - ) - - def test_jobs_output_defaults(self): - jobs = AutoYaraJobsOut.model_validate({}) - - assert jobs.jobs == [] - - -class Test_AutoYaraMgr: - def test_create_rule(self, auto_yara_mgr: AutoYaraMgr, mocker, make_response): - mock = make_response(AUTO_YARA_CREATE_RESPONSE) - mocked_request = mocker.patch.object(auto_yara_mgr.rf_client, 'request', return_value=mock) - - result = auto_yara_mgr.create_rule_job( - query=QUERY, hashes=HASHES, name='WannaCry Monitoring' - ) - - assert isinstance(result, AutoYaraJobCreateOut) - assert result.job_id == 'auto-yara-job-3' - assert mocked_request.call_args[0] == ( - 'post', - AUTO_YARA_JOBS, - {'hashes': HASHES, 'name': 'WannaCry Monitoring', 'query': QUERY}, - ) - - def test_create_rule_without_query(self, auto_yara_mgr: AutoYaraMgr, mocker, make_response): - mock = make_response(AUTO_YARA_CREATE_RESPONSE) - mocked_request = mocker.patch.object(auto_yara_mgr.rf_client, 'request', return_value=mock) - - result = auto_yara_mgr.create_rule_job(hashes=HASHES, name='WannaCry Monitoring') - - assert isinstance(result, AutoYaraJobCreateOut) - assert result.job_id == 'auto-yara-job-3' - assert mocked_request.call_args[0] == ( - 'post', - AUTO_YARA_JOBS, - {'hashes': HASHES, 'name': 'WannaCry Monitoring'}, - ) - - def test_fetch_rules(self, auto_yara_mgr: AutoYaraMgr, mocker, mock_request): - mock = mock_request(MOCK_DIR / 'auto_yara_jobs.json') - mocked_request = mocker.patch.object(auto_yara_mgr.rf_client, 'request', return_value=mock) - - jobs = auto_yara_mgr.fetch_rule_jobs() - - assert isinstance(jobs, AutoYaraJobsOut) - assert len(jobs.jobs) == 2 - assert jobs.jobs[0].job_id == RULE_ID - assert mocked_request.call_args[0] == ('get', AUTO_YARA_JOBS) - - def test_fetch_rule(self, auto_yara_mgr: AutoYaraMgr, mocker, mock_request): - mock = mock_request(MOCK_DIR / 'auto_yara_job.json') - mocked_request = mocker.patch.object(auto_yara_mgr.rf_client, 'request', return_value=mock) - - job = auto_yara_mgr.fetch_rule_job_result(RULE_ID) - - assert isinstance(job, AutoYaraJobOut) - assert job.job.job_id == RULE_ID - assert mocked_request.call_args[0] == ('get', f'{AUTO_YARA_JOBS}/{RULE_ID}') - assert mocked_request.call_args[1] == {} - - def test_fetch_rule_with_sanitize(self, auto_yara_mgr: AutoYaraMgr, mocker, mock_request): - mock = mock_request(MOCK_DIR / 'auto_yara_job.json') - mocked_request = mocker.patch.object(auto_yara_mgr.rf_client, 'request', return_value=mock) - - job = auto_yara_mgr.fetch_rule_job_result(RULE_ID, sanitize=True) - - assert isinstance(job, AutoYaraJobOut) - assert job.job.job_id == RULE_ID - assert mocked_request.call_args[0] == ('get', f'{AUTO_YARA_JOBS}/{RULE_ID}') - assert mocked_request.call_args[1] == {'params': {'sanitize': True}} - - def test_fetch_rule_wait_until_finished( - self, auto_yara_mgr: AutoYaraMgr, mocker, make_response - ): - mocker.patch('psengine.malware_intel.auto_yara_mgr.time.sleep') - created = make_response( - {'job': {'job_id': RULE_ID, 'name': 'WannaCry Monitoring', 'status': 'CREATED'}} - ) - running = make_response( - {'job': {'job_id': RULE_ID, 'name': 'WannaCry Monitoring', 'status': 'RUNNING'}} - ) - finished = make_response( - {'job': {'job_id': RULE_ID, 'name': 'WannaCry Monitoring', 'status': 'FINISHED'}} - ) - mocked_request = mocker.patch.object( - auto_yara_mgr.rf_client, - 'request', - side_effect=[created, running, finished], - ) - - job = auto_yara_mgr.fetch_rule_job_result(RULE_ID, wait_until_finished=True) - - assert isinstance(job, AutoYaraJobOut) - assert job.job.status == 'FINISHED' - assert mocked_request.call_count == 3 - - def test_fetch_rule_wait_until_finished_failed_raises( - self, auto_yara_mgr: AutoYaraMgr, mocker, make_response - ): - mock = make_response( - {'job': {'job_id': RULE_ID, 'name': 'WannaCry Monitoring', 'status': 'FAILED'}} - ) - mocked_request = mocker.patch.object(auto_yara_mgr.rf_client, 'request', return_value=mock) - - with pytest.raises(AutoYaraFetchJobError, match='failed while waiting for FINISHED status'): - auto_yara_mgr.fetch_rule_job_result(RULE_ID, wait_until_finished=True) - - assert mocked_request.call_count == 1 - - def test_edit_rule(self, auto_yara_mgr: AutoYaraMgr, mocker, make_response): - mock = make_response(AUTO_YARA_EDIT_RESPONSE) - mocked_request = mocker.patch.object(auto_yara_mgr.rf_client, 'request', return_value=mock) - - result = auto_yara_mgr.edit_rule_str(RULE_ID, YARA_RULE) - - assert isinstance(result, AutoYaraJobEditOut) - assert result.job_id == RULE_ID - assert mocked_request.call_args[0] == ( - 'post', - f'{AUTO_YARA_JOBS}/edit', - {'job_id': RULE_ID, 'yara_rule_str': YARA_RULE}, - ) - - def test_delete_rule(self, auto_yara_mgr: AutoYaraMgr, mocker, make_response): - mock = make_response(AUTO_YARA_DELETE_RESPONSE) - mocked_request = mocker.patch.object(auto_yara_mgr.rf_client, 'request', return_value=mock) - - result = auto_yara_mgr.delete_rule_job(RULE_ID) - - assert isinstance(result, AutoYaraJobDeleteOut) - assert result.deleted is True - assert mocked_request.call_args[0] == ('delete', f'{AUTO_YARA_JOBS}/{RULE_ID}') - - def test_retry_rule_creation(self, auto_yara_mgr: AutoYaraMgr, mocker, make_response): - mock = make_response(AUTO_YARA_RETRY_RESPONSE) - mocked_request = mocker.patch.object(auto_yara_mgr.rf_client, 'request', return_value=mock) - - result = auto_yara_mgr.retry_failed_rule_job(RULE_ID) - - assert isinstance(result, AutoYaraJobRetryOut) - assert result.retried is True - assert mocked_request.call_args[0] == ('post', f'{AUTO_YARA_JOBS}/{RULE_ID}/retry') - - @pytest.mark.parametrize( - ('method_name', 'args'), - [ - ('create_rule_job', ('not-a-list', 'WannaCry Monitoring')), - ('fetch_rule_job_result', (123,)), - ('fetch_rule_job_result', (RULE_ID, 'not-bool')), - ('edit_rule_str', (RULE_ID, ['not-a-string'])), - ('delete_rule_job', (123,)), - ('retry_failed_rule_job', (123,)), - ], - ) - def test_methods_raise_validation_error(self, auto_yara_mgr: AutoYaraMgr, method_name, args): - method = getattr(auto_yara_mgr, method_name) - - with pytest.raises(ValidationError): - method(*args) diff --git a/tests/malware_intel/test_malware_intel_helpers.py b/tests/malware_intel/test_malware_intel_helpers.py deleted file mode 100644 index 9d7eaf1d..00000000 --- a/tests/malware_intel/test_malware_intel_helpers.py +++ /dev/null @@ -1,41 +0,0 @@ -import json -from pathlib import Path - -from psengine.malware_intel.helpers import save_rules -from psengine.malware_intel.malware_intel import AutoSigmaJobOut, AutoYaraJobOut - -MOCK_DIR = Path(__file__).parent / 'mocks' - - -def test_save_auto_yara_rule(tmp_path): - auto_yara_job = AutoYaraJobOut.model_validate_json( - (MOCK_DIR / 'auto_yara_job.json').read_text() - ) - - save_rules(auto_yara_job, tmp_path) - - files = list(tmp_path.glob('*')) - assert len(files) == 1 - assert files[0].name == 'WannaCry Monitoring.yar' - assert files[0].read_text() == 'rule WannaCry_Monitoring { condition: any of them }' - - -def test_save_auto_sigma_rules(tmp_path): - payload = json.loads((MOCK_DIR / 'auto_sigma_job.json').read_text()) - payload['sigma_rules'].append( - { - 'rule': 'title: Another test rule', - 'rule_id': 'sigma-rule-2', - 'status': 'New', - } - ) - auto_sigma_job = AutoSigmaJobOut.model_validate(payload) - - save_rules(auto_sigma_job, tmp_path) - - files = sorted(tmp_path.glob('*')) - assert len(files) == 2 - assert files[0].name == 'WannaCry Sigma Monitoring - Rule 1.yml' - assert files[1].name == 'WannaCry Sigma Monitoring - Rule 2.yml' - assert files[0].read_text().startswith('title: Suspicious WannaCry Command') - assert files[1].read_text() == 'title: Another test rule' diff --git a/tests/playbook_alerts/test_helper.py b/tests/playbook_alerts/test_helper.py deleted file mode 100644 index ee77e8f3..00000000 --- a/tests/playbook_alerts/test_helper.py +++ /dev/null @@ -1,52 +0,0 @@ -import pytest - -from psengine.playbook_alerts import PACategory -from psengine.playbook_alerts.helpers import save_pba_images - - -def _store_first_image(alert, image_bytes: bytes): - image_id = alert.image_ids[0] - alert.store_image(image_id, image_bytes) - return alert - - -def test_save_pba_images_supports_geopol(alerts_factory, tmp_path): - geopol_alert = _store_first_image( - alerts_factory(PACategory.GEOPOLITICS_FACILITY.value)[0], - b'geopol-image', - ) - - save_pba_images(geopol_alert, tmp_path) - - saved_images = list(tmp_path.glob('*.png')) - assert len(saved_images) == 1 - assert saved_images[0].read_bytes() == b'geopol-image' - - -def test_save_pba_images_supports_image_alert_list(alerts_factory, tmp_path): - domain_alert = _store_first_image( - next(alert for alert in alerts_factory(PACategory.DOMAIN_ABUSE.value) if alert.image_ids), - b'domain-image', - ) - geopol_alert = _store_first_image( - alerts_factory(PACategory.GEOPOLITICS_FACILITY.value)[0], - b'geopol-image', - ) - - save_pba_images([domain_alert, geopol_alert], tmp_path) - - assert {saved_image.read_bytes() for saved_image in tmp_path.glob('*.png')} == { - b'domain-image', - b'geopol-image', - } - - -def test_save_pba_images_rejects_mixed_unsupported_alert_list(alerts_factory, tmp_path): - geopol_alert = _store_first_image( - alerts_factory(PACategory.GEOPOLITICS_FACILITY.value)[0], - b'geopol-image', - ) - identity_alert = alerts_factory(PACategory.IDENTITY_NOVEL_EXPOSURES.value)[0] - - with pytest.raises(TypeError, match='Image saving is only supported'): - save_pba_images([geopol_alert, identity_alert], tmp_path) diff --git a/tests/playbook_alerts/test_pba_mgr.py b/tests/playbook_alerts/test_pba_mgr.py index ba4eda1c..59f4a1f4 100644 --- a/tests/playbook_alerts/test_pba_mgr.py +++ b/tests/playbook_alerts/test_pba_mgr.py @@ -460,7 +460,6 @@ def test_prepare_query(selc, playbook_mgr: PlaybookAlertMgr): max_results=2, statuses=['New'], priority=['High', 'Informational'], - organisation=['moise', 'ernest'], direction='asc', category=['code_repo_leakage'], created_from='2023-01-01', @@ -474,7 +473,6 @@ def test_prepare_query(selc, playbook_mgr: PlaybookAlertMgr): assert query.statuses == ['New'] assert query.priority == ['High', 'Informational'] assert query.direction == 'asc' - assert query.organisation == ['uhash:moise', 'uhash:ernest'] assert query.category == ['code_repo_leakage'] assert query.created_range.from_.strftime('%Y-%m-%d') == '2023-01-01' assert query.created_range.until.strftime('%Y-%m-%d') == '2023-01-02' @@ -482,7 +480,9 @@ def test_prepare_query(selc, playbook_mgr: PlaybookAlertMgr): assert query.updated_range.until.strftime('%Y-%m-%d') == '2023-01-02' query = playbook_mgr._prepare_query( - statuses='New', priority='High', category='domain_abuse', organisation='moise' + statuses='New', + priority='High', + category='domain_abuse', ) assert isinstance(query, SearchIn) @@ -490,7 +490,6 @@ def test_prepare_query(selc, playbook_mgr: PlaybookAlertMgr): assert query.statuses == ['New'] assert query.priority == ['High'] assert query.category == ['domain_abuse'] - assert query.organisation == ['uhash:moise'] assert query.from_ is None assert query.created_range is None assert query.updated_range is None diff --git a/tests/threat_maps/conftest.py b/tests/threat_maps/conftest.py deleted file mode 100644 index 364789eb..00000000 --- a/tests/threat_maps/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -from pathlib import Path - -import pytest - -from psengine.threat_maps import ThreatMapMgr - -MOCK_DIR = Path(__file__).parent / 'mocks' - - -@pytest.fixture -def threat_map_mgr(): - return ThreatMapMgr() diff --git a/tests/threat_maps/mocks/test_fetch_available_maps.json b/tests/threat_maps/mocks/test_fetch_available_maps.json deleted file mode 100644 index 5c0cca9d..00000000 --- a/tests/threat_maps/mocks/test_fetch_available_maps.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "data": [ - { - "name": "Ports are created with the built-in function open_port.", - "type": "actors", - "organization": { - "id": "uhash:iNEFW7goC9", - "name": "Cheese Curds" - }, - "url": "/example/path/uhash:iNEFW7goC9/done" - }, - { - "name": "Do you have any idea why this is not working?", - "type": "malware", - "organization": { - "id": "uhash:SNAF40BpQO6sl3h", - "name": "Hoppin' John" - }, - "url": "/example/path/uhash:SNAF40BpQO6sl3h/done" - } - ] -} \ No newline at end of file diff --git a/tests/threat_maps/mocks/test_fetch_categories.json b/tests/threat_maps/mocks/test_fetch_categories.json deleted file mode 100644 index 0cb425ef..00000000 --- a/tests/threat_maps/mocks/test_fetch_categories.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "data": [ - { - "id": "PUHS5MwXDFi4hkp", - "type": "MitreAttackIdentifier", - "attributes": { - "name": "Fortune cookie" - } - }, - { - "id": "byP4TExmpjts5EU", - "type": "MitreAttackIdentifier", - "attributes": { - "name": "Calf's liver and bacon" - } - }, - { - "id": "G9KA0", - "type": "AttackVector", - "attributes": { - "name": "Turducken", - "alias": [ - "Fajitas", - "Milk toast", - "Spanish rice" - ] - } - } - ], - "counts": { - "returned": 3, - "total": 34 - } -} \ No newline at end of file diff --git a/tests/threat_maps/mocks/test_search_threat_actors.json b/tests/threat_maps/mocks/test_search_threat_actors.json deleted file mode 100644 index 897664f7..00000000 --- a/tests/threat_maps/mocks/test_search_threat_actors.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "data": [ - { - "id": "3rOA", - "type": "Organization", - "attributes": { - "name": "Pepperoni Pizza", - "common_names": [], - "alias": [ - "St. Paul Sandwich", - "Mozzarella sticks", - "Corn flakes" - ], - "categories": [] - } - }, - { - "id": "1YXDWKRnHA", - "type": "Person", - "attributes": { - "name": "yara", - "common_names": [], - "alias": [ - "Fortune cookie" - ], - "categories": [] - } - }, - { - "id": "DE5DoRw5wfb", - "type": "Organization", - "attributes": { - "name": "Al-Qaeda", - "common_names": [], - "alias": [ - "Chicago Hot Dog", - "Maine Lobster" - ], - "categories": [] - } - } - ], - "counts": { - "returned": 3, - "total": 1476948 - }, - "next_offset": "eyJvZmZzZXQiOlszMjU5MzQ5OCwiQl9GRFQiXX0=" -} \ No newline at end of file diff --git a/tests/threat_maps/mocks/test_validate_threat_map_actors.json b/tests/threat_maps/mocks/test_validate_threat_map_actors.json deleted file mode 100644 index 72fb71e0..00000000 --- a/tests/threat_maps/mocks/test_validate_threat_map_actors.json +++ /dev/null @@ -1,137 +0,0 @@ -{ - "data": { - "threat_map": [ - { - "id": "SnlLDxR23K", - "name": "Hot Chicken sandwich", - "alias": [ - "Hot Chicken sandwich" - ], - "categories": [ - { - "id": "6eJ9PYwFOYKnJ5V", - "name": "Chicago Hot Dog" - }, - { - "id": "fFgW3qRS1FYHTiW", - "name": "Threat Actor" - } - ], - "intent": 49, - "opportunity": 10, - "log_entries": [ - { - "watchlist": { - "id": "rHG6vaZ5aard4rwY", - "name": "Milk toast" - }, - "entity": { - "id": "6xYDWb", - "name": "Alice Springs Chicken" - }, - "severity": 2, - "axis": "intent", - "date": "2025-12-02T00:00:00.000Z" - } - ] - }, - { - "id": "0HSELhIyAyNMLWk", - "name": "Clam chowder", - "alias": [ - "Fajitas", - "Poke", - "Tres Leches Cake" - ], - "categories": [ - { - "id": "xHAHn9nvThfVq", - "name": "Pumpkin pie" - } - ], - "intent": 74, - "opportunity": 25, - "log_entries": [ - { - "entity": { - "id": "ohnRZ0", - "name": "Mulligan stew" - }, - "severity": 2, - "axis": "capability", - "date": "2023-05-01T00:00:00.000Z" - } - ] - }, - { - "id": "U654lpE2Qd", - "name": "Caviar", - "alias": [], - "categories": [ - { - "id": "ZJcvmd", - "name": "Mini pizzas" - }, - { - "id": "V60UyK", - "name": "Kung Pao Chicken" - } - ], - "intent": 40, - "opportunity": 24, - "log_entries": [ - { - "watchlist": { - "id": "wjefbouvbi", - "name": "Ice Cream Sundae" - }, - "entity": { - "id": "skzulr", - "name": "Oysters Rockefeller" - }, - "severity": 1, - "axis": "capability", - "date": "2026-04-03T00:00:00.000Z" - } - ] - }, - { - "id": "XFjYSA", - "name": "Hot chicken", - "alias": [], - "categories": [ - { - "id": "RBxL5D", - "name": "Mozzarella Sticks" - }, - { - "id": "CIxkFT69ty", - "name": "Underground Forum Member" - }, - { - "id": "HiLbjp", - "name": "Milkshake" - } - ], - "intent": 32, - "opportunity": 5, - "log_entries": [ - { - "watchlist": { - "id": "hYeuv73yD4p0Utk", - "name": "Sweet Potato Fries" - }, - "entity": { - "id": "sDvVbwKv2EjlacG", - "name": "Chicken sandwich" - }, - "severity": 1, - "axis": "capability", - "date": "2025-09-24T00:00:00.000Z" - } - ] - } - ], - "date": "2026-04-29T18:12:08.371Z" - } -} \ No newline at end of file diff --git a/tests/threat_maps/mocks/test_validate_threat_map_malware.json b/tests/threat_maps/mocks/test_validate_threat_map_malware.json deleted file mode 100644 index 2df954ee..00000000 --- a/tests/threat_maps/mocks/test_validate_threat_map_malware.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "data": { - "threat_map": [ - { - "id": "hatPB7bRFO", - "name": "Carolina style", - "alias": [], - "categories": [], - "prevalence": 75, - "opportunity": 75, - "log_entries": [ - { - "entity": { - "id": "PXsC3D", - "name": "Carolina style" - }, - "severity": 4, - "axis": "intent", - "date": "2026-04-06T00:00:00.000Z" - }, - { - "entity": { - "id": "PXsC3D", - "name": "Carolina style" - }, - "severity": 4, - "axis": "capability", - "date": "2026-04-06T00:00:00.000Z" - } - ] - }, - { - "id": "oJwdJdXeHn", - "name": "Erlang", - "alias": [], - "categories": [], - "prevalence": 75, - "opportunity": 75, - "log_entries": [ - { - "entity": { - "id": "fjNyBcX0jrdGCiT", - "name": "Sour cream" - }, - "severity": 4, - "axis": "intent", - "date": "2026-04-25T00:00:00.000Z" - } - ] - }, - { - "id": "8xZW3", - "name": "Fajita", - "alias": [], - "categories": [], - "prevalence": 75, - "opportunity": 75, - "log_entries": [ - { - "entity": { - "id": "75eaZg", - "name": "Goulash" - }, - "severity": 4, - "axis": "intent", - "date": "2026-04-05T00:00:00.000Z" - }, - { - "entity": { - "id": "75eaZg", - "name": "Goulash" - }, - "severity": 4, - "axis": "capability", - "date": "2026-04-05T00:00:00.000Z" - } - ] - }, - { - "id": "Ro3clHkRIFCosQo", - "name": "Carne pizzaiola", - "alias": [ - "Philadelphia cheesesteak", - "Low Country Boil", - "Chicken nugget", - "Osso Buco" - ], - "categories": [ - { - "id": "Ra38j", - "name": "Chicken Biryani" - }, - { - "id": "cEbp13LK6d", - "name": "sigma" - } - ], - "prevalence": 50, - "opportunity": 50, - "log_entries": [ - { - "entity": { - "id": "KmakZV", - "name": "Simda" - }, - "severity": 3, - "axis": "intent", - "date": "2026-03-28T00:00:00.000Z" - } - ] - } - ], - "date": "2026-04-29T18:12:46.840Z" - } -} \ No newline at end of file diff --git a/tests/threat_maps/test_threat_map.py b/tests/threat_maps/test_threat_map.py deleted file mode 100644 index 0919474f..00000000 --- a/tests/threat_maps/test_threat_map.py +++ /dev/null @@ -1,145 +0,0 @@ -from datetime import datetime - -import pytest - -from psengine.threat_maps import ( - EntityCategory, - ThreatActorProfile, - ThreatMap, - ThreatMapEntity, - ThreatMapFetchIn, - ThreatMapInfo, - ThreatMapMgr, -) -from psengine.threat_maps.models import ThreatMapType -from tests.threat_maps.conftest import MOCK_DIR - - -class Test_ThreatMap_Models: - @pytest.mark.parametrize( - 'map_type', - ['actors', 'malware'], - ) - def test_validate_threat_maps( - self, threat_map_mgr: ThreatMapMgr, map_type, mocker, mock_request - ): - mocks = [mock_request(MOCK_DIR / f'test_validate_threat_map_{map_type}.json')] - mocker.patch.object(threat_map_mgr.rf_client, 'request', side_effect=mocks) - - threat_map = threat_map_mgr.fetch_map(map_type=map_type) - ThreatMap.model_validate(threat_map) - - def test_validate_threat_map_info(self): - payload = { - 'name': 'name', - 'type': 'actor', - 'organization': { - 'id': 'org_id', - 'name': 'org_name', - }, - 'url': 'https://example.com/threat-maps', - } - ThreatMapInfo.model_validate(payload) - - def test_validate_threat_map_equality(self): - actor = { - 'id': 'ta-entity-id', - 'name': 'ta-name', - 'alias': ['alias'], - 'categories': [{'id': 'id', 'name': 'name'}], - 'opportunity': 0, - 'intent': 0, - 'log_entries': [ - { - 'entity': {'id': 'id', 'name': 'name'}, - 'severity': 0, - 'axis': 'axis', - 'date': datetime.now(), - } - ], - } - actor = ThreatMapEntity.model_validate(actor) - actor_twin = ThreatMapEntity.model_validate(actor) - malware = { - 'id': 'malware-entity-id', - 'name': 'malware-name', - 'alias': ['alias'], - 'categories': [{'id': 'id', 'name': 'name'}], - 'opportunity': 0, - 'prevalence': 0, - 'log_entries': [ - { - 'entity': {'id': 'id', 'name': 'name'}, - 'severity': 0, - 'axis': 'axis', - 'date': datetime.now(), - } - ], - } - malware = ThreatMapEntity.model_validate(malware) - malware_twin = ThreatMapEntity.model_validate(malware) - - assert actor == actor_twin - assert malware == malware_twin - assert malware != actor - assert hash(actor) == hash(actor_twin) - assert hash(malware) == hash(malware_twin) - entities = [actor, malware, actor_twin, malware_twin] - assert set(entities) == {actor, malware} - - entity_attributes = [{'name': 'name', 'alias': ['alias']}] - - @pytest.mark.parametrize('id_', ['id']) - @pytest.mark.parametrize('type_', ['type']) - @pytest.mark.parametrize('attributes', entity_attributes) - def test_validate_entity_category(self, id_, type_, attributes): - payload = { - 'id': id_, - 'type': type_, - 'attributes': attributes, - } - EntityCategory.model_validate(payload) - - ta_attributes = [ - { - 'name': 'name', - 'common_names': ['name'], - 'alias': ['alias'], - 'categories': [{'id': 'id', 'name': 'name'}], - }, - ] - - @pytest.mark.parametrize('id_', ['id']) - @pytest.mark.parametrize('type_', ['type']) - @pytest.mark.parametrize('attributes', ta_attributes) - def test_validate_threat_actor_profile(self, id_, type_, attributes): - payload = { - 'id': id_, - 'type': type_, - 'attributes': attributes, - } - ThreatActorProfile.model_validate(payload) - - @pytest.mark.parametrize('malware', [['id'], None]) - @pytest.mark.parametrize('actors', [['id'], None]) - @pytest.mark.parametrize('categories', [['id'], None]) - @pytest.mark.parametrize('watchlists', [['id'], None]) - def test_validate_threat_map_fetch(self, malware, actors, categories, watchlists): - payload = { - 'malware': malware, - 'actors': actors, - 'categories': categories, - 'watchlists': watchlists, - } - - ThreatMapFetchIn.model_validate(payload) - - params = [ - ('actors', 'actor'), - ('malware', 'malware'), - ] - - @pytest.mark.parametrize(('key', 'expected'), params) - def test_threat_map_type_slug(self, key, expected): - map_type = ThreatMapType(key) - assert map_type.category_slug == expected diff --git a/tests/threat_maps/test_threat_map_mgr.py b/tests/threat_maps/test_threat_map_mgr.py deleted file mode 100644 index 02074723..00000000 --- a/tests/threat_maps/test_threat_map_mgr.py +++ /dev/null @@ -1,98 +0,0 @@ -import json - -import pytest - -from psengine.threat_maps import ( - EntityCategory, - ThreatActorProfile, - ThreatMap, - ThreatMapInfo, - ThreatMapMgr, -) -from tests.threat_maps.conftest import MOCK_DIR - - -class Test_ThreatMapMgr: - def test_mgr(self, threat_map_mgr: ThreatMapMgr): - assert isinstance(threat_map_mgr, ThreatMapMgr) - - def test_fetch_available_maps(self, threat_map_mgr: ThreatMapMgr, mocker): - json_path = MOCK_DIR / 'test_fetch_available_maps.json' - with open(json_path) as f: - file_data = json.load(f) - mocker.patch.object( - threat_map_mgr.rf_client, 'request', return_value=mocker.Mock(json=lambda: file_data) - ) - - available_maps = threat_map_mgr.fetch_available_maps() - sample_map = available_maps[0] - assert isinstance(available_maps, list) - assert isinstance(sample_map, ThreatMapInfo) - - @pytest.mark.parametrize( - 'map_type', - ['actors', 'malware'], - ) - def test_fetch_entity_categories(self, threat_map_mgr: ThreatMapMgr, map_type, mocker): - json_path = MOCK_DIR / 'test_fetch_categories.json' - with open(json_path) as f: - file_data = json.load(f) - mocker.patch.object( - threat_map_mgr.rf_client, 'request', return_value=mocker.Mock(json=lambda: file_data) - ) - - categories = threat_map_mgr.fetch_entity_categories(map_type=map_type) - category = categories[0] - assert isinstance(categories, list) - assert isinstance(category, EntityCategory) - - @pytest.mark.parametrize( - 'name', - ['actor', None], - ) - @pytest.mark.parametrize( - 'max_results', - [1, 10000, None], - ) - def test_search_threat_actor(self, threat_map_mgr: ThreatMapMgr, name, max_results, mocker): - json_path = MOCK_DIR / 'test_search_threat_actors.json' - with open(json_path) as f: - file_data = json.load(f) - mock_records = file_data.get('data', []) - mocker.patch.object( - threat_map_mgr.rf_client, - 'request_paged', - side_effect=lambda *args, **kwargs: iter(mock_records), # noqa: ARG005 - ) - - actors = threat_map_mgr.search_threat_actor(name=name, max_results=max_results) - actor = actors[0] - assert isinstance(actor, ThreatActorProfile) - - @pytest.mark.parametrize( - 'map_type', - ['actors', 'malware'], - ) - def test_fetch_map(self, threat_map_mgr: ThreatMapMgr, map_type, mocker, mock_request): - mocks = [mock_request(MOCK_DIR / f'test_validate_threat_map_{map_type}.json')] - mocker.patch.object(threat_map_mgr.rf_client, 'request', side_effect=mocks) - - threat_map = threat_map_mgr.fetch_map(map_type=map_type) - assert isinstance(threat_map, ThreatMap) - - @pytest.mark.parametrize( - 'map_type', - ['actors', 'malware'], - ) - @pytest.mark.parametrize( - 'org_id', - ['uhash:36sKPnfRQsl', 'uhash:69sKLfTGsS'], - ) - def test_fetch_map_org_id( - self, threat_map_mgr: ThreatMapMgr, map_type, org_id, mocker, mock_request - ): - mocks = [mock_request(MOCK_DIR / f'test_validate_threat_map_{map_type}.json')] - mocker.patch.object(threat_map_mgr.rf_client, 'request', side_effect=mocks) - - threat_map = threat_map_mgr.fetch_map(map_type=map_type, org_id=org_id) - assert isinstance(threat_map, ThreatMap) diff --git a/uv.lock b/uv.lock index d0543427..938eb2d1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,11 @@ version = 1 revision = 3 -requires-python = ">=3.10, <3.15" - -[options] -exclude-newer = "2026-04-24T09:50:18.171105Z" -exclude-newer-span = "P7D" +requires-python = ">=3.9, <3.14" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] [[package]] name = "annotated-types" @@ -15,10 +16,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + [[package]] name = "antlr4-python3-runtime" version = "4.13.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] sdist = { url = "https://files.pythonhosted.org/packages/33/5f/2cdf6f7aca3b20d3f316e9f505292e1f256a32089bd702034c29ebde6242/antlr4_python3_runtime-4.13.2.tar.gz", hash = "sha256:909b647e1d2fc2b70180ac586df3933e38919c85f98ccc656a96cd3f25ef3916", size = 117467, upload-time = "2024-08-03T19:00:12.757Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/89/03/a851e84fcbb85214dc637b6378121ef9a0dd61b4c65264675d8a5c9b1ae7/antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8", size = 144462, upload-time = "2024-08-03T19:00:11.134Z" }, @@ -43,10 +57,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, - { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] +[[package]] +name = "build" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4", size = 23382, upload-time = "2025-08-01T21:27:07.844Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -126,47 +156,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/ef725f8eb19b5a261b30f78efa9252ef9d017985cb499102f6f49834cd12/charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", size = 299121, upload-time = "2026-04-02T09:28:14.372Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2f12878fbc680fbbb52386cd39a379801f62eaca74fc8b323381325f0f04/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", size = 200612, upload-time = "2026-04-02T09:28:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b6/10c84e789126ca97d4a7228863a30481e786980a8b8cfcbf4f30658ca63c/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", size = 221041, upload-time = "2026-04-02T09:28:17.554Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/c414866a138400b2e81973d006da7f694cfeaf895ef07d2cba9a8743841a/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", size = 216323, upload-time = "2026-04-02T09:28:18.863Z" }, + { url = "https://files.pythonhosted.org/packages/2e/92/bdcf94997e06b223d826df3abed45a5ad6e17f609b7df9d25cd23b5bde30/charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", size = 208419, upload-time = "2026-04-02T09:28:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/3f9142293c88b1b10e199649ed1330f070c2a68e305335a5819fa7f25fa7/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", size = 195016, upload-time = "2026-04-02T09:28:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d1/d8a6b7dd5c5636b76ce0d080bc57d8e56c7bbd6bc2ac941529a35e41d84a/charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", size = 206115, upload-time = "2026-04-02T09:28:23.259Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8c/60ebe912379627d023eb96995b40bc50308729f210f43d66109ca0a7bbd2/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", size = 204022, upload-time = "2026-04-02T09:28:24.779Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2a/41816ceda78a551cbfdfbeab6f3891152b0e3f758ce6580c2c18c829f774/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", size = 195914, upload-time = "2026-04-02T09:28:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9b/7c7f4b7f11525fcbdfba752455314ac60646bae91cdd671d531c1f7a97c6/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", size = 222159, upload-time = "2026-04-02T09:28:27.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/301682e7469bdbfa2ce219a804f0668b2266ab8520570d85d3b3ef483ea3/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", size = 206154, upload-time = "2026-04-02T09:28:28.848Z" }, + { url = "https://files.pythonhosted.org/packages/20/ec/90339ff5cdc598b265748c1f231c7d7fbd9123a92cee10f757e0b1448de4/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", size = 217423, upload-time = "2026-04-02T09:28:30.248Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e7/a7a6147f8e3375676309cf584b25c72a3bab784ea4085b0011fa07b23aeb/charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", size = 210604, upload-time = "2026-04-02T09:28:31.736Z" }, + { url = "https://files.pythonhosted.org/packages/1a/62/d9340c7a79c393e57807d7fb6c57e82060687891f81b74d3201958b919c1/charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", size = 144631, upload-time = "2026-04-02T09:28:33.158Z" }, + { url = "https://files.pythonhosted.org/packages/21/e7/92901117e2ddc8facfe8235a3ecd4eb482185b2ad5d5b6606b37c1afea06/charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", size = 154710, upload-time = "2026-04-02T09:28:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4f/e1fb138201ad9a32499dd9a98aa4a5a5441fbf7f56b52b619a54b7ee8777/charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", size = 143716, upload-time = "2026-04-02T09:28:35.908Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + [[package]] name = "click" version = "8.3.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ @@ -182,10 +215,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + [[package]] name = "coverage" version = "7.13.5" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, @@ -262,42 +392,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, ] [[package]] @@ -313,36 +413,43 @@ wheels = [ ] [[package]] -name = "freezegun" -version = "1.5.5" +name = "ghp-import" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] -name = "ghp-import" -version = "2.1.0" +name = "griffe" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] dependencies = [ - { name = "python-dateutil" }, + { name = "colorama", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/d7/6c09dd7ce4c7837e4cdb11dce980cb45ae3cd87677298dc3b781b6bce7d3/griffe-1.14.0.tar.gz", hash = "sha256:9d2a15c1eca966d68e00517de5d69dd1bc5c9f2335ef6c1775362ba5b8651a13", size = 424684, upload-time = "2025-09-05T15:02:29.167Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b1/9ff6578d789a89812ff21e4e0f80ffae20a65d5dd84e7a17873fe3b365be/griffe-1.14.0-py3-none-any.whl", hash = "sha256:0e9d52832cccf0f7188cfe585ba962d2674b241c01916d780925df34873bceb0", size = 144439, upload-time = "2025-09-05T15:02:27.511Z" }, ] [[package]] name = "griffe" version = "2.0.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "griffecli" }, - { name = "griffelib" }, + { name = "griffecli", marker = "python_full_version >= '3.10'" }, + { name = "griffelib", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/49/eb6d2935e27883af92c930ed40cc4c69bcd32c402be43b8ca4ab20510f67/griffe-2.0.2.tar.gz", hash = "sha256:c5d56326d159f274492e9bf93a9895cec101155d944caa66d0fc4e0c13751b92", size = 293757, upload-time = "2026-03-27T11:34:52.205Z" } wheels = [ @@ -354,7 +461,8 @@ name = "griffe-typingdoc" version = "0.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffe" }, + { name = "griffe", version = "1.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "griffe", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/15/92e1cdd63515f18e35c357f10970f5a8b46fed15d615305497241c944be2/griffe_typingdoc-0.2.9.tar.gz", hash = "sha256:99c05bf09a9c391464e3937718c9a5a1055bb95ed549f4f7706be9a71578669c", size = 32878, upload-time = "2025-09-05T15:45:32.178Z" } @@ -367,8 +475,8 @@ name = "griffecli" version = "2.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama" }, - { name = "griffelib" }, + { name = "colorama", marker = "python_full_version >= '3.10'" }, + { name = "griffelib", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/e0/6a7d661d71bb043656a109b91d84a42b5342752542074ec83b16a6eb97f0/griffecli-2.0.2.tar.gz", hash = "sha256:40a1ad4181fc39685d025e119ae2c5b669acdc1f19b705fb9bf971f4e6f6dffb", size = 56281, upload-time = "2026-03-27T11:34:50.087Z" } wheels = [ @@ -402,10 +510,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "zipp", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, @@ -444,10 +611,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/1c/8e9c092e0faa0eb5ac8f5e859a62bd82239eba0259c05f6fd9bb6ddd6b91/logging_tree-1.10-py2.py3-none-any.whl", hash = "sha256:f0e9f4645e6476e48c563ba2b858286079f886b2d5ad8abeaffd38e536a387d8", size = 13280, upload-time = "2024-05-03T16:05:10.674Z" }, ] +[[package]] +name = "markdown" +version = "3.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, +] + [[package]] name = "markdown" version = "3.10.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, @@ -458,19 +644,39 @@ name = "markdown-include" version = "0.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ad/d8/66bf162fe6c1adb619f94a6da599323eecacf15b6d57469d0fd0421c10df/markdown-include-0.8.1.tar.gz", hash = "sha256:1d0623e0fc2757c38d35df53752768356162284259d259c486b4ab6285cdbbe3", size = 21873, upload-time = "2023-02-07T09:47:26.608Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d7/e2/c4d20b21a05fe0fee571649cebc05f7f72e80b1a743f932e7326125e6c9e/markdown_include-0.8.1-py3-none-any.whl", hash = "sha256:32f0635b9cfef46997b307e2430022852529f7a5b87c0075c504283e7cc7db53", size = 18837, upload-time = "2023-02-07T09:47:25.03Z" }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "mdurl", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "mdurl" }, + { name = "mdurl", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } wheels = [ @@ -547,28 +753,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, + { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bc/4dc914ead3fe6ddaef035341fee0fc956949bbd27335b611829292b89ee2/markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", size = 20543, upload-time = "2025-09-27T18:37:32.168Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/5fe81fbcfba4aef4093d5f856e5c774ec2057946052d18d168219b7bd9f9/markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", size = 20585, upload-time = "2025-09-27T18:37:33.166Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/e0e5a3d3ae9c4020f696cd055f940ef86b64fe88de26f3a0308b9d3d048c/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", size = 21387, upload-time = "2025-09-27T18:37:34.185Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/651753ef4dea08ea790f4fbb65146a9a44a014986996ca40102e237aa49a/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", size = 20133, upload-time = "2025-09-27T18:37:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0a/c3cf2b4fef5f0426e8a6d7fce3cb966a17817c568ce59d76b92a233fdbec/markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", size = 20588, upload-time = "2025-09-27T18:37:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1b/a7782984844bd519ad4ffdbebbba2671ec5d0ebbeac34736c15fb86399e8/markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", size = 14566, upload-time = "2025-09-27T18:37:37.09Z" }, + { url = "https://files.pythonhosted.org/packages/18/1f/8d9c20e1c9440e215a44be5ab64359e207fcb4f675543f1cf9a2a7f648d0/markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", size = 15053, upload-time = "2025-09-27T18:37:38.054Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, ] [[package]] @@ -594,6 +789,8 @@ name = "mike" version = "2.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "importlib-resources", marker = "python_full_version < '3.10'" }, { name = "jinja2" }, { name = "mkdocs" }, { name = "pyparsing" }, @@ -606,10 +803,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/f7/10f5e101db25741b91e4f4792c5d97b4fa834ead5cf509ae91097d939424/mike-2.1.4-py3-none-any.whl", hash = "sha256:39933e992e155dd70f2297e749a0ed78d8fd7942bc33a3666195d177758a280e", size = 33820, upload-time = "2026-03-08T02:46:28.149Z" }, ] +[[package]] +name = "mimesis" +version = "12.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +sdist = { url = "https://files.pythonhosted.org/packages/88/a1/c5f4f2952489b005e3a7aa1a5f92633b14275a79f53e6126c22f0b046882/mimesis-12.1.0.tar.gz", hash = "sha256:ba31b3b220d1abd760fa268fe7ac79a9bc8438977f9896ddd4375fe2fff07844", size = 4328109, upload-time = "2024-01-07T14:40:53.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/7f/9d8146bbbec124c17a1b3fc9d879b53c87d072274c20a3049d58bda5506b/mimesis-12.1.0-py3-none-any.whl", hash = "sha256:58bf5e2cb68431009e47efde76364ab8305b4e834385d93a5ba49f894f03f6fe", size = 4365011, upload-time = "2024-01-07T14:40:50.652Z" }, +] + [[package]] name = "mimesis" version = "19.1.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] sdist = { url = "https://files.pythonhosted.org/packages/12/bf/1612e48b9eea7fa39e27600e94b9b267b37da749fcdf019e5e6a892ba2ec/mimesis-19.1.0.tar.gz", hash = "sha256:af4f14852a97657c5dbfad860be90c0efe97bf3758809e034b6581a45b13814b", size = 18252875, upload-time = "2026-01-11T09:14:58.969Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/40/f2/39da33ad3c10956d9ee7cdea3c3ee92e98c529b2994ed7c36a208b497603/mimesis-19.1.0-py3-none-any.whl", hash = "sha256:cf2ea4674819eaf386a2b8d28ded450968f1770fcb23f6c6ef884f61727bb4e6", size = 4464087, upload-time = "2026-01-11T09:14:55.738Z" }, @@ -620,11 +833,14 @@ name = "mkdocs" version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "ghp-import" }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "jinja2" }, - { name = "markdown" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "markupsafe" }, { name = "mergedeep" }, { name = "mkdocs-get-deps" }, @@ -644,7 +860,8 @@ name = "mkdocs-autorefs" version = "1.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "markupsafe" }, { name = "mkdocs" }, ] @@ -680,8 +897,10 @@ name = "mkdocs-get-deps" version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "mergedeep" }, - { name = "platformdirs" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } @@ -698,13 +917,15 @@ dependencies = [ { name = "backrefs" }, { name = "colorama" }, { name = "jinja2" }, - { name = "markdown" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "mkdocs" }, { name = "mkdocs-material-extensions" }, { name = "paginate" }, { name = "pygments" }, { name = "pymdown-extensions" }, - { name = "requests" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/57/de/cc1d5139c2782b1a49e1ed1845b3298ed6076b9ba1c740ad7c952d8ffcf9/mkdocs_material-9.6.23.tar.gz", hash = "sha256:62ebc9cdbe90e1ae4f4e9b16a6aa5c69b93474c7b9e79ebc0b11b87f9f055e00", size = 4048130, upload-time = "2025-11-01T16:33:11.782Z" } wheels = [ @@ -720,17 +941,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] +[[package]] +name = "mkdocstrings" +version = "0.30.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markupsafe", marker = "python_full_version < '3.10'" }, + { name = "mkdocs", marker = "python_full_version < '3.10'" }, + { name = "mkdocs-autorefs", marker = "python_full_version < '3.10'" }, + { name = "pymdown-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/33/2fa3243439f794e685d3e694590d28469a9b8ea733af4b48c250a3ffc9a0/mkdocstrings-0.30.1.tar.gz", hash = "sha256:84a007aae9b707fb0aebfc9da23db4b26fc9ab562eb56e335e9ec480cb19744f", size = 106350, upload-time = "2025-09-19T10:49:26.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/2c/f0dc4e1ee7f618f5bff7e05898d20bf8b6e7fa612038f768bfa295f136a4/mkdocstrings-0.30.1-py3-none-any.whl", hash = "sha256:41bd71f284ca4d44a668816193e4025c950b002252081e387433656ae9a70a82", size = 36704, upload-time = "2025-09-19T10:49:24.805Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python", version = "1.18.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] + [[package]] name = "mkdocstrings" version = "1.0.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, - { name = "mkdocs-autorefs" }, - { name = "pymdown-extensions" }, + { name = "jinja2", marker = "python_full_version >= '3.10'" }, + { name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "markupsafe", marker = "python_full_version >= '3.10'" }, + { name = "mkdocs", marker = "python_full_version >= '3.10'" }, + { name = "mkdocs-autorefs", marker = "python_full_version >= '3.10'" }, + { name = "pymdown-extensions", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946, upload-time = "2026-02-07T14:31:40.973Z" } wheels = [ @@ -739,18 +990,40 @@ wheels = [ [package.optional-dependencies] python = [ - { name = "mkdocstrings-python" }, + { name = "mkdocstrings-python", version = "2.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "griffe", version = "1.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mkdocs-autorefs", marker = "python_full_version < '3.10'" }, + { name = "mkdocstrings", version = "0.30.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/ae/58ab2bfbee2792e92a98b97e872f7c003deb903071f75d8d83aa55db28fa/mkdocstrings_python-1.18.2.tar.gz", hash = "sha256:4ad536920a07b6336f50d4c6d5603316fafb1172c5c882370cbbc954770ad323", size = 207972, upload-time = "2025-08-28T16:11:19.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/8f/ce008599d9adebf33ed144e7736914385e8537f5fc686fdb7cceb8c22431/mkdocstrings_python-1.18.2-py3-none-any.whl", hash = "sha256:944fe6deb8f08f33fa936d538233c4036e9f53e840994f6146e8e94eb71b600d", size = 138215, upload-time = "2025-08-28T16:11:18.176Z" }, ] [[package]] name = "mkdocstrings-python" version = "2.0.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "griffelib" }, - { name = "mkdocs-autorefs" }, - { name = "mkdocstrings" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "griffelib", marker = "python_full_version >= '3.10'" }, + { name = "mkdocs-autorefs", marker = "python_full_version >= '3.10'" }, + { name = "mkdocstrings", version = "1.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } wheels = [ @@ -795,11 +1068,27 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, ] [[package]] @@ -822,7 +1111,7 @@ wheels = [ [[package]] name = "psengine" -version = "2.6.0" +version = "2.5.1" source = { editable = "." } dependencies = [ { name = "jsonpath-ng" }, @@ -831,15 +1120,18 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings", extra = ["toml"] }, { name = "python-dateutil" }, - { name = "requests" }, - { name = "stix2" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "stix2", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "stix2", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "typing-extensions" }, ] -[package.dev-dependencies] +[package.optional-dependencies] dev = [ - { name = "freezegun" }, - { name = "mimesis" }, + { name = "build" }, + { name = "mimesis", version = "12.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mimesis", version = "19.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-httpdbg" }, @@ -847,6 +1139,8 @@ dev = [ { name = "pytest-mock" }, { name = "pytest-random-order" }, { name = "ruff" }, + { name = "setuptools" }, + { name = "wheel" }, ] docs = [ { name = "griffe-typingdoc" }, @@ -857,47 +1151,55 @@ docs = [ { name = "mkdocs-codeinclude-plugin" }, { name = "mkdocs-exclude" }, { name = "mkdocs-material" }, - { name = "mkdocstrings", extra = ["python"] }, + { name = "mkdocstrings", version = "0.30.1", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version < '3.10'" }, + { name = "mkdocstrings", version = "1.0.3", source = { registry = "https://pypi.org/simple" }, extra = ["python"], marker = "python_full_version >= '3.10'" }, { name = "rich" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest-mock" }, ] [package.metadata] requires-dist = [ - { name = "jsonpath-ng", specifier = ">=1.5.3,<=1.8.0" }, + { name = "build", marker = "extra == 'dev'", specifier = "==1.3.0" }, + { name = "griffe-typingdoc", marker = "extra == 'docs'", specifier = "~=0.2.8" }, + { name = "jsonpath-ng", specifier = ">=1.5.3,<=1.6.1" }, + { name = "logging-tree", marker = "extra == 'docs'" }, + { name = "markdown-include", marker = "extra == 'docs'", specifier = "~=0.8.1" }, { name = "markdown-strings", specifier = "==3.4.0" }, - { name = "more-itertools", specifier = ">=9.0.0,<=11.0.2" }, + { name = "mike", marker = "extra == 'docs'", specifier = "~=2.1.3" }, + { name = "mimesis", marker = "extra == 'dev'", specifier = ">=12.1.0" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = "~=1.6.1" }, + { name = "mkdocs-codeinclude-plugin", marker = "extra == 'docs'", specifier = "~=0.2.1" }, + { name = "mkdocs-exclude", marker = "extra == 'docs'", specifier = "~=1.0.2" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = "~=9.6.18" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.18" }, + { name = "more-itertools", specifier = ">=9.0.0,<=10.2.0" }, { name = "pydantic", specifier = ">=2.7,<3.0.0" }, - { name = "pydantic-settings", extras = ["toml"], specifier = ">=2.12.2,<2.14.1" }, + { name = "pydantic-settings", extras = ["toml"], specifier = ">=2.5.2,<2.11.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.4" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==6.0.0" }, + { name = "pytest-httpdbg", marker = "extra == 'dev'", specifier = "==0.9.0" }, + { name = "pytest-md", marker = "extra == 'dev'", specifier = "==0.2.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = "==3.14.0" }, + { name = "pytest-random-order", marker = "extra == 'dev'", specifier = "==1.1.1" }, { name = "python-dateutil", specifier = ">=2.7.0" }, { name = "requests", specifier = ">=2.27.1" }, + { name = "rich", marker = "extra == 'docs'" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "~=0.11.0" }, + { name = "ruff", marker = "extra == 'docs'", specifier = "~=0.11.0" }, + { name = "setuptools", marker = "extra == 'dev'", specifier = "==80.9.0" }, { name = "stix2", specifier = "~=3.0.1" }, { name = "typing-extensions", specifier = ">=4.8.0" }, + { name = "wheel", marker = "extra == 'dev'", specifier = "==0.45.1" }, ] +provides-extras = ["dev", "docs"] [package.metadata.requires-dev] -dev = [ - { name = "freezegun", specifier = ">=1.5.5" }, - { name = "mimesis", specifier = ">=19.1.0" }, - { name = "pytest", specifier = "==9.0.3" }, - { name = "pytest-cov", specifier = "==7.1.0" }, - { name = "pytest-httpdbg", specifier = "==0.10.2" }, - { name = "pytest-md", specifier = "==0.2.0" }, - { name = "pytest-mock", specifier = "==3.15.1" }, - { name = "pytest-random-order", specifier = "==1.2.0" }, - { name = "ruff", specifier = "~=0.15.8" }, -] -docs = [ - { name = "griffe-typingdoc", specifier = ">=0.2.8,<0.4.0" }, - { name = "logging-tree", specifier = "==1.10" }, - { name = "markdown-include", specifier = "~=0.8.1" }, - { name = "mike", specifier = ">=2.1.4,<2.3.0" }, - { name = "mkdocs", specifier = "~=1.6.1" }, - { name = "mkdocs-codeinclude-plugin", specifier = "~=0.2.1" }, - { name = "mkdocs-exclude", specifier = "~=1.0.2" }, - { name = "mkdocs-material", specifier = ">=9.6.18,<9.8.0" }, - { name = "mkdocstrings", extras = ["python"], specifier = ">=0.18" }, - { name = "rich", specifier = "==15.0.0" }, -] +dev = [{ name = "pytest-mock", specifier = ">=3.14.0" }] [[package]] name = "pydantic" @@ -978,34 +1280,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" }, + { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" }, + { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" }, { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, @@ -1034,16 +1321,17 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.0" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, - { name = "python-dotenv" }, + { name = "python-dotenv", version = "1.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "python-dotenv", version = "1.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/a1/ae859ffac5a3338a66b74c5e29e244fd3a3cc483c89feaf9f56c39898d75/pydantic_settings-2.13.0.tar.gz", hash = "sha256:95d875514610e8595672800a5c40b073e99e4aae467fa7c8f9c263061ea2e1fe", size = 222450, upload-time = "2026-02-15T12:11:23.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/1a/dd1b9d7e627486cf8e7523d09b70010e05a4bc41414f4ae6ce184cf0afb6/pydantic_settings-2.13.0-py3-none-any.whl", hash = "sha256:d67b576fff39cd086b595441bf9c75d4193ca9c0ed643b90360694d0f1240246", size = 58429, upload-time = "2026-02-15T12:11:22.133Z" }, + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, ] [package.optional-dependencies] @@ -1065,7 +1353,8 @@ name = "pymdown-extensions" version = "10.21.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown" }, + { name = "markdown", version = "3.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown", version = "3.10.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } @@ -1082,49 +1371,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + [[package]] name = "pytest" -version = "9.0.3" +version = "8.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "packaging" }, { name = "pluggy" }, - { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919, upload-time = "2024-12-01T12:54:25.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083, upload-time = "2024-12-01T12:54:19.735Z" }, ] [[package]] name = "pytest-cov" -version = "7.1.0" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, - { name = "pluggy" }, + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.13.5", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" }, ] [[package]] name = "pytest-httpdbg" -version = "0.10.2" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpdbg" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/2d/c04d11b72bd1f23f23320400c2551a832063b8008dadd25247662ed46f7f/pytest_httpdbg-0.10.2.tar.gz", hash = "sha256:b3662f4265a4b044865da9602a953534793dc0c39a7397aa36b586d5c5673dc3", size = 9975, upload-time = "2026-03-29T07:03:52.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/5c/ffbb26295738c399bf670c424653c3c72e53c3cd4fb6791f572236e849dd/pytest_httpdbg-0.9.0.tar.gz", hash = "sha256:8cb97ba81f1562f35e38eee55047b8e36a7bf0cdc57b7ac4870f7d567b73f292", size = 11281, upload-time = "2025-07-25T16:09:33.877Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/9a/31c6b2626ea24a5fca855eeb70dc05e794e63b49e415ace2aa6a24b0f857/pytest_httpdbg-0.10.2-py3-none-any.whl", hash = "sha256:93f33b6a0c0c4ab9b6f4cd5fbb4390b84970bedcfe76a730813d56aa1354c63e", size = 9406, upload-time = "2026-03-29T07:03:51.712Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/62e55f10ef7d54ae5513a52f8169e51b47674a5cd10a6368f575cb9817cd/pytest_httpdbg-0.9.0-py3-none-any.whl", hash = "sha256:71019c4d9e5616b9595e6e6e33855914abc30f4e166b2b41c81dc649f1ebd701", size = 10172, upload-time = "2025-07-25T16:09:32.867Z" }, ] [[package]] @@ -1141,26 +1439,26 @@ wheels = [ [[package]] name = "pytest-mock" -version = "3.15.1" +version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814, upload-time = "2024-03-21T22:14:04.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863, upload-time = "2024-03-21T22:14:02.694Z" }, ] [[package]] name = "pytest-random-order" -version = "1.2.0" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/ad/a2a32d91effe0f84a300b9ba6c9d000bd52f31b77f8e49d8fd8653a9ddc3/pytest_random_order-1.2.0.tar.gz", hash = "sha256:12b2d4ee977ec9922b5e3575afe13c22cbdb06e3d03e550abc43df137b90439a", size = 107304, upload-time = "2025-06-22T14:44:43.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/93/e5/89654b4354b10e89969a74130f391b017dbdc113ce27f0e8ff9fa23e44e1/pytest-random-order-1.1.1.tar.gz", hash = "sha256:4472d7d34f1f1c5f3a359c4ffc5c13ed065232f31eca19c8844c1ab406e79080", size = 14626, upload-time = "2024-01-20T09:25:07.218Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/7f/92c8dbe185aa38270fec1e73e0ed70d8e5de31963aa057ba621055f8b008/pytest_random_order-1.2.0-py3-none-any.whl", hash = "sha256:78d1d6f346222cdf26a7302c502d2f1cab19454529af960b8b9e1427a99ab277", size = 10889, upload-time = "2025-06-22T14:44:42.438Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/944cf846bcd6027a1805c69fec90581f916e99ccafcbe409ae6c76833255/pytest_random_order-1.1.1-py3-none-any.whl", hash = "sha256:882727a8b597ecd06ede28654ffeb8a6d511a1e4abe1054cca7982f2e42008cd", size = 11521, upload-time = "2024-01-20T09:25:05.098Z" }, ] [[package]] @@ -1175,10 +1473,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, @@ -1237,24 +1551,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, ] [[package]] @@ -1269,15 +1574,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "certifi", marker = "python_full_version < '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, + { name = "idna", marker = "python_full_version < '3.10'" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "requests" version = "2.33.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, + { name = "certifi", marker = "python_full_version >= '3.10'" }, + { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ @@ -1286,40 +1613,50 @@ wheels = [ [[package]] name = "rich" -version = "15.0.0" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "ruff" -version = "0.15.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, +version = "0.11.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, ] [[package]] @@ -1380,6 +1717,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" }, { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" }, { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2d/7c4968c60ddc8b504b77301cc80d6e75cd0269b81a779b01d66d8f36dcb8/simplejson-3.20.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b3bf76512ccb07d47944ebdca44c65b781612d38b9098566b4bb40f713fc4047", size = 94039, upload-time = "2025-09-26T16:29:17.406Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e4/d96b56fb87f245240b514c1fe552e76c17e09f0faa1f61137b2296f81529/simplejson-3.20.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:214e26acf2dfb9ff3314e65c4e168a6b125bced0e2d99a65ea7b0f169db1e562", size = 75893, upload-time = "2025-09-26T16:29:18.534Z" }, + { url = "https://files.pythonhosted.org/packages/09/4f/be411eeb52ab21d6d4c00722b632dd2bd430c01a47dfed3c15ef5ad7ee6e/simplejson-3.20.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2fb1259ca9c385b0395bad59cdbf79535a5a84fb1988f339a49bfbc57455a35a", size = 76104, upload-time = "2025-09-26T16:29:19.66Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/3bd0007b64881a90a058c59a4869b1b4f130ddb86a726f884fafc67e5ef7/simplejson-3.20.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c34e028a2ba8553a208ded1da5fa8501833875078c4c00a50dffc33622057881", size = 138261, upload-time = "2025-09-26T16:29:20.822Z" }, + { url = "https://files.pythonhosted.org/packages/15/5d/b6d0b71508e503c759a0a7563cb2c28716ec8af9828ca9f5b59023011406/simplejson-3.20.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b538f9d9e503b0dd43af60496780cb50755e4d8e5b34e5647b887675c1ae9fee", size = 146397, upload-time = "2025-09-26T16:29:22.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/24/40b3e5a3ca5e6f80cc1c639fcd5565ae087e72e8656dea780f02302ddc97/simplejson-3.20.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab998e416ded6c58f549a22b6a8847e75a9e1ef98eb9fbb2863e1f9e61a4105b", size = 134020, upload-time = "2025-09-26T16:29:23.615Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8c/8fc2c2734ac9e514124635b25ca8f7e347db1ded4a30417ee41e78e6d61c/simplejson-3.20.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a8f1c307edf5fbf0c6db3396c5d3471409c4a40c7a2a466fbc762f20d46601a", size = 137598, upload-time = "2025-09-26T16:29:24.835Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d9/15036d7f43c6208fb0fbc827f9f897c1f577fba02aeb7a8a223581da4925/simplejson-3.20.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5a7bbac80bdb82a44303f5630baee140aee208e5a4618e8b9fde3fc400a42671", size = 139770, upload-time = "2025-09-26T16:29:26.244Z" }, + { url = "https://files.pythonhosted.org/packages/73/cc/18374fb9dfcb4827b692ca5a33bdb607384ca06cdb645e0b863022dae8a3/simplejson-3.20.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5ef70ec8fe1569872e5a3e4720c1e1dcb823879a3c78bc02589eb88fab920b1f", size = 139884, upload-time = "2025-09-26T16:29:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/1526d4152806670124dd499ff831726a92bd7e029e8349c4affa78ea8845/simplejson-3.20.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:cb11c09c99253a74c36925d461c86ea25f0140f3b98ff678322734ddc0f038d7", size = 148166, upload-time = "2025-09-26T16:29:29.789Z" }, + { url = "https://files.pythonhosted.org/packages/a4/77/fc16d41b5f67a2591c9b6ff7b0f6aed2b2aed1b6912bb346b61279697638/simplejson-3.20.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:66f7c78c6ef776f8bd9afaad455e88b8197a51e95617bcc44b50dd974a7825ba", size = 140778, upload-time = "2025-09-26T16:29:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/4a/97/a26ef6b7387349623c042f329df70a4f3baf3a365fe6d1154d73da1dcf5a/simplejson-3.20.2-cp39-cp39-win32.whl", hash = "sha256:619ada86bfe3a5aa02b8222ca6bfc5aa3e1075c1fb5b3263d24ba579382df472", size = 74339, upload-time = "2025-09-26T16:29:32.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b7/94c6049a99e3c04eed2064e91295370b7429e2361188e35a78df562312e0/simplejson-3.20.2-cp39-cp39-win_amd64.whl", hash = "sha256:44a6235e09ca5cc41aa5870a952489c06aa4aee3361ae46daa947d8398e57502", size = 76067, upload-time = "2025-09-26T16:29:34.184Z" }, { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, ] @@ -1392,27 +1742,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "stix2" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "pytz", marker = "python_full_version < '3.10'" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "simplejson", marker = "python_full_version < '3.10'" }, + { name = "stix2-patterns", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/6c/598c368b2822783179b0e7992877a3d4b4a7aded18244c1ed1db4894d729/stix2-3.0.1.tar.gz", hash = "sha256:2a2718dc3451c84c709990b2ca220cc39c75ed23e0864d7e8d8190a9365b0cbf", size = 140969, upload-time = "2021-09-24T13:37:25.628Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/4e/f88d650a5cf6483740a9bc31c106a21af3330bef0c8be89dae3c715edca3/stix2-3.0.1-py2.py3-none-any.whl", hash = "sha256:827acf0b5b319c1b857c9db0d54907bb438b2b32312d236c891a305ad49b0ba2", size = 177847, upload-time = "2021-09-24T13:37:24.21Z" }, +] + [[package]] name = "stix2" version = "3.0.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "pytz" }, - { name = "requests" }, - { name = "simplejson" }, - { name = "stix2-patterns" }, + { name = "pytz", marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.33.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "simplejson", marker = "python_full_version >= '3.10'" }, + { name = "stix2-patterns", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/c8/103631824008f5a5259ff35f91442dccbb9ecad9a67f146b19d555679273/stix2-3.0.2.tar.gz", hash = "sha256:5bdaf3b7bd956a35b629c62b2c64fde8b2ce6f329b43ce09e12f672956507645", size = 141614, upload-time = "2026-02-12T08:44:50.012Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/07/ae/d8d4c5bf65293543d0cbf2dd019b0169de3c7a37ae93ae4e2300d446805f/stix2-3.0.2-py2.py3-none-any.whl", hash = "sha256:f4814d29ebc332c92694fc8ddc96a4a5bfe2eac08494c0dec219719e7e654eb3", size = 161013, upload-time = "2026-02-12T08:44:48.771Z" }, ] +[[package]] +name = "stix2-patterns" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "antlr4-python3-runtime", version = "4.9.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "six", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/38/13c114116f6b9028b91fe4dac486446abc2ecdaffd55deda70489ba88519/stix2-patterns-2.0.0.tar.gz", hash = "sha256:07750c5a5af2c758e9d2aa4dde9d8e04bcd162ac2a9b0b4c4de4481d443efa08", size = 61800, upload-time = "2022-03-31T21:24:50.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/22/0255d7aca7b920a27fbcbab72754133615a731a92838a6dde2ed436ce051/stix2_patterns-2.0.0-py2.py3-none-any.whl", hash = "sha256:ca4d68b2db42ed99794a418388769d2676ca828e9cac0b8629e73cd3f68f6458", size = 65867, upload-time = "2022-03-31T21:24:48.576Z" }, +] + [[package]] name = "stix2-patterns" version = "2.1.2" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "antlr4-python3-runtime" }, + { name = "antlr4-python3-runtime", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0d/c1/adca6c1a5226cc3aa594d03b8562955563c6d7ed855aef071ad0e2feb2b8/stix2_patterns-2.1.2.tar.gz", hash = "sha256:b2059d36c1fd87740f3facc22a4147cde1e2b0acb8d5e4c08fbf04b1dc553185", size = 78811, upload-time = "2026-02-11T16:48:16.335Z" } wheels = [ @@ -1452,24 +1844,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] @@ -1494,10 +1868,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "1.26.20" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, @@ -1530,8 +1920,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/7223011bb760fce8ddc53416beb65b83a3ea6d7d13738dde75eeb2c89679/watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8", size = 96390, upload-time = "2024-11-01T14:06:49.325Z" }, + { url = "https://files.pythonhosted.org/packages/9c/62/d2b21bc4e706d3a9d467561f487c2938cbd881c69f3808c43ac1ec242391/watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a", size = 88386, upload-time = "2024-11-01T14:06:50.536Z" }, + { url = "https://files.pythonhosted.org/packages/ea/22/1c90b20eda9f4132e4603a26296108728a8bfe9584b006bd05dd94548853/watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", size = 89017, upload-time = "2024-11-01T14:06:51.717Z" }, { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/5b/79/69f2b0e8d3f2afd462029031baafb1b75d11bb62703f0e1022b2e54d49ee/watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa", size = 87903, upload-time = "2024-11-01T14:06:57.052Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2b/dc048dd71c2e5f0f7ebc04dd7912981ec45793a03c0dc462438e0591ba5d/watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e", size = 88381, upload-time = "2024-11-01T14:06:58.193Z" }, { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, @@ -1543,3 +1938,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] + +[[package]] +name = "wheel" +version = "0.45.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]