├── src └── attackcti │ ├── py.typed │ ├── utils │ ├── __init__.py │ ├── storage.py │ ├── downloader.py │ └── stix.py │ ├── __main__.py │ ├── attack_api.py │ ├── core │ ├── __init__.py │ ├── objects │ │ ├── __init__.py │ │ ├── tactics.py │ │ ├── mitigations.py │ │ ├── analytics.py │ │ ├── groups.py │ │ ├── software.py │ │ ├── campaigns.py │ │ ├── data_sources.py │ │ └── techniques.py │ └── query_client.py │ ├── domains │ ├── __init__.py │ ├── ics.py │ ├── mobile.py │ ├── enterprise.py │ └── base.py │ ├── constants.py │ ├── sources │ ├── __init__.py │ ├── local_loader.py │ ├── attack_source.py │ ├── taxii_loader.py │ └── resolver.py │ ├── __init__.py │ ├── cli.py │ ├── legacy.py │ └── client.py ├── docs ├── playground │ ├── requirements.txt │ ├── 12-Local_vs_TAXII_STIX_20_21.ipynb │ ├── 0-Download-ATTACK-STIX-Data.ipynb │ ├── 11-Initialize_Client_Local_STIX_data.ipynb │ └── 9-Explore_Campaigns.ipynb ├── logo.png ├── _toc.yml ├── _config.yml ├── intro.ipynb └── references.bib ├── requirements.txt ├── Dockerfile ├── tests ├── conftest.py ├── integration │ ├── conftest.py │ ├── test_detections_integration.py │ ├── test_groups_integration.py │ ├── test_techniques_integration.py │ └── test_relationships_integration.py ├── test_groups.py ├── test_detections.py ├── test_relationships.py ├── test_techniques.py ├── test_core_clients.py └── fixtures │ └── simple_attack_bundle.json ├── LICENSE ├── .gitignore ├── pyproject.toml ├── CODE_OF_CONDUCT.md ├── README.md ├── CONTRIBUTING.md └── CHANGELOG.md /src/attackcti/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/playground/requirements.txt: -------------------------------------------------------------------------------- 1 | ipykernel 2 | pandas 3 | attackcti 4 | altair -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OTRF/ATTACK-Python-Client/HEAD/docs/logo.png -------------------------------------------------------------------------------- /src/attackcti/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utilities used by the attackcti package.""" 2 | 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | stix2>=3.0.1 2 | taxii2-client>=2.3.0 3 | stix2-patterns>=2.0.0 4 | six>=1.17.0 5 | pydantic>=2.12.5 6 | requests>=2.32.5 7 | -------------------------------------------------------------------------------- /src/attackcti/__main__.py: -------------------------------------------------------------------------------- 1 | """`python -m attackcti` entrypoint.""" 2 | 3 | from .cli import main 4 | 5 | if __name__ == "__main__": 6 | raise SystemExit(main()) 7 | -------------------------------------------------------------------------------- /src/attackcti/attack_api.py: -------------------------------------------------------------------------------- 1 | """Backwards-compatible module.""" 2 | 3 | from .client import MitreAttackClient 4 | from .client import MitreAttackClient as attack_client 5 | 6 | __all__ = ["MitreAttackClient", "attack_client"] 7 | -------------------------------------------------------------------------------- /src/attackcti/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core helpers for querying and enriching ATT&CK STIX content.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .query_client import QueryClient 6 | 7 | __all__ = [ 8 | "QueryClient", 9 | ] 10 | 11 | -------------------------------------------------------------------------------- /src/attackcti/domains/__init__.py: -------------------------------------------------------------------------------- 1 | """Domain helper package (enterprise/mobile/ics).""" 2 | 3 | from .base import DomainClientBase 4 | from .enterprise import EnterpriseClient 5 | from .ics import ICSClient 6 | from .mobile import MobileClient 7 | 8 | __all__ = ["DomainClientBase", "EnterpriseClient", "MobileClient", "ICSClient"] 9 | 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ATTACK Python Client script: Jupyter Environment Dockerfile 2 | 3 | FROM jupyter/base-notebook 4 | 5 | RUN python3 -m pip install --upgrade six attackcti pandas altair vega 6 | 7 | COPY docs/intro.ipynb ${HOME}/docs/ 8 | COPY docs/playground ${HOME}/docs/playground 9 | COPY docs/presentations ${HOME}/docs/presentations 10 | 11 | USER ${NB_UID} 12 | WORKDIR ${HOME} 13 | -------------------------------------------------------------------------------- /src/attackcti/constants.py: -------------------------------------------------------------------------------- 1 | """Package constants.""" 2 | 3 | ATTACK_TAXII_COLLECTIONS_URL = "https://attack-taxii.mitre.org/api/v21/collections/" 4 | 5 | # TAXII collection identifiers published by MITRE ATT&CK. 6 | ENTERPRISE_ATTACK_COLLECTION_ID = "x-mitre-collection--1f5f1533-f617-4ca8-9ab4-6a02367fa019" 7 | MOBILE_ATTACK_COLLECTION_ID = "x-mitre-collection--dac0d2d7-8653-445c-9bff-82f934c1e858" 8 | ICS_ATTACK_COLLECTION_ID = "x-mitre-collection--90c00720-636b-4485-b342-8751d232bf09" 9 | 10 | -------------------------------------------------------------------------------- /src/attackcti/sources/__init__.py: -------------------------------------------------------------------------------- 1 | """Source (transport) implementations for attackcti.""" 2 | 3 | from .attack_source import MitreAttackSource 4 | from .local_loader import load_local_sources, load_stix_store 5 | from .resolver import load_sources 6 | from .taxii_loader import create_taxii_sources, load_taxii_sources 7 | 8 | __all__ = [ 9 | "create_taxii_sources", 10 | "load_local_sources", 11 | "MitreAttackSource", 12 | "load_sources", 13 | "load_stix_store", 14 | "load_taxii_sources", 15 | ] 16 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provide pytest fixtures for testing the ATTACK Python Client. 3 | 4 | Include a fixture for creating a MitreAttackClient instance backed by a 5 | local STIX bundle for deterministic testing. 6 | """ 7 | 8 | from pathlib import Path 9 | 10 | import pytest 11 | 12 | from attackcti import MitreAttackClient 13 | 14 | FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "simple_attack_bundle.json" 15 | 16 | 17 | @pytest.fixture(scope="module") 18 | def attack_client(): 19 | """Return a client backed by a small STIX bundle for deterministic tests.""" 20 | return MitreAttackClient.from_local(enterprise=str(FIXTURE_PATH)) 21 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration test configuration for the ATT&CK Python Client. 3 | 4 | This module provides pytest fixtures for integration tests, including: 5 | - real_client: A MitreAttackClient instance backed by real ATT&CK STIX bundles. 6 | """ 7 | 8 | import os 9 | 10 | import pytest 11 | 12 | from attackcti import MitreAttackClient 13 | 14 | 15 | @pytest.fixture(scope="session") 16 | def real_client(): 17 | """Provide a MitreAttackClient backed by the real ATT&CK STIX bundles.""" 18 | if os.getenv("RUN_INTEGRATION") not in {"1", "true", "True"}: 19 | pytest.skip("integration tests require RUN_INTEGRATION=1") 20 | return MitreAttackClient.from_attack_stix_data() 21 | -------------------------------------------------------------------------------- /src/attackcti/domains/ics.py: -------------------------------------------------------------------------------- 1 | """ICS ATT&CK domain client.""" 2 | 3 | from __future__ import annotations 4 | 5 | from stix2 import MemorySource, TAXIICollectionSource 6 | 7 | from .base import DomainClientBase 8 | 9 | 10 | class ICSClient(DomainClientBase): 11 | """ICS-domain client.""" 12 | 13 | def __init__( 14 | self, 15 | *, 16 | data_source: TAXIICollectionSource | MemorySource | None, 17 | ) -> None: 18 | super().__init__( 19 | data_source=data_source, 20 | ) 21 | 22 | def get_ics(self, stix_format: bool = True) -> dict[str, list[object]]: 23 | """Alias for `get` to preserve older naming.""" 24 | return self.get(stix_format=stix_format) -------------------------------------------------------------------------------- /tests/integration/test_detections_integration.py: -------------------------------------------------------------------------------- 1 | """Integration checks for Detections helpers using live ATT&CK data.""" 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.integration 7 | def test_real_data_components(real_client): 8 | """Verify detections return data components for a live technique.""" 9 | techniques = real_client.query.techniques.get_techniques() 10 | assert techniques, "Expected live techniques to exist" 11 | components = real_client.query.detections.get_data_components_by_technique_via_analytics( 12 | techniques[0].id, 13 | stix_format=True, 14 | ) 15 | assert isinstance(components, list) 16 | assert components, "Real ATT&CK data should expose components via analytics" 17 | -------------------------------------------------------------------------------- /src/attackcti/domains/mobile.py: -------------------------------------------------------------------------------- 1 | """Mobile ATT&CK domain client.""" 2 | 3 | from __future__ import annotations 4 | 5 | from stix2 import MemorySource, TAXIICollectionSource 6 | 7 | from .base import DomainClientBase 8 | 9 | 10 | class MobileClient(DomainClientBase): 11 | """Mobile-domain client.""" 12 | 13 | def __init__( 14 | self, 15 | *, 16 | data_source: TAXIICollectionSource | MemorySource | None, 17 | ) -> None: 18 | super().__init__( 19 | data_source=data_source, 20 | ) 21 | 22 | def get_mobile(self, stix_format: bool = True) -> dict[str, list[object]]: 23 | """Alias for `get` to preserve older naming.""" 24 | return self.get(stix_format=stix_format) 25 | -------------------------------------------------------------------------------- /tests/integration/test_groups_integration.py: -------------------------------------------------------------------------------- 1 | """Integration checks for GroupsClient using real ATT&CK data.""" 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.integration 7 | def test_real_group_alias_lookup(real_client): 8 | """Verify alias-based lookups return a real intrusion set.""" 9 | groups = real_client.query.groups.get_groups() 10 | assert groups, "Live ATT&CK data should expose intrusion-set entries" 11 | first_group = groups[0] 12 | alias = first_group.aliases[0] if getattr(first_group, "aliases", None) else first_group.name 13 | 14 | matches = real_client.query.groups.get_group_by_alias(alias, case=True) 15 | assert matches, "Alias lookup should find the group" 16 | assert matches[0].name == first_group.name 17 | -------------------------------------------------------------------------------- /src/attackcti/domains/enterprise.py: -------------------------------------------------------------------------------- 1 | """Enterprise ATT&CK domain client.""" 2 | 3 | from __future__ import annotations 4 | 5 | from stix2 import MemorySource, TAXIICollectionSource 6 | 7 | from .base import DomainClientBase 8 | 9 | 10 | class EnterpriseClient(DomainClientBase): 11 | """Enterprise-domain client.""" 12 | 13 | def __init__( 14 | self, 15 | *, 16 | data_source: TAXIICollectionSource | MemorySource | None, 17 | ) -> None: 18 | super().__init__( 19 | data_source=data_source, 20 | ) 21 | 22 | def get_enterprise(self, stix_format: bool = True) -> dict[str, list[object]]: 23 | """Alias for `get` to preserve older naming.""" 24 | return self.get(stix_format=stix_format) -------------------------------------------------------------------------------- /tests/test_groups.py: -------------------------------------------------------------------------------- 1 | """Validation for GroupsClient helpers using the fixture data.""" 2 | 3 | from attackcti.utils.stix import as_dict 4 | 5 | 6 | def test_get_groups_and_alias_match(attack_client): 7 | """GroupsClient should expose the fixture intrusion-set and support alias lookups.""" 8 | groups = attack_client.query.groups.get_groups() 9 | assert groups, "Expected fixture to contain at least one group" 10 | first = groups[0] 11 | assert first.name == "Sample Group" 12 | 13 | alias_matches = attack_client.query.groups.get_group_by_alias("Sample Group Alias", case=True) 14 | assert alias_matches, "Alias search should find the fixture group" 15 | alias_entry = as_dict(alias_matches[0]) 16 | assert alias_entry.get("aliases") and alias_entry["aliases"][0] == "Sample Group Alias" 17 | -------------------------------------------------------------------------------- /src/attackcti/core/objects/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialization of the attackcti.core.objects module.""" 2 | 3 | from .analytics import AnalyticsClient 4 | from .campaigns import CampaignsClient 5 | from .data_sources import DataSourcesClient 6 | from .detections import DetectionsClient 7 | from .groups import GroupsClient 8 | from .mitigations import MitigationsClient 9 | from .relationships import RelationshipsClient 10 | from .software import SoftwareClient 11 | from .tactics import TacticsClient 12 | from .techniques import TechniquesClient 13 | 14 | __all__ = [ 15 | "CampaignsClient", 16 | "DataSourcesClient", 17 | "AnalyticsClient", 18 | "DetectionsClient", 19 | "GroupsClient", 20 | "MitigationsClient", 21 | "RelationshipsClient", 22 | "SoftwareClient", 23 | "TacticsClient", 24 | "TechniquesClient", 25 | ] 26 | -------------------------------------------------------------------------------- /tests/integration/test_techniques_integration.py: -------------------------------------------------------------------------------- 1 | """Integration checks for Techniques helpers running on live ATT&CK data.""" 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.integration 7 | def test_real_technique_detection_walk(real_client): 8 | """Walk real detection data components via the Techniques client.""" 9 | techniques = real_client.query.techniques.get_techniques_by_data_components("Process") 10 | assert techniques, "Expected production data to include Process components" 11 | names = {tech.name for tech in techniques if hasattr(tech, "name")} 12 | assert any("Process" in n for n in names) 13 | 14 | 15 | @pytest.mark.integration 16 | def test_real_techniques_since_time(real_client): 17 | """Ensure time-based queries return recent techniques.""" 18 | recent = real_client.query.techniques.get_techniques_since_time(timestamp="2022-01-01T00:00:00.000Z") 19 | assert recent, "Recent techniques should be present" 20 | -------------------------------------------------------------------------------- /src/attackcti/__init__.py: -------------------------------------------------------------------------------- 1 | """attackcti package. 2 | 3 | This package exposes a small, stable public surface: 4 | - `MitreAttackClient`: main client class 5 | - `attack_client`: backwards-compatible alias 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from importlib import metadata 11 | from typing import TYPE_CHECKING, Any 12 | 13 | __all__ = ["AttackClient", "attack_client", "__version__"] 14 | 15 | try: 16 | __version__ = metadata.version("attackcti") 17 | except metadata.PackageNotFoundError: # pragma: no cover 18 | __version__ = "0.0.0" 19 | 20 | if TYPE_CHECKING: # pragma: no cover 21 | from .client import MitreAttackClient as AttackClient 22 | 23 | 24 | def __getattr__(name: str) -> Any: # PEP 562 25 | """Lazily expose selected public symbols.""" 26 | if name in {"MitreAttackClient", "attack_client"}: 27 | from .client import MitreAttackClient 28 | 29 | return MitreAttackClient 30 | raise AttributeError(name) 31 | -------------------------------------------------------------------------------- /tests/integration/test_relationships_integration.py: -------------------------------------------------------------------------------- 1 | """Integration checks for Relationships helpers against live ATT&CK data.""" 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.integration 7 | def test_export_real_layers(real_client, tmp_path): 8 | """Ensure navigator layers can be generated from real data.""" 9 | real_client.query.relationships.export_groups_navigator_layers(output_dir=str(tmp_path)) 10 | generated = list(tmp_path.glob("*.json")) 11 | assert generated, "Should export at least one navigator layer from real data" 12 | 13 | 14 | @pytest.mark.integration 15 | def test_real_uses_relationships(real_client): 16 | """Verify real group → technique uses relationships exist.""" 17 | groups = real_client.query.groups.get_groups() 18 | assert groups, "Live feed should expose intrusion-set groups" 19 | techniques = real_client.query.relationships.get_techniques_used_by_group(groups[0]) 20 | assert techniques, "Group should map to techniques" 21 | -------------------------------------------------------------------------------- /tests/test_detections.py: -------------------------------------------------------------------------------- 1 | """Exercise detection helpers and data-component lookups.""" 2 | 3 | from attackcti.utils.stix import as_dict 4 | 5 | 6 | def test_get_data_components_by_technique_via_analytics(attack_client): 7 | """Ensures data components can be returned for a technique via analytics.""" 8 | techniques = attack_client.query.techniques.get_techniques(enrich_detections=True, stix_format=True) 9 | assert techniques, "Fixture should expose a technique" 10 | technique = techniques[0] 11 | 12 | components = attack_client.query.detections.get_data_components_by_technique_via_analytics( 13 | technique.id, 14 | stix_format=True, 15 | ) 16 | assert components, "Data components must be linked to the technique" 17 | 18 | names = {comp.get("name") for comp in components} 19 | assert "Process Creation" in names 20 | 21 | component_dict = as_dict(components[0]) 22 | assert component_dict.get("id", "").startswith("x-mitre-data-component--") 23 | -------------------------------------------------------------------------------- /docs/_toc.yml: -------------------------------------------------------------------------------- 1 | # Table of content 2 | # Learn more at https://jupyterbook.org/customize/toc.html 3 | 4 | format: jb-book 5 | root: intro 6 | parts: 7 | - caption: Playground 8 | chapters: 9 | - file: playground/0-Download-ATTACK-STIX-data 10 | - file: playground/1-Collect_All_Functions 11 | - file: playground/2-Collect_Matrix_Specific_Functions 12 | - file: playground/3-Export_All_Techniques_To_CSV 13 | - file: playground/4-Explore_Data_Sources 14 | - file: playground/5-Collect_Techniques_by_Data_Sources 15 | - file: playground/6-Explore_ICS_Attack 16 | - file: playground/7-Export_Groups_Navigator_Layers 17 | - file: playground/8-Lookup_Functions 18 | - file: playground/9-Explore_Campaigns 19 | - file: playground/10-Export_All_Techniques_To_YAML 20 | - file: playground/11-Initialize_Client_Local_STIX_data 21 | - file: playground/12-Local_vs_TAXII_STIX_20_21 22 | - caption: Presentations 23 | chapters: 24 | - file: presentations/1-SANS_CTI_Summit_2022_Explorando_Fuentes_Componentes_Datos 25 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Book settings 2 | # Learn more at https://jupyterbook.org/customize/config.html 3 | 4 | title: ATTACK Python Client 5 | author: Roberto Rodriguez @Cyb3rWard0g 6 | logo: logo.png 7 | 8 | # Force re-execution of notebooks on each build. 9 | # See https://jupyterbook.org/content/execute.html 10 | execute: 11 | execute_notebooks: 'off' 12 | 13 | # Define the name of the latex output file for PDF builds 14 | latex: 15 | latex_documents: 16 | targetname: book.tex 17 | 18 | # Add a bibtex file so that we can create citations 19 | #bibtex_bibfiles: 20 | # - references.bib 21 | 22 | # Information about where the book exists on the web 23 | repository: 24 | url: https://github.com/OTRF/ATTACK-Python-Client # Online location of your book 25 | path_to_book: docs # Optional path to your book, relative to the repository root 26 | branch: master # Which branch of the repository should be used when creating links (optional) 27 | 28 | # Add GitHub buttons to your book 29 | # See https://jupyterbook.org/customize/config.html#add-a-link-to-your-repository 30 | html: 31 | home_page_in_navbar: false 32 | use_edit_page_button: true 33 | use_repository_button: true 34 | use_issues_button: true 35 | baseurl: https://attackcti.com/ 36 | 37 | launch_buttons: 38 | notebook_interface: "classic" # The interface interactive links will activate ["classic", "jupyterlab"] 39 | binderhub_url: "https://mybinder.org" -------------------------------------------------------------------------------- /src/attackcti/core/objects/tactics.py: -------------------------------------------------------------------------------- 1 | """Cross-domain tactic query helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Callable, Dict, List 6 | 7 | from stix2 import CompositeDataSource, Filter 8 | 9 | from ...models import Tactic 10 | from ...utils.stix import parse_stix_objects 11 | 12 | 13 | class TacticsClient: 14 | """Tactic query helper class.""" 15 | 16 | def __init__( 17 | self, 18 | *, 19 | data_source: CompositeDataSource, 20 | parse_fn: Callable = parse_stix_objects 21 | ) -> None: 22 | """Initialize the client with a composite data source.""" 23 | self._data_source = data_source 24 | self._parse_fn = parse_fn 25 | 26 | def get_tactics(self, *, stix_format: bool = True) -> List[Dict[str, Any]]: 27 | """Return tactics across all domains. 28 | 29 | Parameters 30 | ---------- 31 | stix_format : bool, optional 32 | When `True`, return STIX objects/dicts; when `False`, parse to the 33 | `Tactic` Pydantic model. 34 | 35 | Returns 36 | ------- 37 | list[dict[str, Any]] 38 | Tactic objects in the requested format. 39 | """ 40 | all_tactics = self._data_source.query([Filter("type", "=", "x-mitre-tactic")]) 41 | if not stix_format: 42 | all_tactics = self._parse_fn(all_tactics, Tactic) 43 | return all_tactics 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, Roberto Rodriguez 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build/ 2 | docs/source/ 3 | repos/ 4 | .attackcti/ 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | .static_storage/ 61 | .media/ 62 | local_settings.py 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | 111 | # intellij 112 | .idea/ 113 | 114 | .DS_Store 115 | .history 116 | -------------------------------------------------------------------------------- /src/attackcti/core/objects/mitigations.py: -------------------------------------------------------------------------------- 1 | """Cross-domain mitigation query helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Callable, Dict, List, Union 6 | 7 | from stix2 import CompositeDataSource, Filter 8 | from stix2.v21.sdo import CourseOfAction as CourseOfActionV21 9 | 10 | from ...models import Mitigation as MitigationModel 11 | 12 | 13 | class MitigationsClient: 14 | """Mitigation query helper class.""" 15 | 16 | def __init__( 17 | self, 18 | *, 19 | data_source: CompositeDataSource, 20 | remove_fn: Callable | None = None, 21 | parse_fn: Callable | None = None, 22 | ) -> None: 23 | """Initialize the client with a composite data source.""" 24 | self._data_source = data_source 25 | self._remove_fn = remove_fn 26 | self._parse_fn = parse_fn 27 | 28 | def get_mitigations(self, *, skip_revoked_deprecated: bool = True, stix_format: bool = True) -> List[Union[CourseOfActionV21, Dict[str, Any]]]: 29 | """Return course-of-action mitigations across domains. 30 | 31 | Parameters 32 | ---------- 33 | skip_revoked_deprecated : bool, optional 34 | When `True`, omit revoked/deprecated mitigations. 35 | stix_format : bool, optional 36 | When `True`, return STIX objects/dicts; when `False`, parse to the 37 | `Mitigation` Pydantic model. 38 | 39 | Returns 40 | ------- 41 | list[CourseOfActionV21 | dict[str, Any]] 42 | Mitigation objects in the requested format. 43 | """ 44 | all_mitigations = self._data_source.query([Filter("type", "=", "course-of-action")]) 45 | if skip_revoked_deprecated: 46 | all_mitigations = self._remove_fn(all_mitigations) 47 | if not stix_format: 48 | all_mitigations = self._parse_fn(all_mitigations, MitigationModel) 49 | return all_mitigations 50 | -------------------------------------------------------------------------------- /src/attackcti/utils/storage.py: -------------------------------------------------------------------------------- 1 | """Local STIX bundle loading helpers.""" 2 | 3 | from pathlib import Path 4 | 5 | from stix2 import MemorySource 6 | 7 | from .stix import find_json_files, load_stix_json_files 8 | 9 | 10 | class STIXStore: 11 | """Load a STIX bundle from a directory or JSON file.""" 12 | 13 | def __init__(self, path: str, auto_load: bool = True): 14 | """Initialize the store wrapper. 15 | 16 | Args: 17 | path (str): Path to the source directory or JSON file. 18 | auto_load (bool): Flag indicating whether to automatically load data during initialization. Defaults to True. 19 | """ 20 | self.path = Path(path) 21 | self.source = None 22 | self.spec_version: str | None = None 23 | 24 | if auto_load: 25 | self.load_data() 26 | 27 | def load_data(self): 28 | """Load STIX objects from the configured path. 29 | 30 | Raises 31 | ------ 32 | ValueError: If the path is invalid or not specified correctly. 33 | """ 34 | if self.path.is_dir(): 35 | json_files = find_json_files(self.path) 36 | if not json_files: 37 | raise ValueError(f"The specified path {self.path} contains no JSON files.") 38 | loaded = load_stix_json_files(json_files) 39 | self.source = MemorySource(stix_data=loaded.objects) 40 | self.spec_version = loaded.spec_version 41 | elif self.path.is_file() and self.path.suffix == '.json': 42 | loaded = load_stix_json_files([self.path]) 43 | self.source = MemorySource(stix_data=loaded.objects) 44 | self.spec_version = loaded.spec_version 45 | else: 46 | raise ValueError(f"The specified path {self.path} is not a valid directory or JSON file.") 47 | 48 | def get_store(self): 49 | """Return the loaded data store. 50 | 51 | Returns 52 | ------- 53 | The loaded data store (FileSystemSource or MemorySource). 54 | """ 55 | if self.source is None: 56 | raise ValueError("Data has not been loaded yet. Call load_data() first.") 57 | return self.source 58 | -------------------------------------------------------------------------------- /src/attackcti/sources/local_loader.py: -------------------------------------------------------------------------------- 1 | """Local STIX bundle source helpers. 2 | 3 | This module centralizes loading local STIX JSON bundles (files or directories) 4 | into STIX2 data sources via `attackcti.utils.storage.STIXStore`. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import os 10 | from typing import Any 11 | 12 | from ..utils.storage import STIXStore 13 | 14 | 15 | def load_stix_store(path: str | None) -> tuple[Any | None, str | None]: 16 | """Load a STIX store from a directory or JSON file path. 17 | 18 | Args: 19 | path: Path to a directory of JSON files or a single STIX JSON file. If 20 | `None` or not found on disk, returns `(None, None)`. 21 | 22 | Returns 23 | ------- 24 | tuple 25 | A `(source, spec_version)` tuple. If `path` is missing, returns 26 | `(None, None)`. 27 | """ 28 | if path and os.path.exists(path): 29 | store = STIXStore(path) 30 | return store.get_store(), store.spec_version 31 | return None, None 32 | 33 | 34 | def load_local_sources( 35 | *, 36 | enterprise: str | None = None, 37 | mobile: str | None = None, 38 | ics: str | None = None, 39 | ) -> tuple[tuple[Any | None, Any | None, Any | None], dict[str, str | None]]: 40 | """Load local sources for enterprise, mobile, and ICS. 41 | 42 | Args: 43 | enterprise: Path to the local enterprise bundle (dir or JSON file). 44 | mobile: Path to the local mobile bundle (dir or JSON file). 45 | 46 | Returns 47 | ------- 48 | tuple 49 | `((enterprise_source, mobile_source, ics_source), versions)` where 50 | `versions` maps `enterprise|mobile|ics` to detected spec versions (or 51 | `None` when unavailable). 52 | `versions` maps `enterprise|mobile|ics` to detected spec versions (or 53 | `None` when unavailable). 54 | """ 55 | enterprise_source, enterprise_ver = load_stix_store(enterprise) 56 | mobile_source, mobile_ver = load_stix_store(mobile) 57 | ics_source, ics_ver = load_stix_store(ics) 58 | 59 | versions = { 60 | "enterprise": enterprise_ver, 61 | "mobile": mobile_ver, 62 | "ics": ics_ver, 63 | } 64 | return (enterprise_source, mobile_source, ics_source), versions 65 | -------------------------------------------------------------------------------- /src/attackcti/core/objects/analytics.py: -------------------------------------------------------------------------------- 1 | """Cross-domain analytics query helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Callable, Iterable 6 | 7 | from stix2 import CompositeDataSource, Filter 8 | 9 | from ...models import Analytic as AnalyticModel 10 | from ...utils.stix import ( 11 | as_dict, 12 | parse_stix_objects, 13 | query_stix_objects_by_ids, 14 | remove_revoked_deprecated, 15 | ) 16 | 17 | 18 | class AnalyticsClient: 19 | """Cross-domain analytics client (COMPOSITE_DS-backed).""" 20 | 21 | def __init__( 22 | self, 23 | *, 24 | data_source: CompositeDataSource, 25 | remove_fn: Callable = remove_revoked_deprecated, 26 | parse_fn: Callable = parse_stix_objects, 27 | ) -> None: 28 | """Initialize the client with a data source and helper callbacks.""" 29 | self._data_source = data_source 30 | self._remove_fn = remove_fn 31 | self._parse_fn = parse_fn 32 | 33 | def get_analytics(self, *, stix_format: bool = True) -> list[dict[str, Any]]: 34 | """Return all analytic objects.""" 35 | analytics = self._data_source.query(Filter("type", "=", "x-mitre-analytic")) 36 | if not stix_format: 37 | return self._parse_fn(analytics, AnalyticModel) 38 | return [as_dict(a) for a in analytics] 39 | 40 | def get_analytics_by_ids(self, ids: Iterable[str]) -> dict[str, dict[str, Any]]: 41 | """Return analytics keyed by their STIX id.""" 42 | analytics_dict: dict[str, dict[str, Any]] = {} 43 | analytic_ids = {aid for aid in ids if isinstance(aid, str) and aid} 44 | if not analytic_ids: 45 | return analytics_dict 46 | 47 | analytics = query_stix_objects_by_ids( 48 | data_source=self._data_source, 49 | stix_type="x-mitre-analytic", 50 | ids=analytic_ids 51 | ) 52 | for analytic in analytics: 53 | analytic_dict = as_dict(analytic) 54 | analytic_id = analytic_dict.get("id") 55 | if not isinstance(analytic_id, str) or not analytic_id: 56 | continue 57 | log_sources = analytic_dict.get("x_mitre_log_source_references") or [] 58 | analytic_dict["x_attackcti_log_sources"] = [ls for ls in log_sources if isinstance(ls, dict)] 59 | analytics_dict[analytic_id] = analytic_dict 60 | return analytics_dict 61 | -------------------------------------------------------------------------------- /src/attackcti/sources/attack_source.py: -------------------------------------------------------------------------------- 1 | """Wrapper that encapsulates loaded ATT&CK data sources.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import Any 7 | 8 | from stix2 import CompositeDataSource 9 | 10 | from .resolver import load_sources 11 | 12 | 13 | @dataclass 14 | class MitreAttackSource: 15 | """Container for ATT&CK STIX sources. 16 | 17 | Attributes 18 | ---------- 19 | enterprise 20 | Domain-scoped source for Enterprise ATT&CK (or None if not loaded). 21 | mobile 22 | Domain-scoped source for Mobile ATT&CK (or None if not loaded). 23 | ics 24 | Domain-scoped source for ICS ATT&CK (or None if not loaded). 25 | composite 26 | `CompositeDataSource` containing all loaded domain sources. 27 | versions 28 | Mapping of domain -> spec version (or None). 29 | mode 30 | One of local, taxii, mixed, empty. 31 | spec_version 32 | Unified spec version if all domains match, else None. 33 | """ 34 | 35 | enterprise: Any | None 36 | mobile: Any | None 37 | ics: Any | None 38 | composite: CompositeDataSource 39 | versions: dict[str, str | None] 40 | mode: str 41 | spec_version: str | None 42 | 43 | @classmethod 44 | def load( 45 | cls, 46 | *, 47 | enterprise: str | None = None, 48 | mobile: str | None = None, 49 | ics: str | None = None, 50 | connect_taxii: bool = True, 51 | proxies: dict | None = None, 52 | verify: bool = True, 53 | collection_url: str | None = None, 54 | ) -> "MitreAttackSource": 55 | """Load ATT&CK sources and return a container.""" 56 | (ent, mob, ics_src), versions, mode, spec_version = load_sources( 57 | enterprise=enterprise, 58 | mobile=mobile, 59 | ics=ics, 60 | connect_taxii=connect_taxii, 61 | proxies=proxies, 62 | verify=verify, 63 | collection_url=collection_url, 64 | ) 65 | composite = CompositeDataSource() 66 | composite.add_data_sources([ds for ds in (ent, mob, ics_src) if ds is not None]) 67 | return cls( 68 | enterprise=ent, 69 | mobile=mob, 70 | ics=ics_src, 71 | composite=composite, 72 | versions=versions, 73 | mode=mode, 74 | spec_version=spec_version, 75 | ) 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "attackcti" 3 | version = "0.6.4" 4 | description = "MITRE ATT&CK CTI Python library" 5 | readme = "README.md" 6 | license = { file = "LICENSE" } 7 | authors = [ 8 | { name = "Cyb3rWard0g", email = "9653181+Cyb3rWard0g@users.noreply.github.com" } 9 | ] 10 | requires-python = ">=3.9" 11 | keywords = [ 12 | "threat hunting", 13 | "dfir", 14 | "cti", 15 | "cyber threat intelligence", 16 | "mitre att&ck", 17 | ] 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Operating System :: OS Independent", 21 | "Topic :: Security", 22 | "License :: OSI Approved :: BSD License", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3 :: Only", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | ] 31 | dependencies = [ 32 | "pydantic>=2.12.5", 33 | "requests>=2.32.5", 34 | "six>=1.17.0", 35 | "stix2>=3.0.1", 36 | "stix2-patterns>=2.0.0", 37 | "taxii2-client>=2.3.0", 38 | ] 39 | 40 | [project.urls] 41 | Documentation = "https://attackcti.com" 42 | Code = "https://github.com/OTRF/ATTACK-Python-Client" 43 | "Issue tracker" = "https://github.com/OTRF/ATTACK-Python-Client/issues" 44 | 45 | [project.optional-dependencies] 46 | dev = [ 47 | "pytest>=8.4.2", 48 | "pytest-asyncio>=0.23", 49 | "pyright>=1.1.407", 50 | "ruff>=0.14.6", 51 | "black", 52 | "anyio>=4", 53 | "build", 54 | "twine", 55 | "packaging", 56 | "setuptools", 57 | "setuptools_scm", 58 | "wheel", 59 | ] 60 | 61 | [project.scripts] 62 | attackcti = "attackcti.cli:main" 63 | 64 | [build-system] 65 | requires = ["hatchling"] 66 | build-backend = "hatchling.build" 67 | 68 | [tool.hatch.build.targets.wheel] 69 | packages = ["src/attackcti"] 70 | include = ["src/attackcti/py.typed"] 71 | 72 | [tool.ruff] 73 | line-length = 120 74 | extend-exclude = [ 75 | "docs/playground", 76 | ] 77 | 78 | [tool.ruff.lint] 79 | select = ["E", "F", "I", "D", "B"] 80 | ignore = ["E501"] 81 | 82 | [tool.ruff.lint.pydocstyle] 83 | convention = "numpy" 84 | 85 | [dependency-groups] 86 | dev = [ 87 | "ruff>=0.14.9", 88 | ] 89 | 90 | [tool.pytest.ini_options] 91 | testpaths = ["tests"] 92 | python_files = ["test_*.py"] 93 | addopts = "-ra" 94 | pythonpath = ["src"] 95 | norecursedirs = ["repos", ".venv"] 96 | markers = [ 97 | "integration: tests that spin a real local ipykernel; require RUN_INTEGRATION=1", 98 | ] 99 | -------------------------------------------------------------------------------- /src/attackcti/cli.py: -------------------------------------------------------------------------------- 1 | """Command-line interface for attackcti. 2 | 3 | This is intentionally small and dependency-light (stdlib only). It provides a thin wrapper 4 | over `attackcti.utils.downloader.STIXDownloader`. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import argparse 10 | import sys 11 | 12 | 13 | def _build_parser() -> argparse.ArgumentParser: 14 | """Build the top-level CLI parser.""" 15 | parser = argparse.ArgumentParser(prog="attackcti", description="Utilities for working with MITRE ATT&CK CTI data.") 16 | subparsers = parser.add_subparsers(dest="command", required=True) 17 | 18 | download = subparsers.add_parser("download", help="Download ATT&CK STIX bundles from upstream sources.") 19 | download.add_argument("--download-dir", "-d", required=True, help="Directory to write downloaded files into.") 20 | download.add_argument( 21 | "--stix-version", 22 | choices=("2.0", "2.1"), 23 | default="2.1", 24 | help="STIX version to download.", 25 | ) 26 | download.add_argument( 27 | "--domain", 28 | choices=("enterprise", "mobile", "ics"), 29 | help="Domain to download (omit to use --all-domains).", 30 | ) 31 | download.add_argument("--release", help="Specific ATT&CK release (omit for latest).") 32 | download.add_argument("--pretty-print", action="store_true", help="Pretty-print JSON after download.") 33 | download.add_argument("--all-domains", action="store_true", help="Download enterprise, mobile, and ics.") 34 | 35 | return parser 36 | 37 | 38 | def main(argv: list[str] | None = None) -> int: 39 | """CLI entrypoint for the `attackcti` command.""" 40 | parser = _build_parser() 41 | args = parser.parse_args(argv) 42 | 43 | if args.command == "download": 44 | from .utils.downloader import STIXDownloader 45 | 46 | if not args.all_domains and not args.domain: 47 | parser.error("Provide --domain or use --all-domains.") 48 | if args.all_domains and args.domain: 49 | parser.error("--domain and --all-domains are mutually exclusive.") 50 | 51 | downloader = STIXDownloader(download_dir=args.download_dir) 52 | if args.all_domains: 53 | downloader.download_all_domains( 54 | stix_version=args.stix_version, 55 | release=args.release, 56 | pretty_print=args.pretty_print, 57 | ) 58 | else: 59 | downloader.download_attack_data( 60 | stix_version=args.stix_version, 61 | domain=args.domain, 62 | release=args.release, 63 | pretty_print=args.pretty_print, 64 | ) 65 | return 0 66 | 67 | parser.error(f"Unknown command: {args.command}") 68 | return 2 69 | 70 | 71 | if __name__ == "__main__": # pragma: no cover 72 | raise SystemExit(main(sys.argv[1:])) 73 | -------------------------------------------------------------------------------- /tests/test_relationships.py: -------------------------------------------------------------------------------- 1 | """Verify relationship helpers with the tiny fixture bundle.""" 2 | 3 | import json 4 | 5 | from attackcti.utils.stix import as_dict 6 | 7 | 8 | def test_get_techniques_used_by_all_groups(attack_client): 9 | """Ensure the group → technique lookup returns an enriched record.""" 10 | results = attack_client.query.relationships.get_techniques_used_by_all_groups(stix_format=False) 11 | assert results, "Expected at least one group-technique usage record" 12 | 13 | sample = results[0] 14 | assert sample["technique_id"] == "T0001" 15 | assert sample["technique"] == "Sample Technique" 16 | assert sample["technique_revoked"] is False 17 | 18 | 19 | def test_export_groups_navigator_layers(tmp_path, attack_client): 20 | """Export navigator layers for the fixture and validate the JSON.""" 21 | attack_client.query.relationships.export_groups_navigator_layers(output_dir=str(tmp_path)) 22 | exports = list(tmp_path.glob("*.json")) 23 | assert exports, "Navigator layers must be created" 24 | 25 | payload = json.loads(exports[0].read_text()) 26 | techniques = payload.get("techniques") or [] 27 | assert techniques[0]["techniqueID"] == "T0001" 28 | assert techniques[0]["techniqueName"] == "Sample Technique" 29 | 30 | 31 | def test_get_techniques_used_by_group_returns_sample(attack_client): 32 | """Call the helper with the sample group and expect the technique.""" 33 | groups = attack_client.query.groups.get_groups() 34 | assert groups, "Fixture should expose at least one group" 35 | sample_group = groups[0] 36 | 37 | techniques = attack_client.query.relationships.get_techniques_used_by_group(sample_group, stix_format=False) 38 | assert techniques, "Expected the sample group to use the sample technique" 39 | technique = techniques[0] 40 | assert technique["external_references"][0]["external_id"] == "T0001" 41 | 42 | 43 | def test_get_techniques_mitigated_by_mitigations_returns_sample(attack_client): 44 | """Ensure the mitigation helper finds the technique referenced by the fixture.""" 45 | mitigations = attack_client.query.mitigations.get_mitigations() 46 | assert mitigations, "Fixture must contain a mitigation" 47 | 48 | results = attack_client.query.relationships.get_techniques_mitigated_by_mitigations( 49 | mitigations[0], 50 | stix_format=False, 51 | ) 52 | assert results, "Mitigation should reference the sample technique" 53 | assert results[0]["external_references"][0]["external_id"] == "T0001" 54 | 55 | 56 | def test_get_techniques_used_by_software(attack_client): 57 | """Verify the software usage helper can trace back to the sample technique.""" 58 | software = attack_client.query.software.get_software() 59 | assert software, "Fixture should include at least one software object" 60 | 61 | techniques = attack_client.query.relationships.get_techniques_used_by_software( 62 | software[0], 63 | stix_format=False, 64 | ) 65 | assert techniques, "Sample software should exercise the sample technique" 66 | technique = as_dict(techniques[0]) 67 | assert technique["id"].startswith("attack-pattern--") 68 | -------------------------------------------------------------------------------- /src/attackcti/core/objects/groups.py: -------------------------------------------------------------------------------- 1 | """Cross-domain group query helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Callable, Dict, List, Union 6 | 7 | from stix2 import CompositeDataSource, Filter 8 | from stix2.v21.sdo import IntrusionSet as IntrusionSetV21 9 | 10 | from ...models import Group as GroupModel 11 | 12 | 13 | class GroupsClient: 14 | """Group query helper class.""" 15 | 16 | def __init__( 17 | self, 18 | *, 19 | data_source: CompositeDataSource, 20 | remove_fn: Callable | None = None, 21 | parse_fn: Callable | None = None, 22 | ) -> None: 23 | """Initialize the client with a composite data source.""" 24 | self._data_source = data_source 25 | self._remove_fn = remove_fn 26 | self._parse_fn = parse_fn 27 | 28 | def get_groups(self, *, skip_revoked_deprecated: bool = True, stix_format: bool = True) -> List[Union[IntrusionSetV21, Dict[str, Any]]]: 29 | """Return all intrusion-set groups across domains. 30 | 31 | Parameters 32 | ---------- 33 | skip_revoked_deprecated 34 | When `True`, omit revoked/deprecated groups. 35 | stix_format 36 | When `True`, return STIX objects; when `False`, parse to the `Group` 37 | Pydantic model. 38 | 39 | Returns 40 | ------- 41 | List[Union[IntrusionSetV21, Dict[str, Any]]] 42 | Intrusion-set group objects in the requested format. 43 | """ 44 | groups = self._data_source.query([Filter("type", "=", "intrusion-set")]) 45 | if skip_revoked_deprecated: 46 | groups = self._remove_fn(groups) 47 | if not stix_format: 48 | groups = self._parse_fn(groups, GroupModel) 49 | return groups 50 | 51 | 52 | def get_group_by_alias(self, alias: str, *, case: bool = True, stix_format: bool = True) -> List[Union[IntrusionSetV21, Dict[str, Any]]]: 53 | """Return groups matching a provided alias. 54 | 55 | Parameters 56 | ---------- 57 | alias 58 | Alias to match. 59 | case 60 | When `True`, perform case-sensitive match; otherwise performs 61 | case-insensitive containment match. 62 | stix_format 63 | When `True`, return STIX objects; when `False`, parse to the `Group` 64 | Pydantic model. 65 | 66 | Returns 67 | ------- 68 | List[Union[IntrusionSetV21, Dict[str, Any]]] 69 | Matching group objects in the requested format. 70 | """ 71 | if not case: 72 | groups = self.get_groups(stix_format=True) 73 | out: list[Any] = [] 74 | for group in groups: 75 | if "aliases" in group.keys(): 76 | for group_alias in group["aliases"]: 77 | if alias.lower() in group_alias.lower(): 78 | out.append(GroupModel) 79 | else: 80 | filter_objects = [Filter("type", "=", "intrusion-set"), Filter("aliases", "=", alias)] 81 | out = self._data_source.query(filter_objects) 82 | if not stix_format: 83 | out = self._parse_fn(out, GroupModel) 84 | return out 85 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | -------------------------------------------------------------------------------- /tests/test_techniques.py: -------------------------------------------------------------------------------- 1 | """Confirm technique helpers behave as expected against the fixture.""" 2 | 3 | from attackcti.utils.stix import as_dict 4 | 5 | 6 | def test_get_techniques_by_data_components(attack_client): 7 | """Request techniques by data components and inspect detection enrichments.""" 8 | techniques = attack_client.query.techniques.get_techniques_by_data_components("Process") 9 | assert techniques, "Expected at least one technique for the fixture" 10 | 11 | technique = as_dict(techniques[0]) 12 | assert technique["name"] == "Sample Technique" 13 | 14 | strategies = technique.get("x_attackcti_detection_strategies") 15 | assert strategies, "Detection enrichment must attach strategies" 16 | 17 | analytics = strategies[0].get("x_attackcti_analytics") 18 | assert analytics, "Analytics should be populated" 19 | 20 | log_sources = analytics[0].get("x_attackcti_log_sources") 21 | assert log_sources, "Log sources are expected" 22 | 23 | data_component = log_sources[0].get("x_attackcti_data_component") 24 | assert data_component and data_component.get("name") == "Process Creation" 25 | 26 | 27 | def test_get_technique_by_name_case_insensitive_and_sensitive(attack_client): 28 | """Verify case-sensitive and case-insensitive name queries.""" 29 | exact_match = attack_client.query.techniques.get_technique_by_name( 30 | "Sample Technique", 31 | case=True, 32 | stix_format=False, 33 | ) 34 | assert exact_match, "Exact name match should return the technique" 35 | 36 | partial_match = attack_client.query.techniques.get_technique_by_name( 37 | "Sample", 38 | case=False, 39 | stix_format=False, 40 | ) 41 | assert partial_match, "Case-insensitive containment search should succeed" 42 | 43 | 44 | def test_get_techniques_by_content_and_platform(attack_client): 45 | """Search descriptions and platforms using both helpers.""" 46 | by_content = attack_client.query.techniques.get_techniques_by_content(content="synthetic", stix_format=False) 47 | assert by_content, "Content search should find the sample technique" 48 | 49 | by_platform_sensitive = attack_client.query.techniques.get_techniques_by_platform(name="Windows") 50 | assert by_platform_sensitive, "Platform filter with contains should find the technique" 51 | 52 | by_platform_insensitive = attack_client.query.techniques.get_techniques_by_platform( 53 | name="windows", 54 | case=False, 55 | stix_format=False, 56 | ) 57 | assert by_platform_insensitive, "Case-insensitive platform search should still match" 58 | 59 | 60 | def test_get_techniques_by_tactic_and_since_time(attack_client): 61 | """Ensure tactic and time filters are functioning.""" 62 | by_tactic_sensitive = attack_client.query.techniques.get_techniques_by_tactic(name="execution") 63 | assert by_tactic_sensitive, "Tactic filter should return the technique" 64 | 65 | by_tactic_insensitive = attack_client.query.techniques.get_techniques_by_tactic( 66 | name="Execution", 67 | case=False, 68 | stix_format=False, 69 | ) 70 | assert by_tactic_insensitive, "Case-insensitive tactic lookup should still succeed" 71 | 72 | since_time = attack_client.query.techniques.get_techniques_since_time(timestamp="2023-01-01T00:00:00.000Z") 73 | assert since_time, "Timestamp filter should include the sample technique" 74 | -------------------------------------------------------------------------------- /tests/test_core_clients.py: -------------------------------------------------------------------------------- 1 | """Cover analytics, campaigns, data sources, groups, software, and tactic helpers.""" 2 | 3 | import pytest 4 | 5 | 6 | def test_analytics_helpers(attack_client): 7 | """Analytics client should return the fixture analytic and allow lookups by id.""" 8 | analytics = attack_client.query.analytics.get_analytics() 9 | assert analytics, "Analytics helper should surface data from the fixture" 10 | 11 | analytic_id = analytics[0]["id"] 12 | analytics_by_id = attack_client.query.analytics.get_analytics_by_ids([analytic_id]) 13 | assert analytic_id in analytics_by_id 14 | entry = analytics_by_id[analytic_id] 15 | assert entry["name"] == "Sample Analytic" 16 | assert entry.get("x_attackcti_log_sources"), "Enriched log sources should be present" 17 | 18 | 19 | def test_campaign_helpers(attack_client): 20 | """Campaign helpers should filter by alias and timestamp.""" 21 | campaigns = attack_client.query.campaigns.get_campaigns() 22 | assert campaigns, "Fixture must include a campaign" 23 | 24 | alias_match = attack_client.query.campaigns.get_campaign_by_alias(alias="Sample Campaign Alias", case=True, stix_format=False) 25 | assert alias_match, "Case-insensitive alias search should find the campaign" 26 | 27 | since_time = attack_client.query.campaigns.get_campaigns_since_time(timestamp="2023-01-01T00:00:00.000Z") 28 | assert since_time, "Campaign should appear in since-time results" 29 | 30 | 31 | def test_data_source_helpers(attack_client): 32 | """Data source helpers should return components and emit the deprecation warning.""" 33 | components = attack_client.query.data_sources.get_data_components() 34 | assert components, "Fixture provides data components" 35 | 36 | first_id = components[0]["id"] 37 | selected = attack_client.query.data_sources.get_data_components_by_ids([first_id], stix_format=False) 38 | assert selected and selected[0]["id"] == first_id 39 | 40 | with pytest.warns(DeprecationWarning): 41 | sources = attack_client.query.data_sources.get_data_sources(stix_format=True) 42 | assert sources, "Deprecated data sources should still be retrievable" 43 | assert isinstance(sources[0], dict) 44 | 45 | 46 | def test_group_helpers(attack_client): 47 | """Group helpers should respect alias lookups.""" 48 | groups = attack_client.query.groups.get_groups() 49 | assert groups, "Fixture exposes at least one group" 50 | 51 | alias_match = attack_client.query.groups.get_group_by_alias("Sample Group Alias", case=True) 52 | assert alias_match, "Alias search should resolve the group" 53 | assert alias_match[0].name == "Sample Group" 54 | 55 | 56 | def test_software_helpers(attack_client): 57 | """Software helper should return malware and tool entries.""" 58 | software_all = attack_client.query.software.get_software() 59 | assert software_all, "Fixture should expose malware/tool objects" 60 | 61 | malware = attack_client.query.software.get_malware() 62 | assert malware and malware[0]["type"] == "malware" 63 | 64 | tools = attack_client.query.software.get_tools() 65 | assert tools and tools[0]["type"] == "tool" 66 | 67 | 68 | def test_tactics_helper(attack_client): 69 | """Ensure tactics client can return fixture tactics.""" 70 | tactics = attack_client.query.tactics.get_tactics() 71 | assert tactics, "Fixture contains at least one tactic" 72 | tactic = tactics[0] 73 | assert tactic["name"] == "Execution" 74 | assert tactic["x_mitre_shortname"] == "execution" 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ATT&CK Python Client 2 | 3 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OTRF/ATTACK-Python-Client/master) 4 | [![Open_Threat_Research Community](https://img.shields.io/badge/Open_Threat_Research-Community-brightgreen.svg)](https://twitter.com/OTR_Community) 5 | [![Open Source Love svg1](https://badges.frapsoft.com/os/v3/open-source.svg?v=103)](https://github.com/ellerbrock/open-source-badges/) 6 | [![Downloads](https://pepy.tech/badge/attackcti)](https://pepy.tech/project/attackcti) 7 | 8 | A Python module to access up-to-date ATT&CK content available in [STIX](https://oasis-open.github.io/cti-documentation/stix/intro) via a public [TAXII](https://oasis-open.github.io/cti-documentation/taxii/intro) server. This project leverages python classes and functions from the [cti-python-stix2](https://github.com/oasis-open/cti-python-stix2) and [cti-taxii-client](https://github.com/oasis-open/cti-taxii-client) libraries developed by MITRE. 9 | 10 | ## Goals 11 | 12 | * Provide an easy way to access and interact with up-to-date ATT&CK content available in STIX via public TAXII server. 13 | * Allow security analysts to quickly explore ATT&CK content and apply it in their daily operations. 14 | * Allow the integration of ATT&CK content with other platforms to host up to date information from the framework. 15 | * Help security analysts during the transition from the old ATT&CK MediaWiki API to the STIX/TAXII 2.0 API. 16 | * Learn STIX2 and TAXII Client Python libraries 17 | 18 | ## Documentation 19 | 20 | ### [https://attackcti.com](https://attackcti.com) 21 | 22 | ## Current Status: Production/Stable 23 | 24 | The project is currently in a Production/Stable stage, which means that the current main functions are more stable. I would love to get your feedback to make it a better project. 25 | 26 | ## Resources 27 | 28 | * [MITRE CTI](https://github.com/mitre/cti) 29 | * [OASIS CTI TAXII Client](https://github.com/oasis-open/cti-taxii-client) 30 | * [OASIS CTI Python STIX2](https://github.com/oasis-open/cti-python-stix2) 31 | * [MITRE ATT&CK Framework](https://attack.mitre.org/wiki/Main_Page) 32 | * [ATT&CK MediaWiki API](https://attack.mitre.org/wiki/Using_the_API) 33 | * [Invoke-ATTACKAPI](https://github.com/Cyb3rWard0g/Invoke-ATTACKAPI) 34 | * [Mitre-Attack-API](https://github.com/annamcabee/Mitre-Attack-API) 35 | 36 | 37 | ### Installation 38 | 39 | You can install it via pip: 40 | 41 | ``` 42 | pip install attackcti 43 | ``` 44 | 45 | Or you can also do the following: 46 | 47 | ``` 48 | git clone https://github.com/OTRF/ATTACK-Python-Client 49 | cd ATTACK-Python-Client 50 | pip install . 51 | ``` 52 | 53 | ## Contribution 54 | 55 | * Now that the project is more stable, It would be great to get your feedback and hopefully get more contributions to the project. Let us know if you have any features in mind. We would love to collaborate to make them happen in the project. 56 | * Check our basic contribution guidelines and submit an issue with your ideas. 57 | * Be concise but clear when adding a title and description to your feature proposal. 58 | * One pull request per issue. 59 | * Select one or more labels when you submit an issue. 60 | * Make sure you are in the correct branch [Master]. 61 | * Try to avoid sizeable changes unless warranted. 62 | * Be patient and polite as the project is still relatively small, which is why we would appreciate your help where possible. 63 | 64 | ## Author 65 | 66 | * Roberto Rodriguez [@Cyb3rWard0g](https://twitter.com/Cyb3rWard0g) 67 | 68 | ## Official Committers 69 | 70 | * Jose Luis Rodriguez [@Cyb3rPandaH](https://twitter.com/Cyb3rPandaH) 71 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | When contributing to this repository, please share your proposal via a GitHub issue and our [discord](https://discord.com/invite/efBGmbQ) server. 4 | 5 | This documentation contains a set of guidelines to help you during the contribution process. 6 | We are happy to welcome all the contributions from anyone willing to improve/add new scripts to this project. Thank you for helping out and remember, **no contribution is too small.** 7 | 8 | #### Table Of Contents 9 | 10 | * [Code of Conduct](#code-of-conduct) 11 | * [Submitting Contributions](#submit-contributions) 12 | * [Find An Issue](#step-0--find-an-issue) 13 | * [Fork The Project](#step-1--fork-the-project) 14 | * [Branch](#step-2--branch) 15 | * [Work on the issue assigned](#step-3--work-on-the-issue-assigned) 16 | * [Commit](#step-4--commit) 17 | * [Work Remotely](#step-5--work-remotely) 18 | * [Pull Request](#step-6--pull-request) 19 | 20 | ## Code of Conduct 21 | This project and everyone participating in it is governed by the Contributor Covenant. Make sure to read it here: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) 22 | 23 | ## Submit Contributions 24 | Below you will find the process and workflow used to review and merge your changes. 25 | 26 | ### Step 0 : Find an issue 27 | - Take a look at the Existing Issues or create your **own** Issues! 28 | - Wait for the Issue to be assigned to you after which you can start working on it. 29 | - Note : Every change in this project should/must have an associated issue. 30 | 31 | ### Step 1 : Fork the Project 32 | - Fork this Repository. This will create a Local Copy of this Repository on your Github Profile. Keep a reference to the original project in `upstream` remote. 33 | ``` 34 | $ git clone https://github.com//ATTACK-Python-Client.git 35 | # Navigate to the project directory. 36 | $ cd Hacking-Scripts 37 | $ git remote add upstream https://github.com/OTRF/ATTACK-Python-Client.git 38 | ``` 39 | 40 | - If you have already forked the project, update your copy before working. 41 | ``` 42 | $ git remote update 43 | $ git checkout 44 | $ git rebase upstream/ 45 | ``` 46 | 47 | ### Step 2 : Branch 48 | Create a new branch. Use its name to identify the issue your addressing. 49 | ``` 50 | # It will create a new branch with name Branch_Name and switch to that branch 51 | $ git checkout -b Branch_Name 52 | ``` 53 | 54 | ### Step 3 : Work on the issue assigned 55 | - Work on the issue(s) assigned to you. 56 | - Add all the files/folders needed. 57 | - After you've made changes or made your contribution to the project add changes to the branch you've just created by: 58 | ``` 59 | # To add all new files to branch Branch_Name 60 | $ git add . 61 | ``` 62 | ``` 63 | # To add only a few files to Branch_Name 64 | $ git add 65 | ``` 66 | 67 | ### Step 4 : Commit 68 | - To commit give a descriptive message for the convenience of reviewer by: 69 | ``` 70 | # This message get associated with all files you have changed 71 | $ git commit -m "message" 72 | ``` 73 | - **NOTE**: A PR should have only one commit. Multiple commits not allowed. 74 | 75 | ### Step 5 : Work Remotely 76 | - Now you are ready to your work to the remote repository. 77 | - When your work is ready and complies with the project conventions, upload your changes to your fork: 78 | 79 | ``` 80 | # To push your work to your remote repository 81 | $ git push -u origin Branch_Name 82 | ``` 83 | - Here is how your branch will look. 84 | 85 | ### Step 6 : Pull Request 86 | - Go to your repository in browser and click on compare and pull requests. Then add a title and description to your pull request that explains your contribution. 87 | - Good Work! Your Pull Request has been submitted and will be reviewed by the moderators and merged. 88 | -------------------------------------------------------------------------------- /docs/intro.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Introduction\n", 8 | "\n", 9 | "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/OTRF/ATTACK-Python-Client/master)\n", 10 | "[![Open_Threat_Research Community](https://img.shields.io/badge/Open_Threat_Research-Community-brightgreen.svg)](https://twitter.com/OTR_Community)\n", 11 | "[![Open Source Love svg1](https://badges.frapsoft.com/os/v3/open-source.svg?v=103)](https://github.com/ellerbrock/open-source-badges/)\n", 12 | "[![Downloads](https://pepy.tech/badge/attackcti)](https://pepy.tech/project/attackcti)\n", 13 | "\n", 14 | "A Python module to access up to date ATT&CK content available in STIX via public TAXII server. This project leverages the python classes and functions of the [cti-python-stix2](https://github.com/oasis-open/cti-python-stix2) and [cti-taxii-client](https://github.com/oasis-open/cti-taxii-client) libraries developed by MITRE.\n", 15 | "\n", 16 | "## Goals\n", 17 | "\n", 18 | "* Provide an easy way to access and interact with up to date ATT&CK content available in STIX via public TAXII server\n", 19 | "* Allow security analysts to quickly explore ATT&CK content and apply it in their daily operations\n", 20 | "* Allow the integration of ATT&Ck content with other platforms to host up to date information from the framework\n", 21 | "* Help security analysts during the transition from the ATT&CK MediaWiki API to the STIX/TAXII 2.0 API\n", 22 | "* Learn STIX2 and TAXII Client Python libraries\n", 23 | "\n", 24 | "## Current Status: Production/Stable\n", 25 | "\n", 26 | "The project is currently in a Production/Stable stage, which means that the current main functions are more stable. I would love to get your feedback to make it a better project.\n", 27 | "\n", 28 | "## Resources\n", 29 | "\n", 30 | "* [MITRE CTI](https://github.com/mitre/cti)\n", 31 | "* [OASIS CTI TAXII Client](https://github.com/oasis-open/cti-taxii-client)\n", 32 | "* [OASIS CTI Python STIX2](https://github.com/oasis-open/cti-python-stix2)\n", 33 | "* [MITRE ATT&CK Framework](https://attack.mitre.org/wiki/Main_Page)\n", 34 | "* [ATT&CK MediaWiki API](https://attack.mitre.org/wiki/Using_the_API)\n", 35 | "* [Invoke-ATTACKAPI](https://github.com/Cyb3rWard0g/Invoke-ATTACKAPI)\n", 36 | "* [Mitre-Attack-API](https://github.com/annamcabee/Mitre-Attack-API)\n", 37 | "\n", 38 | "### Requirements\n", 39 | "\n", 40 | "Python 3+\n", 41 | "\n", 42 | "### Installation\n", 43 | "\n", 44 | "You can install it via PIP:\n", 45 | "\n", 46 | "```\n", 47 | "pip install attackcti\n", 48 | "```\n", 49 | "\n", 50 | "Or you can also do the following:\n", 51 | "\n", 52 | "```\n", 53 | "git clone https://github.com/OTRF/ATTACK-Python-Client\n", 54 | "cd ATTACK-Python-Client\n", 55 | "pip install .\n", 56 | "```\n", 57 | "\n", 58 | "## Author\n", 59 | "\n", 60 | "* Roberto Rodriguez [@Cyb3rWard0g](https://twitter.com/Cyb3rWard0g)\n", 61 | "\n", 62 | "## Official Committers\n", 63 | "\n", 64 | "* Jose Luis Rodriguez [@Cyb3rPandaH](https://twitter.com/Cyb3rPandaH)" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": null, 70 | "metadata": {}, 71 | "outputs": [], 72 | "source": [] 73 | } 74 | ], 75 | "metadata": { 76 | "kernelspec": { 77 | "display_name": "PySpark_Python3", 78 | "language": "python", 79 | "name": "pyspark3" 80 | }, 81 | "language_info": { 82 | "codemirror_mode": { 83 | "name": "ipython", 84 | "version": 3 85 | }, 86 | "file_extension": ".py", 87 | "mimetype": "text/x-python", 88 | "name": "python", 89 | "nbconvert_exporter": "python", 90 | "pygments_lexer": "ipython3", 91 | "version": "3.7.3" 92 | } 93 | }, 94 | "nbformat": 4, 95 | "nbformat_minor": 2 96 | } 97 | -------------------------------------------------------------------------------- /src/attackcti/core/objects/software.py: -------------------------------------------------------------------------------- 1 | """Cross-domain software query helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Callable, Dict, List, Union 6 | 7 | from stix2 import CompositeDataSource, Filter 8 | from stix2.v21.sdo import Malware as MalwareV21 9 | from stix2.v21.sdo import Tool as ToolV21 10 | 11 | from ...models import Software 12 | from ...utils.stix import parse_stix_objects, remove_revoked_deprecated 13 | 14 | 15 | class SoftwareClient: 16 | """Software (malware/tool) query helper class.""" 17 | 18 | def __init__( 19 | self, 20 | *, 21 | data_source: CompositeDataSource, 22 | remove_fn: Callable = remove_revoked_deprecated, 23 | parse_fn: Callable = parse_stix_objects, 24 | ) -> None: 25 | """Initialize the client with a composite data source.""" 26 | self._data_source = data_source 27 | self._remove_fn = remove_fn 28 | self._parse_fn = parse_fn 29 | 30 | def get_software(self, *, skip_revoked_deprecated: bool = True, stix_format: bool = True) -> List[Union[MalwareV21, ToolV21, Dict[str, Any]]]: 31 | """Return malware and tool software objects across domains. 32 | 33 | Parameters 34 | ---------- 35 | skip_revoked_deprecated : bool, optional 36 | When `True`, omit revoked/deprecated software. 37 | stix_format : bool, optional 38 | When `True`, return STIX objects/dicts; when `False`, parse to the 39 | `Software` Pydantic model. 40 | 41 | Returns 42 | ------- 43 | list[MalwareV21 | ToolV21 | dict[str, Any]] 44 | Software objects in the requested format. 45 | """ 46 | all_software = self._data_source.query([Filter("type", "in", ["malware", "tool"])]) 47 | if skip_revoked_deprecated: 48 | all_software = self._remove_fn(all_software) 49 | if not stix_format: 50 | all_software = self._parse_fn(all_software, Software) 51 | return all_software 52 | 53 | def get_malware( 54 | self, 55 | *, 56 | skip_revoked_deprecated: bool = True, 57 | stix_format: bool = True, 58 | ) -> list[MalwareV21 | dict[str, Any]]: 59 | """Return malware software objects across domains. 60 | 61 | Parameters 62 | ---------- 63 | skip_revoked_deprecated : bool, optional 64 | When `True`, omit revoked/deprecated malware. 65 | stix_format : bool, optional 66 | When `True`, return STIX objects/dicts; when `False`, parse to the 67 | `Software` Pydantic model. 68 | 69 | Returns 70 | ------- 71 | list[MalwareV21 | dict[str, Any]] 72 | Malware objects in the requested format. 73 | """ 74 | malware = self._data_source.query(Filter("type", "=", "malware")) 75 | if skip_revoked_deprecated: 76 | malware = self._remove_fn(malware) 77 | if not stix_format: 78 | malware = self._parse_fn(malware, Software) 79 | return malware 80 | 81 | def get_tools( 82 | self, 83 | *, 84 | skip_revoked_deprecated: bool = True, 85 | stix_format: bool = True, 86 | ) -> list[ToolV21 | dict[str, Any]]: 87 | """Return tool software objects across domains. 88 | 89 | Parameters 90 | ---------- 91 | skip_revoked_deprecated : bool, optional 92 | When `True`, omit revoked/deprecated tools. 93 | stix_format : bool, optional 94 | When `True`, return STIX objects/dicts; when `False`, parse to the 95 | `Software` Pydantic model. 96 | 97 | Returns 98 | ------- 99 | list[ToolV21 | dict[str, Any]] 100 | Tool objects in the requested format. 101 | """ 102 | tools = self._data_source.query(Filter("type", "=", "tool")) 103 | if skip_revoked_deprecated: 104 | tools = self._remove_fn(tools) 105 | if not stix_format: 106 | tools = self._parse_fn(tools, Software) 107 | return tools 108 | -------------------------------------------------------------------------------- /src/attackcti/sources/taxii_loader.py: -------------------------------------------------------------------------------- 1 | """MITRE ATT&CK TAXII source helpers. 2 | 3 | This module builds TAXII 2.1 collection sources for the MITRE ATT&CK datasets. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from stix2 import TAXIICollectionSource 9 | from taxii2client.v21 import Collection 10 | 11 | from ..constants import ( 12 | ATTACK_TAXII_COLLECTIONS_URL, 13 | ENTERPRISE_ATTACK_COLLECTION_ID, 14 | ICS_ATTACK_COLLECTION_ID, 15 | MOBILE_ATTACK_COLLECTION_ID, 16 | ) 17 | 18 | 19 | def _normalize_collections_url(collections_url: str) -> str: 20 | """Normalize a TAXII collections base URL. 21 | 22 | Args: 23 | collections_url: Base URL for TAXII collections (typically ends with 24 | /collections/). 25 | 26 | Returns 27 | ------- 28 | Normalized URL with a trailing /. 29 | 30 | Raises 31 | ------ 32 | ValueError: If collections_url is empty after trimming whitespace. 33 | """ 34 | collections_url = collections_url.strip() 35 | if not collections_url: 36 | raise ValueError("collection_url must be a non-empty string") 37 | if not collections_url.endswith("/"): 38 | collections_url += "/" 39 | return collections_url 40 | 41 | 42 | def create_taxii_sources( 43 | *, 44 | proxies: dict | None = None, 45 | verify: bool = True, 46 | collection_url: str | None = None, 47 | ) -> tuple[TAXIICollectionSource, TAXIICollectionSource, TAXIICollectionSource]: 48 | """Create TAXII sources for enterprise, mobile, and ICS ATT&CK. 49 | 50 | Args: 51 | proxies: Requests proxy configuration passed to taxii2client (and 52 | ultimately requests). 53 | verify: Whether to verify TLS certificates. 54 | collection_url: Base collections URL (ending in /collections/). If 55 | omitted, uses :data:`attackcti.constants.ATTACK_TAXII_COLLECTIONS_URL`. 56 | 57 | Returns 58 | ------- 59 | A tuple of sources for (enterprise, mobile, ics). 60 | 61 | Raises 62 | ------ 63 | ValueError 64 | If collection_url is provided but empty. 65 | """ 66 | collections_url = _normalize_collections_url(collection_url or ATTACK_TAXII_COLLECTIONS_URL) 67 | 68 | enterprise_url = f"{collections_url}{ENTERPRISE_ATTACK_COLLECTION_ID}/" 69 | mobile_url = f"{collections_url}{MOBILE_ATTACK_COLLECTION_ID}/" 70 | ics_url = f"{collections_url}{ICS_ATTACK_COLLECTION_ID}/" 71 | 72 | enterprise_collection = Collection(enterprise_url, verify=verify, proxies=proxies) 73 | mobile_collection = Collection(mobile_url, verify=verify, proxies=proxies) 74 | ics_collection = Collection(ics_url, verify=verify, proxies=proxies) 75 | 76 | return ( 77 | TAXIICollectionSource(enterprise_collection), 78 | TAXIICollectionSource(mobile_collection), 79 | TAXIICollectionSource(ics_collection), 80 | ) 81 | 82 | 83 | def load_taxii_sources( 84 | *, 85 | proxies: dict | None = None, 86 | verify: bool = True, 87 | collection_url: str | None = None, 88 | ) -> tuple[tuple[TAXIICollectionSource, TAXIICollectionSource, TAXIICollectionSource], dict[str, str]]: 89 | """Load TAXII sources for enterprise, mobile, and ICS. 90 | 91 | Args: 92 | proxies: Requests proxy configuration passed to taxii2client. 93 | verify: Whether to verify TLS certificates. 94 | collection_url: Base collections URL (ending in /collections/). If 95 | omitted, uses :data:`attackcti.constants.ATTACK_TAXII_COLLECTIONS_URL`. 96 | 97 | Returns 98 | ------- 99 | `((enterprise_source, mobile_source, ics_source), versions)` where 100 | `versions` maps `enterprise|mobile|ics` to `"2.1"`. 101 | `versions` maps `enterprise|mobile|ics` to `"2.1"`. 102 | 103 | Raises 104 | ------ 105 | ValueError: If collection_url is provided but empty. 106 | """ 107 | sources = create_taxii_sources( 108 | proxies=proxies, 109 | verify=verify, 110 | collection_url=collection_url, 111 | ) 112 | versions = { 113 | "enterprise": "2.1", 114 | "mobile": "2.1", 115 | "ics": "2.1", 116 | } 117 | return sources, versions 118 | -------------------------------------------------------------------------------- /src/attackcti/core/objects/campaigns.py: -------------------------------------------------------------------------------- 1 | """Cross-domain campaign query helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Callable, Dict, List, Union 6 | 7 | from stix2 import CompositeDataSource, Filter 8 | from stix2.v21.sdo import Campaign as CampaignV21 9 | 10 | from ...models import Campaign as CampaignModel 11 | 12 | 13 | class CampaignsClient: 14 | """Campaigns query helper class.""" 15 | 16 | def __init__( 17 | self, 18 | *, 19 | data_source: CompositeDataSource, 20 | remove_fn: Callable | None = None, 21 | parse_fn: Callable | None = None, 22 | ) -> None: 23 | """Initialize the client with a composite data source.""" 24 | self._data_source = data_source 25 | self._remove_fn = remove_fn 26 | self._parse_fn = parse_fn 27 | 28 | def get_campaigns(self, *, skip_revoked_deprecated: bool = True, stix_format: bool = True) -> List[Union[CampaignV21, Dict[str, Any]]]: 29 | """Return campaigns across ATT&CK matrices. 30 | 31 | Parameters 32 | ---------- 33 | skip_revoked_deprecated 34 | When `True`, omit revoked/deprecated campaigns. 35 | stix_format 36 | When `True`, return STIX objects; when `False`, parse to the 37 | `Campaign` Pydantic model. 38 | 39 | Returns 40 | ------- 41 | List[Union[CampaignV21, Dict[str, Any]]] 42 | Campaign objects in the requested format. 43 | """ 44 | all_campaigns = self._data_source.query([Filter("type", "=", "campaign")]) 45 | if skip_revoked_deprecated: 46 | all_campaigns = self._remove_fn(all_campaigns) 47 | if not stix_format: 48 | all_campaigns = self._parse_fn(all_campaigns, CampaignModel) 49 | return all_campaigns 50 | 51 | 52 | def get_campaign_by_alias(self, *, alias: str, case: bool = True, stix_format: bool = True) -> List[Union[CampaignV21, Dict[str, Any]]]: 53 | """Return campaigns that match a provided alias. 54 | 55 | Parameters 56 | ---------- 57 | alias 58 | Alias to match. 59 | case 60 | When `True`, perform case-sensitive match; otherwise performs 61 | case-insensitive containment match. 62 | stix_format 63 | When `True`, return STIX objects; when `False`, parse to the 64 | `Campaign` Pydantic model. 65 | 66 | Returns 67 | ------- 68 | List[Union[CampaignV21, Dict[str, Any]]] 69 | Matching campaign objects in the requested format. 70 | """ 71 | if not case: 72 | all_campaigns = self.get_campaigns(stix_format=True) 73 | out: list[Any] = [] 74 | for campaign in all_campaigns: 75 | if "aliases" in campaign.keys(): 76 | for campaign_alias in campaign["aliases"]: 77 | if alias.lower() in campaign_alias.lower(): 78 | out.append(CampaignModel) 79 | else: 80 | filter_objects = [Filter("type", "=", "campaign"), Filter("aliases", "contains", alias)] 81 | out = self._data_source.query(filter_objects) 82 | 83 | if not stix_format: 84 | out = self._parse_fn(out, CampaignModel) 85 | return out 86 | 87 | 88 | def get_campaigns_since_time(self, *, timestamp: str, stix_format: bool = True) -> List[Union[CampaignV21, Dict[str, Any]]]: 89 | """Return campaigns created after the provided timestamp. 90 | 91 | Parameters 92 | ---------- 93 | timestamp 94 | Timestamp string for filtering. 95 | stix_format 96 | When `True`, return STIX objects; when `False`, parse to the 97 | `Campaign` Pydantic model. 98 | 99 | Returns 100 | ------- 101 | List[Union[CampaignV21, Dict[str, Any]]] 102 | Campaign objects in the requested format. 103 | """ 104 | filter_objects = [Filter("type", "=", "campaign"), Filter("created", ">", timestamp)] 105 | out = self._data_source.query(filter_objects) 106 | if not stix_format: 107 | out = self._parse_fn(out, CampaignModel) 108 | return out 109 | -------------------------------------------------------------------------------- /src/attackcti/core/objects/data_sources.py: -------------------------------------------------------------------------------- 1 | """Cross-domain data source/component helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Callable, Dict, Iterable, List 6 | from warnings import warn 7 | 8 | from stix2 import CompositeDataSource, Filter 9 | 10 | from ...models import DataComponent as DataComponentModel 11 | from ...models import DataSource as DataSourceModel 12 | 13 | 14 | class DataSourcesClient: 15 | """Data source and data component queries.""" 16 | 17 | def __init__( 18 | self, 19 | *, 20 | data_source: CompositeDataSource, 21 | remove_fn: Callable | None = None, 22 | parse_fn: Callable | None = None, 23 | ) -> None: 24 | """Initialize the client with a composite data source.""" 25 | self._data_source = data_source 26 | self._remove_fn = remove_fn 27 | self._parse_fn = parse_fn 28 | 29 | def get_data_components(self, *, skip_revoked_deprecated: bool = True, stix_format: bool = True) -> List[Dict[str, Any]]: 30 | """Return data components across all domains. 31 | 32 | Parameters 33 | ---------- 34 | skip_revoked_deprecated 35 | When `True`, omit revoked/deprecated data components. 36 | stix_format 37 | When `True`, return STIX objects; when `False`, parse to the 38 | `DataComponent` Pydantic model. 39 | 40 | Returns 41 | ------- 42 | List[Dict[str, Any]] 43 | Data component objects in the requested format. 44 | """ 45 | all_data_components = self._data_source.query([Filter("type", "=", "x-mitre-data-component")]) 46 | if skip_revoked_deprecated: 47 | all_data_components = self._remove_fn(all_data_components) 48 | if not stix_format: 49 | all_data_components = self._parse_fn(all_data_components, DataComponentModel) 50 | return all_data_components 51 | 52 | def get_data_sources(self, *, include_data_components: bool = False, stix_format: bool = True) -> List[Dict[str, Any]]: 53 | """Return data sources across all domains. 54 | 55 | Parameters 56 | ---------- 57 | include_data_components 58 | When `True`, enrich data sources with related data components 59 | (requires Pydantic parsing). 60 | stix_format 61 | When `True`, return STIX objects; when `False`, parse to the 62 | `DataSource` Pydantic model. 63 | 64 | Returns 65 | ------- 66 | List[Dict[str, Any]] 67 | Data source objects in the requested format. 68 | """ 69 | warn( 70 | "Data Sources (`x-mitre-data-source`) are deprecated as of ATT&CK Specification 3.3.0. " 71 | "Data Sources are superseded by the Detection Strategy framework..", 72 | DeprecationWarning, 73 | stacklevel=2, 74 | ) 75 | all_data_sources = self._data_source.query([Filter("type", "=", "x-mitre-data-source")]) 76 | all_data_sources = self._remove_fn(all_data_sources) 77 | if include_data_components: 78 | all_data_sources = self._parse_fn(all_data_sources, DataSourceModel, include_data_components=True) 79 | elif not stix_format: 80 | all_data_sources = self._parse_fn(all_data_sources, DataSourceModel) 81 | return all_data_sources 82 | 83 | def get_data_components_by_ids( 84 | self, 85 | ids: Iterable[str], 86 | *, 87 | stix_format: bool = True, 88 | data_components: list[dict[str, Any]] | None = None, 89 | skip_revoked_deprecated: bool = True, 90 | ) -> list[dict[str, Any]]: 91 | """Return data component objects for the requested ids.""" 92 | dc_ids = {did for did in ids if isinstance(did, str) and did} 93 | if not dc_ids: 94 | return [] 95 | 96 | if data_components is None: 97 | data_components = self.get_data_components( 98 | skip_revoked_deprecated=skip_revoked_deprecated, 99 | stix_format=True, 100 | ) 101 | 102 | selected = [dc for dc in data_components if dc.get("id") in dc_ids] 103 | if not stix_format: 104 | return self._parse_fn(selected, DataComponentModel) 105 | return selected 106 | -------------------------------------------------------------------------------- /src/attackcti/sources/resolver.py: -------------------------------------------------------------------------------- 1 | """Source selection helpers. 2 | 3 | This module contains the policy for combining multiple source types (local STIX 4 | bundles and TAXII) into the final set of domain sources used by the client. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from typing import Any 10 | 11 | from .local_loader import load_local_sources 12 | from .taxii_loader import load_taxii_sources 13 | 14 | 15 | def load_sources( 16 | *, 17 | enterprise: str | None = None, 18 | mobile: str | None = None, 19 | ics: str | None = None, 20 | connect_taxii: bool = True, 21 | proxies: dict | None = None, 22 | verify: bool = True, 23 | collection_url: str | None = None, 24 | ) -> tuple[tuple[Any | None, Any | None, Any | None], dict[str, str | None], str, str | None]: 25 | """Load sources using a local-first policy with optional TAXII fallback. 26 | 27 | Policy: 28 | - If local sources exist, use them. 29 | - If some local domains are missing and `connect_taxii=True`, fill missing domains from TAXII. 30 | - If no local sources exist: 31 | - If `connect_taxii=True`, load all domains from TAXII. 32 | - If `connect_taxii=False`: 33 | - Raise if the caller provided local paths (they were invalid). 34 | - Otherwise return an empty configuration. 35 | 36 | Args: 37 | enterprise: Path to the local enterprise bundle (dir or JSON file). 38 | mobile: Path to the local mobile bundle (dir or JSON file). 39 | ics: Path to the local ICS bundle (dir or JSON file). 40 | connect_taxii: If `True`, allow TAXII fallback/fill behavior. 41 | proxies: Requests proxy configuration for TAXII. 42 | verify: Whether to verify TLS certificates for TAXII. 43 | collection_url: Base TAXII collections URL (ending in `/collections/`). 44 | 45 | Returns 46 | ------- 47 | tuple 48 | A tuple `(sources, versions, mode, spec_version)` where: 49 | - `sources` is `(enterprise_source, mobile_source, ics_source)` 50 | - `versions` maps `enterprise|mobile|ics` to spec versions (or `None`) 51 | - `mode` is one of `local`, `taxii`, `mixed`, `empty` 52 | - `spec_version` is the unified spec version if known, else `None` 53 | 54 | Raises 55 | ------ 56 | ValueError: If local paths were provided but none were loadable and 57 | `connect_taxii=False`. 58 | """ 59 | local_paths_provided = any((enterprise, mobile, ics)) 60 | 61 | (enterprise_source, mobile_source, ics_source), versions = load_local_sources( 62 | enterprise=enterprise, 63 | mobile=mobile, 64 | ics=ics, 65 | ) 66 | 67 | any_local = any((enterprise_source, mobile_source, ics_source)) 68 | if not any_local: 69 | if not connect_taxii: 70 | if local_paths_provided: 71 | raise ValueError("No valid local data sources found.") 72 | return (None, None, None), {"enterprise": None, "mobile": None, "ics": None}, "empty", None 73 | 74 | (enterprise_source, mobile_source, ics_source), versions = load_taxii_sources( 75 | proxies=proxies, 76 | verify=verify, 77 | collection_url=collection_url, 78 | ) 79 | return (enterprise_source, mobile_source, ics_source), versions, "taxii", "2.1" 80 | 81 | missing_any = any(ds is None for ds in (enterprise_source, mobile_source, ics_source)) 82 | if missing_any and connect_taxii: 83 | (taxii_enterprise, taxii_mobile, taxii_ics), taxii_versions = load_taxii_sources( 84 | proxies=proxies, 85 | verify=verify, 86 | collection_url=collection_url, 87 | ) 88 | if enterprise_source is None: 89 | enterprise_source = taxii_enterprise 90 | versions["enterprise"] = taxii_versions["enterprise"] 91 | if mobile_source is None: 92 | mobile_source = taxii_mobile 93 | versions["mobile"] = taxii_versions["mobile"] 94 | if ics_source is None: 95 | ics_source = taxii_ics 96 | versions["ics"] = taxii_versions["ics"] 97 | mode = "mixed" 98 | else: 99 | mode = "local" 100 | 101 | non_null_versions = {v for v in versions.values() if v is not None} 102 | spec_version = non_null_versions.pop() if len(non_null_versions) == 1 else None 103 | return (enterprise_source, mobile_source, ics_source), versions, mode, spec_version 104 | -------------------------------------------------------------------------------- /src/attackcti/domains/base.py: -------------------------------------------------------------------------------- 1 | """Shared domain client implementation.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Dict 6 | 7 | from stix2 import CompositeDataSource, Filter, MemorySource, TAXIICollectionSource 8 | 9 | from ..core.query_client import QueryClient 10 | from ..models import pydantic_model_mapping 11 | 12 | 13 | def _filter_software_by_type(items: list[Any], *, stix_type: str) -> list[Any]: 14 | """Return software objects matching a specific STIX type.""" 15 | out: list[Any] = [] 16 | for item in items: 17 | if isinstance(item, dict): 18 | if item.get("type") == stix_type: 19 | out.append(item) 20 | else: 21 | item_type = getattr(item, "type", None) 22 | if item_type == stix_type: 23 | out.append(item) 24 | return out 25 | 26 | 27 | class DomainClientBase: 28 | """Base class for domain-scoped clients (enterprise/mobile/ics).""" 29 | 30 | def __init__( 31 | self, 32 | *, 33 | data_source: TAXIICollectionSource | MemorySource | None, 34 | ) -> None: 35 | self.data_source = data_source 36 | if self.data_source is None: 37 | raise RuntimeError("domain source is not loaded") 38 | # Set up a composite data source. 39 | composite = CompositeDataSource() 40 | # Add the domain-specific data source. 41 | composite.add_data_sources([self.data_source]) 42 | # Initialize the query client. 43 | self._query_client = QueryClient(composite, pydantic_map=pydantic_model_mapping) 44 | 45 | def get(self, stix_format: bool = True) -> dict[str, list[Any]]: 46 | """Return a bundle of common ATT&CK objects for the domain.""" 47 | return { 48 | "techniques": self.get_techniques(stix_format=stix_format), 49 | "data-component": self.get_data_components(stix_format=stix_format), 50 | "mitigations": self.get_mitigations(stix_format=stix_format), 51 | "groups": self.get_groups(stix_format=stix_format), 52 | "malware": self.get_malware(stix_format=stix_format), 53 | "tools": self.get_tools(stix_format=stix_format), 54 | "data-source": self.get_data_sources(stix_format=stix_format), 55 | "relationships": self.get_relationships(stix_format=stix_format), 56 | "tactics": self.get_tactics(stix_format=stix_format), 57 | "matrix": self.data_source.query(Filter("type", "=", "x-mitre-matrix")), 58 | "identity": self.data_source.query(Filter("type", "=", "identity")), 59 | "marking-definition": self.data_source.query(Filter("type", "=", "marking-definition")), 60 | "campaigns": self.get_campaigns(stix_format=stix_format), 61 | } 62 | 63 | # Domain-scoped wrappers that delegate to the core query helpers. 64 | 65 | def get_techniques(self, stix_format: bool = True) -> list[Any]: 66 | """Return techniques for this domain.""" 67 | return self._query_client.techniques.get_techniques(stix_format=stix_format) 68 | 69 | def get_data_components(self, stix_format: bool = True) -> list[Dict[str, Any]]: 70 | """Return data components for this domain.""" 71 | return self._query_client.data_sources.get_data_components(stix_format=stix_format) 72 | 73 | def get_mitigations(self, stix_format: bool = True) -> list[Any]: 74 | """Return mitigations for this domain.""" 75 | return self._query_client.mitigations.get_mitigations(stix_format=stix_format) 76 | 77 | def get_groups(self, stix_format: bool = True) -> list[Any]: 78 | """Return intrusion-set groups for this domain.""" 79 | return self._query_client.groups.get_groups(stix_format=stix_format) 80 | 81 | def get_malware(self, stix_format: bool = True) -> list[Any]: 82 | """Return malware for this domain.""" 83 | software = self._query_client.software.get_software(stix_format=stix_format) 84 | return _filter_software_by_type(software, stix_type="malware") 85 | 86 | def get_tools(self, stix_format: bool = True) -> list[Any]: 87 | """Return tools for this domain.""" 88 | software = self._query_client.software.get_software(stix_format=stix_format) 89 | return _filter_software_by_type(software, stix_type="tool") 90 | 91 | def get_data_sources(self, stix_format: bool = True) -> list[Dict[str, Any]]: 92 | """Return data sources for this domain.""" 93 | return self._query_client.data_sources.get_data_sources(stix_format=stix_format) 94 | 95 | def get_relationships(self, stix_format: bool = True) -> list[Any]: 96 | """Return relationships for this domain.""" 97 | return self._query_client.relationships.get_relationships(stix_format=stix_format) 98 | 99 | def get_tactics(self, stix_format: bool = True) -> list[Dict[str, Any]]: 100 | """Return tactics for this domain.""" 101 | return self._query_client.tactics.get_tactics(stix_format=stix_format) 102 | 103 | def get_campaigns(self, stix_format: bool = True) -> list[Any]: 104 | """Return campaigns for this domain.""" 105 | return self._query_client.campaigns.get_campaigns(stix_format=stix_format) 106 | -------------------------------------------------------------------------------- /docs/references.bib: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @inproceedings{holdgraf_evidence_2014, 5 | address = {Brisbane, Australia, Australia}, 6 | title = {Evidence for {Predictive} {Coding} in {Human} {Auditory} {Cortex}}, 7 | booktitle = {International {Conference} on {Cognitive} {Neuroscience}}, 8 | publisher = {Frontiers in Neuroscience}, 9 | author = {Holdgraf, Christopher Ramsay and de Heer, Wendy and Pasley, Brian N. and Knight, Robert T.}, 10 | year = {2014} 11 | } 12 | 13 | @article{holdgraf_rapid_2016, 14 | title = {Rapid tuning shifts in human auditory cortex enhance speech intelligibility}, 15 | volume = {7}, 16 | issn = {2041-1723}, 17 | url = {http://www.nature.com/doifinder/10.1038/ncomms13654}, 18 | doi = {10.1038/ncomms13654}, 19 | number = {May}, 20 | journal = {Nature Communications}, 21 | author = {Holdgraf, Christopher Ramsay and de Heer, Wendy and Pasley, Brian N. and Rieger, Jochem W. and Crone, Nathan and Lin, Jack J. and Knight, Robert T. and Theunissen, Frédéric E.}, 22 | year = {2016}, 23 | pages = {13654}, 24 | file = {Holdgraf et al. - 2016 - Rapid tuning shifts in human auditory cortex enhance speech intelligibility.pdf:C\:\\Users\\chold\\Zotero\\storage\\MDQP3JWE\\Holdgraf et al. - 2016 - Rapid tuning shifts in human auditory cortex enhance speech intelligibility.pdf:application/pdf} 25 | } 26 | 27 | @inproceedings{holdgraf_portable_2017, 28 | title = {Portable learning environments for hands-on computational instruction using container-and cloud-based technology to teach data science}, 29 | volume = {Part F1287}, 30 | isbn = {978-1-4503-5272-7}, 31 | doi = {10.1145/3093338.3093370}, 32 | abstract = {© 2017 ACM. There is an increasing interest in learning outside of the traditional classroom setting. This is especially true for topics covering computational tools and data science, as both are challenging to incorporate in the standard curriculum. These atypical learning environments offer new opportunities for teaching, particularly when it comes to combining conceptual knowledge with hands-on experience/expertise with methods and skills. Advances in cloud computing and containerized environments provide an attractive opportunity to improve the effciency and ease with which students can learn. This manuscript details recent advances towards using commonly-Available cloud computing services and advanced cyberinfrastructure support for improving the learning experience in bootcamp-style events. We cover the benets (and challenges) of using a server hosted remotely instead of relying on student laptops, discuss the technology that was used in order to make this possible, and give suggestions for how others could implement and improve upon this model for pedagogy and reproducibility.}, 33 | booktitle = {{ACM} {International} {Conference} {Proceeding} {Series}}, 34 | author = {Holdgraf, Christopher Ramsay and Culich, A. and Rokem, A. and Deniz, F. and Alegro, M. and Ushizima, D.}, 35 | year = {2017}, 36 | keywords = {Teaching, Bootcamps, Cloud computing, Data science, Docker, Pedagogy} 37 | } 38 | 39 | @article{holdgraf_encoding_2017, 40 | title = {Encoding and decoding models in cognitive electrophysiology}, 41 | volume = {11}, 42 | issn = {16625137}, 43 | doi = {10.3389/fnsys.2017.00061}, 44 | abstract = {© 2017 Holdgraf, Rieger, Micheli, Martin, Knight and Theunissen. Cognitive neuroscience has seen rapid growth in the size and complexity of data recorded from the human brain as well as in the computational tools available to analyze this data. This data explosion has resulted in an increased use of multivariate, model-based methods for asking neuroscience questions, allowing scientists to investigate multiple hypotheses with a single dataset, to use complex, time-varying stimuli, and to study the human brain under more naturalistic conditions. These tools come in the form of “Encoding” models, in which stimulus features are used to model brain activity, and “Decoding” models, in which neural features are used to generated a stimulus output. Here we review the current state of encoding and decoding models in cognitive electrophysiology and provide a practical guide toward conducting experiments and analyses in this emerging field. Our examples focus on using linear models in the study of human language and audition. We show how to calculate auditory receptive fields from natural sounds as well as how to decode neural recordings to predict speech. The paper aims to be a useful tutorial to these approaches, and a practical introduction to using machine learning and applied statistics to build models of neural activity. The data analytic approaches we discuss may also be applied to other sensory modalities, motor systems, and cognitive systems, and we cover some examples in these areas. In addition, a collection of Jupyter notebooks is publicly available as a complement to the material covered in this paper, providing code examples and tutorials for predictive modeling in python. The aimis to provide a practical understanding of predictivemodeling of human brain data and to propose best-practices in conducting these analyses.}, 45 | journal = {Frontiers in Systems Neuroscience}, 46 | author = {Holdgraf, Christopher Ramsay and Rieger, J.W. and Micheli, C. and Martin, S. and Knight, R.T. and Theunissen, F.E.}, 47 | year = {2017}, 48 | keywords = {Decoding models, Encoding models, Electrocorticography (ECoG), Electrophysiology/evoked potentials, Machine learning applied to neuroscience, Natural stimuli, Predictive modeling, Tutorials} 49 | } 50 | 51 | @book{ruby, 52 | title = {The Ruby Programming Language}, 53 | author = {Flanagan, David and Matsumoto, Yukihiro}, 54 | year = {2008}, 55 | publisher = {O'Reilly Media} 56 | } 57 | -------------------------------------------------------------------------------- /docs/playground/12-Local_vs_TAXII_STIX_20_21.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# 12 - Local and TAXII Modes (STIX 2.0 / 2.1)\n", 8 | "\n", 9 | "This notebook shows how to:\n", 10 | "- download ATT&CK STIX bundles in **STIX 2.0** and **STIX 2.1**\n", 11 | "- load them locally (offline) with `AttackClient.from_local()`\n", 12 | "- optionally connect to the MITRE ATT&CK **TAXII 2.1** server with `AttackClient.from_taxii()`\n", 13 | "\n", 14 | "Notes:\n", 15 | "- The MITRE TAXII server is **rate limited** (10 requests / 10 minutes / IP).\n", 16 | "- Local bundles can be STIX 2.0 (cti repo) or STIX 2.1 (attack-stix-data).\n" 17 | ] 18 | }, 19 | { 20 | "cell_type": "markdown", 21 | "metadata": {}, 22 | "source": [ 23 | "## Imports\n", 24 | "This uses the `attackcti` downloader and client." 25 | ] 26 | }, 27 | { 28 | "cell_type": "code", 29 | "execution_count": null, 30 | "metadata": {}, 31 | "outputs": [], 32 | "source": [ 33 | "from __future__ import annotations\n", 34 | "\n", 35 | "from pathlib import Path\n", 36 | "\n", 37 | "from stix2 import Filter\n", 38 | "\n", 39 | "from attackcti import MitreAttackClient\n", 40 | "from attackcti.utils.downloader import STIXDownloader\n" 41 | ] 42 | }, 43 | { 44 | "cell_type": "markdown", 45 | "metadata": {}, 46 | "source": [ 47 | "## Download STIX 2.0 and STIX 2.1 bundles\n", 48 | "These calls require network access. If you already have the bundles on disk, skip this section and set the paths below." 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": null, 54 | "metadata": {}, 55 | "outputs": [], 56 | "source": [ 57 | "download_root = Path('./downloads')\n", 58 | "download_root.mkdir(parents=True, exist_ok=True)\n", 59 | "\n", 60 | "# STIX 2.0 example (from mitre/cti tags)\n", 61 | "stix20 = STIXDownloader(download_dir=str(download_root / 'stix-2.0'), stix_version='2.0')\n", 62 | "stix20.download_attack_data(domain='enterprise', release='15.1', pretty_print=True)\n", 63 | "stix20_enterprise = Path(stix20.downloaded_file_paths['enterprise'])\n", 64 | "\n", 65 | "# STIX 2.1 example (from mitre-attack/attack-stix-data)\n", 66 | "stix21 = STIXDownloader(download_dir=str(download_root / 'stix-2.1'), stix_version='2.1')\n", 67 | "stix21.download_attack_data(domain='enterprise', release='18.1', pretty_print=True)\n", 68 | "stix21_enterprise = Path(stix21.downloaded_file_paths['enterprise'])\n", 69 | "\n", 70 | "stix20_enterprise, stix21_enterprise\n" 71 | ] 72 | }, 73 | { 74 | "cell_type": "markdown", 75 | "metadata": {}, 76 | "source": [ 77 | "## Load locally (offline)\n", 78 | "`AttackClient.from_local()` loads STIX JSON bundles from disk and does not contact the network." 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": null, 84 | "metadata": {}, 85 | "outputs": [], 86 | "source": [ 87 | "# STIX 2.0 local mode (single file example)\n", 88 | "client20 = MitreAttackClient.from_local(enterprise=str(stix20_enterprise))\n", 89 | "print('mode:', client20.mode, 'spec_version:', client20.spec_version)\n", 90 | "\n", 91 | "# Direct datastore query (works for both 2.0 and 2.1 local bundles)\n", 92 | "techniques_20 = client20.TC_ENTERPRISE_SOURCE.query([Filter('type', '=', 'attack-pattern')])\n", 93 | "print('enterprise attack-pattern count (2.0):', len(techniques_20))\n" 94 | ] 95 | }, 96 | { 97 | "cell_type": "code", 98 | "execution_count": null, 99 | "metadata": {}, 100 | "outputs": [], 101 | "source": [ 102 | "# STIX 2.1 local mode\n", 103 | "# If you downloaded only enterprise above, you can still load just enterprise.\n", 104 | "client21 = MitreAttackClient.from_local(enterprise=str(stix21_enterprise))\n", 105 | "print('mode:', client21.mode, 'spec_version:', client21.spec_version)\n", 106 | "\n", 107 | "techniques_21 = client21.TC_ENTERPRISE_SOURCE.query([Filter('type', '=', 'attack-pattern')])\n", 108 | "print('enterprise attack-pattern count (2.1):', len(techniques_21))\n" 109 | ] 110 | }, 111 | { 112 | "cell_type": "markdown", 113 | "metadata": {}, 114 | "source": [ 115 | "## Connect to MITRE ATT&CK TAXII 2.1 (rate limited)\n", 116 | "This is the network-backed mode. Keep queries small to avoid hitting the rate limit." 117 | ] 118 | }, 119 | { 120 | "cell_type": "code", 121 | "execution_count": null, 122 | "metadata": {}, 123 | "outputs": [], 124 | "source": [ 125 | "# TAXII mode (STIX 2.1 over TAXII 2.1)\n", 126 | "taxii = MitreAttackClient.from_taxii()\n", 127 | "print('mode:', taxii.mode, 'spec_version:', taxii.spec_version)\n", 128 | "\n", 129 | "# Example: fetch one object by STIX id (one request)\n", 130 | "stix_id = 'attack-pattern--ad255bfe-a9e6-4b52-a258-8d3462abe842'\n", 131 | "obj = taxii.TC_ENTERPRISE_SOURCE.get(stix_id)\n", 132 | "print(obj['type'], obj['id'], obj.get('spec_version'))\n" 133 | ] 134 | }, 135 | { 136 | "cell_type": "code", 137 | "execution_count": null, 138 | "metadata": {}, 139 | "outputs": [], 140 | "source": [ 141 | "obj" 142 | ] 143 | }, 144 | { 145 | "cell_type": "code", 146 | "execution_count": null, 147 | "metadata": {}, 148 | "outputs": [], 149 | "source": [] 150 | } 151 | ], 152 | "metadata": { 153 | "kernelspec": { 154 | "display_name": ".venv", 155 | "language": "python", 156 | "name": "python3" 157 | }, 158 | "language_info": { 159 | "codemirror_mode": { 160 | "name": "ipython", 161 | "version": 3 162 | }, 163 | "file_extension": ".py", 164 | "mimetype": "text/x-python", 165 | "name": "python", 166 | "nbconvert_exporter": "python", 167 | "pygments_lexer": "ipython3", 168 | "version": "3.13.3" 169 | } 170 | }, 171 | "nbformat": 4, 172 | "nbformat_minor": 2 173 | } 174 | -------------------------------------------------------------------------------- /src/attackcti/legacy.py: -------------------------------------------------------------------------------- 1 | """Attach legacy `MitreAttackClient.get_*` methods. 2 | 3 | The modern API is exposed via sub-clients (composition), e.g.: 4 | - `client.enterprise.get_techniques()` 5 | - `client.relationships.get_software_used_by_group()` 6 | 7 | For backwards compatibility, this module installs `MitreAttackClient.get_*` methods which delegate to 8 | the appropriate sub-client methods. 9 | """ 10 | 11 | from __future__ import annotations 12 | 13 | from typing import Any, Callable, Dict, Tuple 14 | 15 | LegacyTarget = Tuple[str, str] 16 | 17 | 18 | LEGACY_METHODS: Dict[str, LegacyTarget] = { 19 | # Query (cross-domain) 20 | "get_attack": ("query", "get_attack"), 21 | "get_campaigns": ("query.campaigns", "get_campaigns"), 22 | "get_techniques": ("query.techniques", "get_techniques"), 23 | "get_groups": ("query.groups", "get_groups"), 24 | "get_mitigations": ("query.mitigations", "get_mitigations"), 25 | "get_data_components": ("query.data_sources", "get_data_components"), 26 | "get_software": ("query.software", "get_software"), 27 | "get_relationships": ("query.relationships", "get_relationships"), 28 | "get_tactics": ("query.tactics", "get_tactics"), 29 | "get_data_sources": ("query.data_sources", "get_data_sources"), 30 | "get_technique_by_name": ("query.techniques", "get_technique_by_name"), 31 | "get_techniques_by_content": ("query.techniques", "get_techniques_by_content"), 32 | "get_techniques_by_platform": ("query.techniques", "get_techniques_by_platform"), 33 | "get_techniques_by_tactic": ("query.techniques", "get_techniques_by_tactic"), 34 | "get_object_by_attack_id": ("query", "get_object_by_attack_id"), 35 | "get_campaign_by_alias": ("query.campaigns", "get_campaign_by_alias"), 36 | "get_group_by_alias": ("query.groups", "get_group_by_alias"), 37 | "get_campaigns_since_time": ("query.campaigns", "get_campaigns_since_time"), 38 | "get_techniques_since_time": ("query.techniques", "get_techniques_since_time"), 39 | # Enterprise domain 40 | "get_enterprise": ("enterprise", "get"), 41 | "get_enterprise_campaigns": ("enterprise", "get_campaigns"), 42 | "get_enterprise_techniques": ("enterprise", "get_techniques"), 43 | "get_enterprise_data_components": ("enterprise", "get_data_components"), 44 | "get_enterprise_mitigations": ("enterprise", "get_mitigations"), 45 | "get_enterprise_groups": ("enterprise", "get_groups"), 46 | "get_enterprise_malware": ("enterprise", "get_malware"), 47 | "get_enterprise_tools": ("enterprise", "get_tools"), 48 | "get_enterprise_relationships": ("enterprise", "get_relationships"), 49 | "get_enterprise_tactics": ("enterprise", "get_tactics"), 50 | "get_enterprise_data_sources": ("enterprise", "get_data_sources"), 51 | # Mobile domain 52 | "get_mobile": ("mobile", "get"), 53 | "get_mobile_campaigns": ("mobile", "get_campaigns"), 54 | "get_mobile_techniques": ("mobile", "get_techniques"), 55 | "get_mobile_data_components": ("mobile", "get_data_components"), 56 | "get_mobile_mitigations": ("mobile", "get_mitigations"), 57 | "get_mobile_groups": ("mobile", "get_groups"), 58 | "get_mobile_malware": ("mobile", "get_malware"), 59 | "get_mobile_tools": ("mobile", "get_tools"), 60 | "get_mobile_relationships": ("mobile", "get_relationships"), 61 | "get_mobile_tactics": ("mobile", "get_tactics"), 62 | "get_mobile_data_sources": ("mobile", "get_data_sources"), 63 | # ICS domain 64 | "get_ics": ("ics", "get"), 65 | "get_ics_campaigns": ("ics", "get_campaigns"), 66 | "get_ics_techniques": ("ics", "get_techniques"), 67 | "get_ics_data_components": ("ics", "get_data_components"), 68 | "get_ics_mitigations": ("ics", "get_mitigations"), 69 | "get_ics_groups": ("ics", "get_groups"), 70 | "get_ics_malware": ("ics", "get_malware"), 71 | "get_ics_tools": ("ics", "get_tools"), 72 | "get_ics_relationships": ("ics", "get_relationships"), 73 | "get_ics_tactics": ("ics", "get_tactics"), 74 | "get_ics_data_sources": ("ics", "get_data_sources"), 75 | # Detections 76 | "get_detection_strategies": ("query.detections", "get_detection_strategies"), 77 | "get_analytics": ("query.detections", "get_analytics"), 78 | "get_detection_strategies_by_technique": ("query.detections", "get_detection_strategies_by_technique"), 79 | "get_analytics_by_technique": ("query.detections", "get_analytics_by_technique"), 80 | "get_log_source_references_by_technique": ("query.detections", "get_log_source_references_by_technique"), 81 | "get_data_components_by_technique_via_analytics": ("query.detections", "get_data_components_by_technique_via_analytics"), 82 | # Relationships 83 | "get_relationships_by_object": ("query.relationships", "get_relationships_by_object"), 84 | "get_techniques_by_relationship": ("query.relationships", "get_techniques_by_relationship"), 85 | "get_techniques_used_by_group": ("query.relationships", "get_techniques_used_by_group"), 86 | "get_techniques_used_by_all_groups": ("query.relationships", "get_techniques_used_by_all_groups"), 87 | "get_software_used_by_group": ("query.relationships", "get_software_used_by_group"), 88 | "get_techniques_used_by_software": ("query.relationships", "get_techniques_used_by_software"), 89 | "get_techniques_used_by_group_software": ("query.relationships", "get_techniques_used_by_group_software"), 90 | "get_techniques_mitigated_by_mitigations": ("query.relationships", "get_techniques_mitigated_by_mitigations"), 91 | "get_data_components_by_technique": ("query.detections", "get_data_components_by_technique_via_analytics"), 92 | "export_groups_navigator_layers": ("query.relationships", "export_groups_navigator_layers"), 93 | } 94 | 95 | 96 | def _make_delegator(property_name: str, method_name: str, legacy_name: str) -> Callable[..., Any]: 97 | def delegator(self: Any, *args: Any, **kwargs: Any) -> Any: 98 | target = self 99 | for attr in property_name.split("."): 100 | target = getattr(target, attr) 101 | method = getattr(target, method_name) 102 | return method(*args, **kwargs) 103 | 104 | delegator.__name__ = legacy_name 105 | delegator.__qualname__ = legacy_name 106 | delegator.__doc__ = f"Legacy alias for `{property_name}.{method_name}()`." 107 | return delegator 108 | 109 | 110 | def attach_legacy_methods(client_cls: type) -> None: 111 | """Attach legacy methods to the given MitreAttackClient class.""" 112 | for legacy_name, (property_name, method_name) in LEGACY_METHODS.items(): 113 | if hasattr(client_cls, legacy_name): 114 | continue 115 | setattr(client_cls, legacy_name, _make_delegator(property_name, method_name, legacy_name)) 116 | -------------------------------------------------------------------------------- /docs/playground/0-Download-ATTACK-STIX-Data.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Download ATT&CK STIX Data" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "## Import STIX Downloader" 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": 1, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "from attackcti.utils.downloader import STIXDownloader" 24 | ] 25 | }, 26 | { 27 | "cell_type": "markdown", 28 | "metadata": {}, 29 | "source": [ 30 | "## Initialize STIX 2.0 Downloader" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": 2, 36 | "metadata": {}, 37 | "outputs": [], 38 | "source": [ 39 | "stix20_downloader = STIXDownloader(download_dir=\"./downloads\", stix_version=\"2.0\")" 40 | ] 41 | }, 42 | { 43 | "cell_type": "markdown", 44 | "metadata": {}, 45 | "source": [ 46 | "## Download ATT&CK Enterprise v15.1" 47 | ] 48 | }, 49 | { 50 | "cell_type": "code", 51 | "execution_count": 3, 52 | "metadata": {}, 53 | "outputs": [ 54 | { 55 | "name": "stdout", 56 | "output_type": "stream", 57 | "text": [ 58 | "Downloaded enterprise-attack.json to downloads/v16.1\n" 59 | ] 60 | } 61 | ], 62 | "source": [ 63 | "stix20_downloader.download_attack_data(domain=\"enterprise\", release=\"16.1\")" 64 | ] 65 | }, 66 | { 67 | "cell_type": "markdown", 68 | "metadata": {}, 69 | "source": [ 70 | "## Initialize STX 2.1 Downloader " 71 | ] 72 | }, 73 | { 74 | "cell_type": "code", 75 | "execution_count": 4, 76 | "metadata": {}, 77 | "outputs": [], 78 | "source": [ 79 | "stix21_downloader = STIXDownloader(download_dir=\"./downloads\", stix_version=\"2.1\")" 80 | ] 81 | }, 82 | { 83 | "cell_type": "markdown", 84 | "metadata": {}, 85 | "source": [ 86 | "## Download ATT&CK Mobile v15.1" 87 | ] 88 | }, 89 | { 90 | "cell_type": "code", 91 | "execution_count": 5, 92 | "metadata": {}, 93 | "outputs": [ 94 | { 95 | "name": "stdout", 96 | "output_type": "stream", 97 | "text": [ 98 | "Downloaded mobile-attack.json to downloads/v16.1\n" 99 | ] 100 | } 101 | ], 102 | "source": [ 103 | "stix21_downloader.download_attack_data(domain=\"mobile\", release=\"16.1\")" 104 | ] 105 | }, 106 | { 107 | "cell_type": "code", 108 | "execution_count": 6, 109 | "metadata": {}, 110 | "outputs": [ 111 | { 112 | "data": { 113 | "text/plain": [ 114 | "'downloads/v16.1/mobile-attack.json'" 115 | ] 116 | }, 117 | "execution_count": 6, 118 | "metadata": {}, 119 | "output_type": "execute_result" 120 | } 121 | ], 122 | "source": [ 123 | "stix21_downloader.downloaded_file_path" 124 | ] 125 | }, 126 | { 127 | "cell_type": "code", 128 | "execution_count": 7, 129 | "metadata": {}, 130 | "outputs": [ 131 | { 132 | "data": { 133 | "text/plain": [ 134 | "{'mobile': 'downloads/v16.1/mobile-attack.json'}" 135 | ] 136 | }, 137 | "execution_count": 7, 138 | "metadata": {}, 139 | "output_type": "execute_result" 140 | } 141 | ], 142 | "source": [ 143 | "stix21_downloader.downloaded_file_paths" 144 | ] 145 | }, 146 | { 147 | "cell_type": "markdown", 148 | "metadata": {}, 149 | "source": [ 150 | "## Initialize STIX Storage" 151 | ] 152 | }, 153 | { 154 | "cell_type": "code", 155 | "execution_count": 8, 156 | "metadata": {}, 157 | "outputs": [], 158 | "source": [ 159 | "from attackcti.utils.storage import STIXStore" 160 | ] 161 | }, 162 | { 163 | "cell_type": "code", 164 | "execution_count": 9, 165 | "metadata": {}, 166 | "outputs": [], 167 | "source": [ 168 | "store = STIXStore(stix21_downloader.downloaded_file_path)" 169 | ] 170 | }, 171 | { 172 | "cell_type": "code", 173 | "execution_count": 10, 174 | "metadata": {}, 175 | "outputs": [], 176 | "source": [ 177 | "from stix2 import Filter\n", 178 | "\n", 179 | "filters = [Filter(\"type\", \"=\", \"attack-pattern\")]\n", 180 | "\n", 181 | "techniques = store.source.query(filters)" 182 | ] 183 | }, 184 | { 185 | "cell_type": "code", 186 | "execution_count": 11, 187 | "metadata": {}, 188 | "outputs": [ 189 | { 190 | "data": { 191 | "text/plain": [ 192 | "187" 193 | ] 194 | }, 195 | "execution_count": 11, 196 | "metadata": {}, 197 | "output_type": "execute_result" 198 | } 199 | ], 200 | "source": [ 201 | "len(techniques)" 202 | ] 203 | }, 204 | { 205 | "cell_type": "markdown", 206 | "metadata": {}, 207 | "source": [ 208 | "## Download All Domains at Once" 209 | ] 210 | }, 211 | { 212 | "cell_type": "code", 213 | "execution_count": 12, 214 | "metadata": {}, 215 | "outputs": [], 216 | "source": [ 217 | "stix20_downloader = STIXDownloader(download_dir=\"./downloads\", stix_version=\"2.0\")" 218 | ] 219 | }, 220 | { 221 | "cell_type": "code", 222 | "execution_count": 13, 223 | "metadata": {}, 224 | "outputs": [ 225 | { 226 | "name": "stdout", 227 | "output_type": "stream", 228 | "text": [ 229 | "Downloaded enterprise-attack.json to downloads/v16.1\n", 230 | "Downloaded mobile-attack.json to downloads/v16.1\n", 231 | "Downloaded ics-attack.json to downloads/v16.1\n" 232 | ] 233 | }, 234 | { 235 | "data": { 236 | "text/plain": [ 237 | "{'enterprise': 'downloads/v16.1/enterprise-attack.json',\n", 238 | " 'mobile': 'downloads/v16.1/mobile-attack.json',\n", 239 | " 'ics': 'downloads/v16.1/ics-attack.json'}" 240 | ] 241 | }, 242 | "execution_count": 13, 243 | "metadata": {}, 244 | "output_type": "execute_result" 245 | } 246 | ], 247 | "source": [ 248 | "stix20_downloader.download_all_domains(release=\"16.1\")" 249 | ] 250 | }, 251 | { 252 | "cell_type": "code", 253 | "execution_count": null, 254 | "metadata": {}, 255 | "outputs": [], 256 | "source": [] 257 | } 258 | ], 259 | "metadata": { 260 | "kernelspec": { 261 | "display_name": ".venv", 262 | "language": "python", 263 | "name": "python3" 264 | }, 265 | "language_info": { 266 | "codemirror_mode": { 267 | "name": "ipython", 268 | "version": 3 269 | }, 270 | "file_extension": ".py", 271 | "mimetype": "text/x-python", 272 | "name": "python", 273 | "nbconvert_exporter": "python", 274 | "pygments_lexer": "ipython3", 275 | "version": "3.13.3" 276 | } 277 | }, 278 | "nbformat": 4, 279 | "nbformat_minor": 2 280 | } 281 | -------------------------------------------------------------------------------- /src/attackcti/core/query_client.py: -------------------------------------------------------------------------------- 1 | """Convenience wrapper for cross-domain query helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | from stix2 import CompositeDataSource, Filter 8 | 9 | from ..models import pydantic_model_mapping 10 | from ..utils.stix import parse_stix_objects, remove_revoked_deprecated 11 | from .objects.analytics import AnalyticsClient 12 | from .objects.campaigns import CampaignsClient 13 | from .objects.data_sources import DataSourcesClient 14 | from .objects.detections import DetectionsClient 15 | from .objects.groups import GroupsClient 16 | from .objects.mitigations import MitigationsClient 17 | from .objects.relationships import RelationshipsClient 18 | from .objects.software import SoftwareClient 19 | from .objects.tactics import TacticsClient 20 | from .objects.techniques import TechniquesClient 21 | 22 | 23 | class QueryClient: 24 | """Cross-domain query client (COMPOSITE_DS-backed).""" 25 | 26 | def __init__( 27 | self, 28 | data_source: CompositeDataSource, 29 | *, 30 | pydantic_map: dict[str, object] | None = None, 31 | ) -> None: 32 | """Initialize the query client with shared data source and helpers.""" 33 | self.data_source = data_source 34 | self.pydantic_map = pydantic_map or pydantic_model_mapping 35 | 36 | # Initialize object-specific clients 37 | self._relationships_client: RelationshipsClient = RelationshipsClient( 38 | data_source=data_source, 39 | get_techniques_fn=None, 40 | get_groups_fn=None, 41 | get_data_components_fn=None, 42 | get_data_sources_fn=None, 43 | remove_fn=remove_revoked_deprecated, 44 | parse_fn=parse_stix_objects, 45 | ) 46 | self._techniques_client = TechniquesClient( 47 | data_source=data_source, 48 | remove_fn=remove_revoked_deprecated, 49 | parse_fn=parse_stix_objects, 50 | ) 51 | self._campaigns_client: CampaignsClient = CampaignsClient( 52 | data_source=data_source, 53 | remove_fn=remove_revoked_deprecated, 54 | parse_fn=parse_stix_objects, 55 | ) 56 | self._mitigations_client: MitigationsClient = MitigationsClient( 57 | data_source=data_source, 58 | remove_fn=remove_revoked_deprecated, 59 | parse_fn=parse_stix_objects, 60 | ) 61 | self._analytics_client: AnalyticsClient = AnalyticsClient( 62 | data_source=data_source, 63 | remove_fn=remove_revoked_deprecated, 64 | parse_fn=parse_stix_objects, 65 | ) 66 | self._detections_client: DetectionsClient = DetectionsClient( 67 | data_source=data_source, 68 | get_analytics_by_ids_fn = None, 69 | get_data_components_by_ids_fn = None, 70 | remove_fn=remove_revoked_deprecated, 71 | parse_fn=parse_stix_objects, 72 | ) 73 | self._groups_client: GroupsClient = GroupsClient( 74 | data_source=data_source, 75 | remove_fn=remove_revoked_deprecated, 76 | parse_fn=parse_stix_objects, 77 | ) 78 | self._data_sources_client: DataSourcesClient = DataSourcesClient( 79 | data_source=data_source, 80 | remove_fn=remove_revoked_deprecated, 81 | parse_fn=parse_stix_objects, 82 | ) 83 | self._software_client: SoftwareClient = SoftwareClient( 84 | data_source=data_source, 85 | remove_fn=remove_revoked_deprecated, 86 | parse_fn=parse_stix_objects, 87 | ) 88 | self._tactics_client: TacticsClient = TacticsClient( 89 | data_source=data_source, 90 | parse_fn=parse_stix_objects, 91 | ) 92 | # Link detections client to techniques client for enrichment 93 | self._techniques_client.set_enrich_with_detections_fn(self._detections_client.enrich_techniques_with_detections) 94 | self._techniques_client.set_enrich_data_components_fn(self._detections_client.enrich_techniques_with_data_components) 95 | 96 | # Link techniques client to relationships client for enrichment 97 | self._relationships_client.set_get_techniques_fn(self._techniques_client.get_techniques) 98 | self._relationships_client.set_get_groups_fn(self._groups_client.get_groups) 99 | self._relationships_client.set_get_data_components_fn(self._data_sources_client.get_data_components) 100 | self._relationships_client.set_get_data_sources_fn(self._data_sources_client.get_data_sources) 101 | # Link analytics and data source clients to detections client for enrichment 102 | self._detections_client.set_get_analytics_by_ids_fn(self._analytics_client.get_analytics_by_ids) 103 | self._detections_client.set_get_data_components_by_ids_fn(self._data_sources_client.get_data_components_by_ids) 104 | 105 | @property 106 | def campaigns(self) -> CampaignsClient: 107 | """Return the campaigns client (cached).""" 108 | return self._campaigns_client 109 | 110 | @property 111 | def techniques(self) -> TechniquesClient: 112 | """Return the techniques client (cached).""" 113 | return self._techniques_client 114 | 115 | @property 116 | def mitigations(self) -> MitigationsClient: 117 | """Return the mitigations client (cached).""" 118 | return self._mitigations_client 119 | 120 | @property 121 | def analytics(self) -> AnalyticsClient: 122 | """Return the analytis client (cached).""" 123 | return self._analytics_client 124 | 125 | @property 126 | def detections(self) -> DetectionsClient: 127 | """Return the detections client (cached).""" 128 | return self._detections_client 129 | 130 | @property 131 | def groups(self) -> GroupsClient: 132 | """Return the groups client (cached).""" 133 | return self._groups_client 134 | 135 | @property 136 | def relationships(self) -> RelationshipsClient: 137 | """Return the relationships client (cached).""" 138 | return self._relationships_client 139 | 140 | @property 141 | def data_sources(self) -> DataSourcesClient: 142 | """Return the data sources client (cached).""" 143 | return self._data_sources_client 144 | 145 | @property 146 | def software(self) -> SoftwareClient: 147 | """Return the software client (cached).""" 148 | return self._software_client 149 | 150 | @property 151 | def tactics(self) -> TacticsClient: 152 | """Return the tactics client (cached).""" 153 | return self._tactics_client 154 | 155 | def get_object_by_attack_id(self, object_type: str, attack_id: str, *, stix_format: bool = True) -> list[Any]: 156 | """Return STIX objects by ATT&CK external id. 157 | 158 | Parameters 159 | ---------- 160 | object_type 161 | STIX type to query (e.g., attack-pattern). 162 | attack_id 163 | ATT&CK external reference id (e.g., T1003). 164 | stix_format 165 | When `True`, return STIX objects/dicts; when `False`, parse to the 166 | mapped Pydantic model if available. 167 | 168 | Returns 169 | ------- 170 | list[Any] 171 | Matching STIX objects in the requested format. 172 | 173 | Raises 174 | ------ 175 | ValueError 176 | If an unsupported `object_type` is provided. 177 | """ 178 | valid_objects = { 179 | "attack-pattern", 180 | "course-of-action", 181 | "intrusion-set", 182 | "malware", 183 | "tool", 184 | "x-mitre-data-source", 185 | "x-mitre-data-component", 186 | "campaign", 187 | } 188 | if object_type not in valid_objects: 189 | raise ValueError(f"ERROR: Valid object must be one of {valid_objects}") 190 | 191 | filter_objects = [Filter("type", "=", object_type), Filter("external_references.external_id", "=", attack_id)] 192 | all_stix_objects = self.data_source.query(filter_objects) 193 | if not stix_format: 194 | pydantic_model = pydantic_model_mapping.get(object_type) 195 | if pydantic_model: 196 | all_stix_objects = parse_stix_objects(all_stix_objects, pydantic_model) 197 | return all_stix_objects 198 | -------------------------------------------------------------------------------- /docs/playground/11-Initialize_Client_Local_STIX_data.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Initialize ATTACK Client with Local STIX Data" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "## Local JSON Files" 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": 1, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "local_paths = {\n", 24 | " 'enterprise': '.attackcti/stix-2.1/v18.1/enterprise-attack.json',\n", 25 | " 'mobile': '.attackcti/stix-2.1/v18.1mobile-attack.json',\n", 26 | " 'ics': '.attackcti/stix-2.1/v18.1ics-attack.json'\n", 27 | "}" 28 | ] 29 | }, 30 | { 31 | "cell_type": "markdown", 32 | "metadata": {}, 33 | "source": [ 34 | "## Initialize ATTACK Client" 35 | ] 36 | }, 37 | { 38 | "cell_type": "code", 39 | "execution_count": 2, 40 | "metadata": {}, 41 | "outputs": [], 42 | "source": [ 43 | "from attackcti import MitreAttackClient" 44 | ] 45 | }, 46 | { 47 | "cell_type": "code", 48 | "execution_count": 3, 49 | "metadata": {}, 50 | "outputs": [], 51 | "source": [ 52 | "lift = MitreAttackClient(local_paths=local_paths)" 53 | ] 54 | }, 55 | { 56 | "cell_type": "code", 57 | "execution_count": 4, 58 | "metadata": {}, 59 | "outputs": [], 60 | "source": [ 61 | "enterprise_techniques = lift.get_enterprise_techniques()" 62 | ] 63 | }, 64 | { 65 | "cell_type": "code", 66 | "execution_count": 5, 67 | "metadata": {}, 68 | "outputs": [ 69 | { 70 | "data": { 71 | "text/plain": [ 72 | "691" 73 | ] 74 | }, 75 | "execution_count": 5, 76 | "metadata": {}, 77 | "output_type": "execute_result" 78 | } 79 | ], 80 | "source": [ 81 | "len(enterprise_techniques)" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": 6, 87 | "metadata": {}, 88 | "outputs": [ 89 | { 90 | "data": { 91 | "text/plain": [ 92 | "AttackPattern(type='attack-pattern', spec_version='2.1', id='attack-pattern--005a06c6-14bf-4118-afa0-ebcd8aebb0c9', created_by_ref='identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', created='2019-11-27T14:58:00.429Z', modified='2025-10-24T17:48:19.176Z', name='Scheduled Task', description='Adversaries may abuse the Windows Task Scheduler to perform task scheduling for initial or recurring execution of malicious code. There are multiple ways to access the Task Scheduler in Windows. The [schtasks](https://attack.mitre.org/software/S0111) utility can be run directly on the command line, or the Task Scheduler can be opened through the GUI within the Administrator Tools section of the Control Panel.(Citation: Stack Overflow) In some cases, adversaries have used a .NET wrapper for the Windows Task Scheduler, and alternatively, adversaries have used the Windows netapi32 library and [Windows Management Instrumentation](https://attack.mitre.org/techniques/T1047) (WMI) to create a scheduled task. Adversaries may also utilize the Powershell Cmdlet `Invoke-CimMethod`, which leverages WMI class `PS_ScheduledTask` to create a scheduled task via an XML path.(Citation: Red Canary - Atomic Red Team)\\n\\nAn adversary may use Windows Task Scheduler to execute programs at system startup or on a scheduled basis for persistence. The Windows Task Scheduler can also be abused to conduct remote Execution as part of Lateral Movement and/or to run a process under the context of a specified account (such as SYSTEM). Similar to [System Binary Proxy Execution](https://attack.mitre.org/techniques/T1218), adversaries have also abused the Windows Task Scheduler to potentially mask one-time execution under signed/trusted system processes.(Citation: ProofPoint Serpent)\\n\\nAdversaries may also create \"hidden\" scheduled tasks (i.e. [Hide Artifacts](https://attack.mitre.org/techniques/T1564)) that may not be visible to defender tools and manual queries used to enumerate tasks. Specifically, an adversary may hide a task from `schtasks /query` and the Task Scheduler by deleting the associated Security Descriptor (SD) registry value (where deletion of this value must be completed using SYSTEM permissions).(Citation: SigmaHQ)(Citation: Tarrask scheduled task) Adversaries may also employ alternate methods to hide tasks, such as altering the metadata (e.g., `Index` value) within associated registry keys.(Citation: Defending Against Scheduled Task Attacks in Windows Environments) ', kill_chain_phases=[KillChainPhase(kill_chain_name='mitre-attack', phase_name='execution'), KillChainPhase(kill_chain_name='mitre-attack', phase_name='persistence'), KillChainPhase(kill_chain_name='mitre-attack', phase_name='privilege-escalation')], revoked=False, external_references=[ExternalReference(source_name='mitre-attack', url='https://attack.mitre.org/techniques/T1053/005', external_id='T1053.005'), ExternalReference(source_name='ProofPoint Serpent', description='Campbell, B. et al. (2022, March 21). Serpent, No Swiping! New Backdoor Targets French Entities with Unique Attack Chain. Retrieved April 11, 2022.', url='https://www.proofpoint.com/us/blog/threat-insight/serpent-no-swiping-new-backdoor-targets-french-entities-unique-attack-chain'), ExternalReference(source_name='Defending Against Scheduled Task Attacks in Windows Environments', description='Harshal Tupsamudre. (2022, June 20). Defending Against Scheduled Tasks. Retrieved July 5, 2022.', url='https://blog.qualys.com/vulnerabilities-threat-research/2022/06/20/defending-against-scheduled-task-attacks-in-windows-environments'), ExternalReference(source_name='Twitter Leoloobeek Scheduled Task', description='Loobeek, L. (2017, December 8). leoloobeek Status. Retrieved September 12, 2024.', url='https://x.com/leoloobeek/status/939248813465853953'), ExternalReference(source_name='Tarrask scheduled task', description='Microsoft Threat Intelligence Team & Detection and Response Team . (2022, April 12). Tarrask malware uses scheduled tasks for defense evasion. Retrieved June 1, 2022.', url='https://www.microsoft.com/security/blog/2022/04/12/tarrask-malware-uses-scheduled-tasks-for-defense-evasion/'), ExternalReference(source_name='Microsoft Scheduled Task Events Win10', description='Microsoft. (2017, May 28). Audit Other Object Access Events. Retrieved June 27, 2019.', url='https://docs.microsoft.com/en-us/windows/security/threat-protection/auditing/audit-other-object-access-events'), ExternalReference(source_name='TechNet Scheduled Task Events', description='Microsoft. (n.d.). General Task Registration. Retrieved December 12, 2017.', url='https://technet.microsoft.com/library/dd315590.aspx'), ExternalReference(source_name='Red Canary - Atomic Red Team', description='Red Canary - Atomic Red Team. (n.d.). T1053.005 - Scheduled Task/Job: Scheduled Task. Retrieved June 19, 2024.', url='https://github.com/redcanaryco/atomic-red-team/blob/master/atomics/T1053.005/T1053.005.md'), ExternalReference(source_name='TechNet Autoruns', description='Russinovich, M. (2016, January 4). Autoruns for Windows v13.51. Retrieved June 6, 2016.', url='https://technet.microsoft.com/en-us/sysinternals/bb963902'), ExternalReference(source_name='TechNet Forum Scheduled Task Operational Setting', description='Satyajit321. (2015, November 3). Scheduled Tasks History Retention settings. Retrieved December 12, 2017.', url='https://social.technet.microsoft.com/Forums/en-US/e5bca729-52e7-4fcb-ba12-3225c564674c/scheduled-tasks-history-retention-settings?forum=winserver8gen'), ExternalReference(source_name='SigmaHQ', description='Sittikorn S. (2022, April 15). Removal Of SD Value to Hide Schedule Task - Registry. Retrieved June 1, 2022.', url='https://github.com/SigmaHQ/sigma/blob/master/rules/windows/registry/registry_delete/registry_delete_schtasks_hide_task_via_sd_value_removal.yml'), ExternalReference(source_name='Stack Overflow', description='Stack Overflow. (n.d.). How to find the location of the Scheduled Tasks folder. Retrieved June 19, 2024.', url='https://stackoverflow.com/questions/2913816/how-to-find-the-location-of-the-scheduled-tasks-folder')], object_marking_refs=['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], x_mitre_attack_spec_version='3.3.0', x_mitre_contributors=['Andrew Northern, @ex_raritas', 'Bryan Campbell, @bry_campbell', 'Selena Larson, @selenalarson', 'Sittikorn Sangrattanapitak', 'Zachary Abzug, @ZackDoesML'], x_mitre_deprecated=False, x_mitre_detection='', x_mitre_domains=['enterprise-attack'], x_mitre_is_subtechnique=True, x_mitre_modified_by_ref='identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_platforms=['Windows'], x_mitre_remote_support=False, x_mitre_version='1.8')" 93 | ] 94 | }, 95 | "execution_count": 6, 96 | "metadata": {}, 97 | "output_type": "execute_result" 98 | } 99 | ], 100 | "source": [ 101 | "enterprise_techniques[1]" 102 | ] 103 | }, 104 | { 105 | "cell_type": "code", 106 | "execution_count": null, 107 | "metadata": {}, 108 | "outputs": [], 109 | "source": [] 110 | } 111 | ], 112 | "metadata": { 113 | "kernelspec": { 114 | "display_name": ".venv", 115 | "language": "python", 116 | "name": "python3" 117 | }, 118 | "language_info": { 119 | "codemirror_mode": { 120 | "name": "ipython", 121 | "version": 3 122 | }, 123 | "file_extension": ".py", 124 | "mimetype": "text/x-python", 125 | "name": "python", 126 | "nbconvert_exporter": "python", 127 | "pygments_lexer": "ipython3", 128 | "version": "3.13.3" 129 | } 130 | }, 131 | "nbformat": 4, 132 | "nbformat_minor": 2 133 | } 134 | -------------------------------------------------------------------------------- /src/attackcti/utils/downloader.py: -------------------------------------------------------------------------------- 1 | """Download utilities for ATT&CK STIX bundles.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import os 7 | import re 8 | from pathlib import Path 9 | from typing import Dict, List, Optional, Union 10 | 11 | import requests 12 | 13 | 14 | class STIXDownloader: 15 | """Download ATT&CK STIX bundles from upstream repositories.""" 16 | 17 | def __init__(self, download_dir: str, domain: Optional[str] = None, stix_version: Optional[str] = None, use_session: bool = False): 18 | """Initialize the downloader with optional defaults. 19 | 20 | Args: 21 | download_dir (str): Directory to download the STIX files to. 22 | domain (Optional[str]): Default ATT&CK domain from the following list ["enterprise", "mobile", "ics"]. 23 | stix_version (Optional[str]): Default version of STIX to download. Options are "2.0" or "2.1". 24 | use_session (bool): Whether to use a persistent session for HTTP requests. Defaults to False. 25 | """ 26 | self.download_dir = download_dir 27 | self.domain = domain 28 | self.stix_version = stix_version 29 | self.use_session = use_session 30 | self.cti_base_url = "https://raw.githubusercontent.com/mitre/cti/" 31 | self.stix_data_base_url = "https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/" 32 | self.session = requests.Session() if use_session else None # Use a session if specified 33 | self.downloaded_file_paths: Dict[str, str] = {} # Attribute to store the full paths of the downloaded files 34 | 35 | @staticmethod 36 | def fetch_attack_stix2_0_versions() -> List[str]: 37 | """Fetch available ATT&CK versions in STIX 2.0 format. 38 | 39 | Returns 40 | ------- 41 | List[str]: A list of available ATT&CK versions in STIX 2.0 format. 42 | """ 43 | ref_to_tag = re.compile(r"ATT&CK-v(.*)") 44 | resp = requests.get("https://api.github.com/repos/mitre/cti/git/refs/tags", timeout=30) 45 | resp.raise_for_status() 46 | tags = resp.json() 47 | versions = [ref_to_tag.search(tag["ref"]).groups()[0] for tag in tags if "ATT&CK-v" in tag["ref"]] 48 | return versions 49 | 50 | @staticmethod 51 | def fetch_attack_stix2_1_versions() -> List[str]: 52 | """Fetch available ATT&CK versions in STIX 2.1 format. 53 | 54 | Returns 55 | ------- 56 | List[str]: A list of available ATT&CK versions in STIX 2.1 format. 57 | """ 58 | index_url = "https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/index.json" 59 | resp = requests.get(index_url, timeout=30) 60 | resp.raise_for_status() 61 | index_data = resp.json() 62 | versions = [v["version"] for v in index_data["collections"][0]["versions"]] 63 | return versions 64 | 65 | @staticmethod 66 | def _version_key(version: str) -> tuple[int, ...]: 67 | """Return a comparable key for dotted ATT&CK versions (e.g., '18.1').""" 68 | parts: list[int] = [] 69 | for part in str(version).split("."): 70 | try: 71 | parts.append(int(part)) 72 | except ValueError: 73 | # Fallback: treat non-numeric segments as 0. 74 | parts.append(0) 75 | return tuple(parts) 76 | 77 | def download_file(self, url: str, dest_path: Union[str, Path]) -> None: 78 | """Download a file from `url` to `dest_path`. 79 | 80 | Args: 81 | url (str): URL of the file to download. 82 | dest_path (str | Path): Destination file path to save the downloaded file. 83 | 84 | Raises 85 | ------ 86 | requests.HTTPError: If the download request fails. 87 | """ 88 | if self.session: 89 | response = self.session.get(url, stream=True, timeout=60) # Use session if available 90 | else: 91 | response = requests.get(url, stream=True, timeout=60) # Otherwise, use a regular request 92 | 93 | response.raise_for_status() 94 | with open(dest_path, 'wb') as f: 95 | for chunk in response.iter_content(chunk_size=8192): 96 | f.write(chunk) 97 | 98 | def is_pretty_printed(self, file_path: Union[str, Path]) -> bool: 99 | """Heuristically detect whether a JSON file is already pretty-printed. 100 | 101 | This is a best-effort check to avoid reformatting files that already have 102 | indentation and newlines. It intentionally only inspects a small prefix 103 | of the file for performance. 104 | """ 105 | path = Path(file_path) 106 | with path.open("rb") as f: 107 | prefix = f.read(8192) 108 | 109 | # If the file contains no newlines at all, it's almost certainly compact/minified. 110 | if b"\n" not in prefix and b"\r" not in prefix: 111 | return False 112 | 113 | # Detect an indentation pattern on a subsequent line. 114 | # Example: '\n "objects": ...' 115 | return re.search(rb"\r?\n[ \t]{2,}\"", prefix) is not None 116 | 117 | def pretty_print_json(self, file_path: Union[str, Path]) -> None: 118 | """Rewrite a JSON file with indentation (atomic write).""" 119 | path = Path(file_path) 120 | data = json.loads(path.read_text(encoding="utf-8")) 121 | 122 | tmp_path = path.with_suffix(path.suffix + ".tmp") 123 | tmp_path.write_text( 124 | json.dumps(data, indent=4, ensure_ascii=False) + "\n", 125 | encoding="utf-8", 126 | ) 127 | os.replace(tmp_path, path) 128 | 129 | def download_attack_data( 130 | self, 131 | stix_version: Optional[str] = None, 132 | domain: Optional[str] = None, 133 | release: Optional[str] = None, 134 | pretty_print: Optional[bool] = None, 135 | *, 136 | force: bool = True, 137 | ): 138 | """Download an ATT&CK STIX release file. 139 | 140 | Args: 141 | stix_version (Optional[str]): Version of STIX to download. Options are "2.0" or "2.1". If not specified, uses the default. 142 | domain (Optional[str]): An ATT&CK domain from the following list ["enterprise", "mobile", "ics"]. If not specified, uses the default. 143 | release (Optional[str]): ATT&CK release to download. If not specified, downloads the latest release. 144 | pretty_print (Optional[bool]): Whether to pretty-print the JSON file after downloading. If None, do not pretty-print. 145 | force (bool): When `False`, skip downloading if the destination file already exists. 146 | 147 | Raises 148 | ------ 149 | ValueError: If the STIX version is invalid or the release version does not exist. 150 | """ 151 | stix_version = stix_version or self.stix_version 152 | domain = domain or self.domain 153 | 154 | if stix_version not in ["2.0", "2.1"]: 155 | raise ValueError("Invalid STIX version. Choose '2.0' or '2.1'.") 156 | 157 | resolved_release: str | None = release 158 | if stix_version == "2.0": 159 | base_url = self.cti_base_url 160 | if release is None: 161 | release_dir = "master" 162 | else: 163 | versions = self.fetch_attack_stix2_0_versions() 164 | if release not in versions: 165 | raise ValueError(f"Release {release} not found in cti repository.") 166 | release_dir = f"ATT%26CK-v{release}" 167 | url_path = f"{release_dir}/{domain}-attack/{domain}-attack.json" 168 | else: 169 | base_url = self.stix_data_base_url 170 | if release is None: 171 | # Prefer a versioned file so we can name the directory by the actual version. 172 | # This requires a versions lookup from the upstream index.json. 173 | try: 174 | versions = self.fetch_attack_stix2_1_versions() 175 | resolved_release = max(versions, key=self._version_key) if versions else None 176 | except Exception: 177 | resolved_release = None 178 | 179 | if resolved_release: 180 | url_path = f"{domain}-attack/{domain}-attack-{resolved_release}.json" 181 | else: 182 | # Fallback to the unversioned latest bundle if index lookup fails. 183 | url_path = f"{domain}-attack/{domain}-attack.json" 184 | else: 185 | versions = self.fetch_attack_stix2_1_versions() 186 | if release not in versions: 187 | raise ValueError(f"Release {release} not found in attack-stix-data repository.") 188 | url_path = f"{domain}-attack/{domain}-attack-{release}.json" 189 | 190 | download_url = f"{base_url}{url_path}" 191 | 192 | release_folder = f"v{resolved_release}" if resolved_release else "latest" 193 | release_download_dir = Path(self.download_dir) / release_folder 194 | release_download_dir.mkdir(parents=True, exist_ok=True) 195 | 196 | dest_path = release_download_dir / f"{domain}-attack.json" 197 | if not force and dest_path.exists(): 198 | self.downloaded_file_path = str(dest_path) 199 | self.downloaded_file_paths[domain] = str(dest_path) 200 | return 201 | 202 | self.download_file(download_url, dest_path) 203 | 204 | self.downloaded_file_path = str(dest_path) # Store the full path of the downloaded file 205 | self.downloaded_file_paths[domain] = str(dest_path) # Store the path for the specific domain 206 | 207 | if pretty_print: 208 | if not self.is_pretty_printed(dest_path): 209 | self.pretty_print_json(dest_path) 210 | 211 | print(f"Downloaded {domain}-attack.json to {release_download_dir}") 212 | 213 | def download_all_domains( 214 | self, 215 | stix_version: Optional[str] = None, 216 | release: Optional[str] = None, 217 | pretty_print: Optional[bool] = None, 218 | *, 219 | force: bool = True, 220 | ): 221 | """Download ATT&CK STIX release files for all domains. 222 | 223 | Args: 224 | stix_version (Optional[str]): Version of STIX to download. Options are "2.0" or "2.1". If not specified, uses the default. 225 | release (Optional[str]): ATT&CK release to download. If not specified, downloads the latest release. 226 | pretty_print (Optional[bool]): Whether to pretty-print the JSON file after downloading. If None, do not pretty-print. 227 | force (bool): When `False`, skip downloading files that already exist. 228 | """ 229 | domains = ["enterprise", "mobile", "ics"] 230 | for domain in domains: 231 | self.download_attack_data( 232 | stix_version=stix_version, 233 | domain=domain, 234 | release=release, 235 | pretty_print=pretty_print, 236 | force=force, 237 | ) 238 | 239 | return self.downloaded_file_paths 240 | -------------------------------------------------------------------------------- /tests/fixtures/simple_attack_bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "bundle", 3 | "id": "bundle--b8b9b0a2-6a5b-41ee-9ed3-6612b45cf66a", 4 | "spec_version": "2.1", 5 | "objects": [ 6 | { 7 | "type": "attack-pattern", 8 | "spec_version": "2.1", 9 | "id": "attack-pattern--f6d79fc5-6e2a-4c1b-9d22-3be5da1be56f", 10 | "created": "2024-01-01T00:00:00.000Z", 11 | "modified": "2024-01-01T01:00:00.000Z", 12 | "name": "Sample Technique", 13 | "description": "A synthetic technique for tests.", 14 | "x_mitre_domains": ["enterprise-attack"], 15 | "x_mitre_platforms": ["Windows"], 16 | "external_references": [ 17 | { 18 | "source_name": "mitre-attack", 19 | "external_id": "T0001" 20 | } 21 | ], 22 | "kill_chain_phases": [ 23 | { 24 | "kill_chain_name": "mitre-attack", 25 | "phase_name": "execution" 26 | } 27 | ] 28 | }, 29 | { 30 | "type": "intrusion-set", 31 | "spec_version": "2.1", 32 | "id": "intrusion-set--d63f0054-df6c-47d9-8a83-d8c8a2ea3c57", 33 | "created": "2024-01-01T00:00:00.000Z", 34 | "modified": "2024-01-01T01:00:00.000Z", 35 | "name": "Sample Group", 36 | "description": "A synthetic group for tests.", 37 | "aliases": [ 38 | "Sample Group Alias" 39 | ], 40 | "external_references": [ 41 | { 42 | "source_name": "mitre-attack", 43 | "external_id": "G0001" 44 | } 45 | ] 46 | }, 47 | { 48 | "type": "relationship", 49 | "spec_version": "2.1", 50 | "id": "relationship--4d7bc865-2e89-4f71-9f82-40c65dd36a2d", 51 | "created": "2024-01-01T00:10:00.000Z", 52 | "modified": "2024-01-01T01:10:00.000Z", 53 | "relationship_type": "uses", 54 | "source_ref": "intrusion-set--d63f0054-df6c-47d9-8a83-d8c8a2ea3c57", 55 | "target_ref": "attack-pattern--f6d79fc5-6e2a-4c1b-9d22-3be5da1be56f", 56 | "description": "Sample Group uses Sample Technique." 57 | }, 58 | { 59 | "type": "x-mitre-detection-strategy", 60 | "spec_version": "2.1", 61 | "id": "x-mitre-detection-strategy--a4a22b0c-19c8-4f60-8d08-ecf2f3d9a1a5", 62 | "created": "2024-01-01T00:00:00.000Z", 63 | "modified": "2024-01-01T01:00:00.000Z", 64 | "name": "Sample Detection Strategy", 65 | "x_mitre_analytic_refs": [ 66 | "x-mitre-analytic--0d5f91d8-8c35-4dab-8dd7-1f7c7efe3b77" 67 | ], 68 | "x_mitre_domains": ["enterprise-attack"] 69 | }, 70 | { 71 | "type": "x-mitre-analytic", 72 | "spec_version": "2.1", 73 | "id": "x-mitre-analytic--0d5f91d8-8c35-4dab-8dd7-1f7c7efe3b77", 74 | "created": "2024-01-01T00:05:00.000Z", 75 | "modified": "2024-01-01T01:05:00.000Z", 76 | "name": "Sample Analytic", 77 | "description": "Synthetic analytic for tests.", 78 | "x_mitre_log_source_references": [ 79 | { 80 | "x_mitre_data_component_ref": "x-mitre-data-component--ce5f38c9-dafe-4c1b-8ab4-8504e9e4d4ea", 81 | "name": "WinEventLog:Security", 82 | "channel": "EventCode=4688" 83 | } 84 | ], 85 | "x_mitre_domains": ["enterprise-attack"] 86 | }, 87 | { 88 | "type": "x-mitre-data-component", 89 | "spec_version": "2.1", 90 | "id": "x-mitre-data-component--ce5f38c9-dafe-4c1b-8ab4-8504e9e4d4ea", 91 | "created": "2024-01-01T00:00:00.000Z", 92 | "modified": "2024-01-01T01:00:00.000Z", 93 | "name": "Process Creation", 94 | "description": "Synthetic data component for tests.", 95 | "x_mitre_log_sources": [ 96 | { 97 | "name": "WinEventLog:Security", 98 | "channel": "EventCode=4688" 99 | } 100 | ], 101 | "external_references": [ 102 | { 103 | "source_name": "mitre-attack", 104 | "external_id": "DC0001" 105 | } 106 | ] 107 | }, 108 | { 109 | "type": "relationship", 110 | "spec_version": "2.1", 111 | "id": "relationship--1c99a1ef-3f60-4c28-bb8b-1430de7b5bd7", 112 | "created": "2024-01-01T00:20:00.000Z", 113 | "modified": "2024-01-01T01:20:00.000Z", 114 | "relationship_type": "detects", 115 | "source_ref": "x-mitre-detection-strategy--a4a22b0c-19c8-4f60-8d08-ecf2f3d9a1a5", 116 | "target_ref": "attack-pattern--f6d79fc5-6e2a-4c1b-9d22-3be5da1be56f" 117 | }, 118 | { 119 | "type": "x-mitre-data-source", 120 | "spec_version": "2.1", 121 | "id": "x-mitre-data-source--3f8c362f-890a-4b60-9b9c-2b2a5822a444", 122 | "created": "2024-01-01T00:00:00.000Z", 123 | "modified": "2024-01-01T01:00:00.000Z", 124 | "name": "Windows Event Logs", 125 | "description": "Event logs produced by Microsoft Windows operating systems.", 126 | "x_mitre_domains": [ 127 | "enterprise-attack" 128 | ], 129 | "x_mitre_data_components": [ 130 | "x-mitre-data-component--ce5f38c9-dafe-4c1b-8ab4-8504e9e4d4ea" 131 | ] 132 | }, 133 | { 134 | "type": "x-mitre-tactic", 135 | "spec_version": "2.1", 136 | "id": "x-mitre-tactic--3a1a2a7a-1a56-4ca4-9e12-4e7d213f1b51", 137 | "created": "2024-01-01T00:05:00.000Z", 138 | "modified": "2024-01-01T01:05:00.000Z", 139 | "name": "Execution", 140 | "x_mitre_shortname": "execution", 141 | "x_mitre_domains": [ 142 | "enterprise-attack" 143 | ] 144 | }, 145 | { 146 | "type": "x-mitre-matrix", 147 | "spec_version": "2.1", 148 | "id": "x-mitre-matrix--5c1d9b1e-9f9b-4e68-8d78-1e5fbf5a87e2", 149 | "created": "2024-01-01T00:00:00.000Z", 150 | "modified": "2024-01-01T01:00:00.000Z", 151 | "name": "Enterprise ATT&CK Matrix", 152 | "description": "A synthetic subset of the Enterprise ATT&CK matrix for tests.", 153 | "x_mitre_domain": "enterprise-attack", 154 | "x_mitre_version": "12" 155 | }, 156 | { 157 | "type": "campaign", 158 | "spec_version": "2.1", 159 | "id": "campaign--0f00b751-6ad3-4d0d-8d3e-b637d5587ee5", 160 | "created": "2024-01-01T00:10:00.000Z", 161 | "modified": "2024-01-01T01:10:00.000Z", 162 | "name": "Sample Campaign", 163 | "description": "A synthetic campaign linking back to Sample Group.", 164 | "aliases": [ 165 | "Sample Campaign Alias" 166 | ], 167 | "object_marking_refs": [ 168 | "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168" 169 | ] 170 | }, 171 | { 172 | "type": "malware", 173 | "spec_version": "2.1", 174 | "id": "malware--22ad4d95-0d6a-4f6a-9e8b-780daef3b842", 175 | "created": "2024-01-01T00:12:00.000Z", 176 | "modified": "2024-01-01T01:12:00.000Z", 177 | "name": "Sample Malware", 178 | "description": "Provides synthetic context for tests.", 179 | "x_mitre_platforms": [ 180 | "Windows" 181 | ], 182 | "object_marking_refs": [ 183 | "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168" 184 | ], 185 | "is_family": false 186 | }, 187 | { 188 | "type": "software", 189 | "spec_version": "2.1", 190 | "id": "software--7c7f8c0a-907a-4d75-868c-41f16b8dcf6e", 191 | "created": "2024-01-01T00:15:00.000Z", 192 | "modified": "2024-01-01T01:15:00.000Z", 193 | "name": "Sample Software", 194 | "description": "Used by the sample campaign.", 195 | "x_mitre_platforms": [ 196 | "Windows" 197 | ], 198 | "object_marking_refs": [ 199 | "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168" 200 | ], 201 | "is_family": false 202 | }, 203 | { 204 | "type": "tool", 205 | "spec_version": "2.1", 206 | "id": "tool--2f9f0cb5-61cb-4e5f-95d5-f243d0c0d5a5", 207 | "created": "2024-01-01T00:17:00.000Z", 208 | "modified": "2024-01-01T01:17:00.000Z", 209 | "name": "Sample Tool", 210 | "description": "Supplementary tool entry for tests.", 211 | "object_marking_refs": [ 212 | "marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168" 213 | ], 214 | "is_family": false 215 | }, 216 | { 217 | "type": "course-of-action", 218 | "spec_version": "2.1", 219 | "id": "course-of-action--526c95c3-3ea6-4da4-88f2-9cde76e5e7f7", 220 | "created": "2024-01-01T00:18:00.000Z", 221 | "modified": "2024-01-01T01:18:00.000Z", 222 | "name": "Sample Mitigation", 223 | "description": "Mitigates the Sample Technique." 224 | }, 225 | { 226 | "type": "asset", 227 | "spec_version": "2.1", 228 | "id": "asset--e1cc8f7d-26df-44d3-9db5-1aed6c047fdc", 229 | "created": "2024-01-01T00:19:00.000Z", 230 | "modified": "2024-01-01T01:19:00.000Z", 231 | "name": "Sample Asset", 232 | "description": "Infrastructure asset for testing.", 233 | "asset_type": "workstation" 234 | }, 235 | { 236 | "type": "collection", 237 | "spec_version": "2.1", 238 | "id": "collection--d2e94c7b-d61b-4cac-a61d-2f1f0b3ab5c8", 239 | "created": "2024-01-01T00:20:00.000Z", 240 | "modified": "2024-01-01T01:20:00.000Z", 241 | "name": "Sample Collection", 242 | "description": "A placeholder collection entry." 243 | }, 244 | { 245 | "type": "relationship", 246 | "spec_version": "2.1", 247 | "id": "relationship--8fca4d55-3f4f-4b1c-896a-79e18aa8db4a", 248 | "created": "2024-01-01T00:25:00.000Z", 249 | "modified": "2024-01-01T01:25:00.000Z", 250 | "relationship_type": "uses", 251 | "source_ref": "campaign--0f00b751-6ad3-4d0d-8d3e-b637d5587ee5", 252 | "target_ref": "malware--22ad4d95-0d6a-4f6a-9e8b-780daef3b842" 253 | }, 254 | { 255 | "type": "relationship", 256 | "spec_version": "2.1", 257 | "id": "relationship--4b3d3f1e-6f12-4dd5-9300-54f0a0e4d6df", 258 | "created": "2024-01-01T00:26:00.000Z", 259 | "modified": "2024-01-01T01:26:00.000Z", 260 | "relationship_type": "uses", 261 | "source_ref": "malware--22ad4d95-0d6a-4f6a-9e8b-780daef3b842", 262 | "target_ref": "attack-pattern--f6d79fc5-6e2a-4c1b-9d22-3be5da1be56f" 263 | }, 264 | { 265 | "type": "relationship", 266 | "spec_version": "2.1", 267 | "id": "relationship--6c6fa7c8-4b3a-46ae-9a67-9557c1fc7c0c", 268 | "created": "2024-01-01T00:27:00.000Z", 269 | "modified": "2024-01-01T01:27:00.000Z", 270 | "relationship_type": "uses", 271 | "source_ref": "software--7c7f8c0a-907a-4d75-868c-41f16b8dcf6e", 272 | "target_ref": "attack-pattern--f6d79fc5-6e2a-4c1b-9d22-3be5da1be56f" 273 | }, 274 | { 275 | "type": "relationship", 276 | "spec_version": "2.1", 277 | "id": "relationship--c5748b1b-a4e7-4a3d-8f8b-37101bac73fc", 278 | "created": "2024-01-01T00:28:00.000Z", 279 | "modified": "2024-01-01T01:28:00.000Z", 280 | "relationship_type": "uses", 281 | "source_ref": "intrusion-set--d63f0054-df6c-47d9-8a83-d8c8a2ea3c57", 282 | "target_ref": "tool--2f9f0cb5-61cb-4e5f-95d5-f243d0c0d5a5" 283 | }, 284 | { 285 | "type": "relationship", 286 | "spec_version": "2.1", 287 | "id": "relationship--09c3d60f-7603-4be4-92b9-7bcf8817849c", 288 | "created": "2024-01-01T00:29:00.000Z", 289 | "modified": "2024-01-01T01:29:00.000Z", 290 | "relationship_type": "mitigates", 291 | "source_ref": "course-of-action--526c95c3-3ea6-4da4-88f2-9cde76e5e7f7", 292 | "target_ref": "attack-pattern--f6d79fc5-6e2a-4c1b-9d22-3be5da1be56f" 293 | } 294 | ] 295 | } 296 | -------------------------------------------------------------------------------- /src/attackcti/utils/stix.py: -------------------------------------------------------------------------------- 1 | """STIX utility helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | from collections.abc import Callable 7 | from itertools import islice 8 | from pathlib import Path 9 | from typing import Any, Iterable, Iterator, Sequence 10 | 11 | import stix2 12 | from pydantic import BaseModel, TypeAdapter 13 | from stix2 import CompositeDataSource, Filter, MemorySource, TAXIICollectionSource 14 | from stix2.datastore.filters import apply_common_filters 15 | 16 | from ..models import LoadedStix, pydantic_model_mapping 17 | 18 | 19 | def as_dict(obj: Any) -> dict[str, Any]: 20 | """Return a dictionary view of a STIX object or raw mapping.""" 21 | if isinstance(obj, dict): 22 | return obj 23 | 24 | try: 25 | return json.loads(obj.serialize()) 26 | except Exception: 27 | inner = getattr(obj, "_inner", None) 28 | if isinstance(inner, dict): 29 | return inner 30 | try: 31 | return dict(obj) 32 | except Exception: 33 | return {} 34 | 35 | 36 | def stix_id(obj: Any) -> str | None: 37 | """Return the STIX `id` from a dict or python-stix2 object.""" 38 | if isinstance(obj, dict): 39 | value = obj.get("id") 40 | return value if isinstance(value, str) and value else None 41 | value = getattr(obj, "id", None) 42 | return value if isinstance(value, str) and value else None 43 | 44 | 45 | def stix_type(obj: Any) -> str | None: 46 | """Return the STIX `type` from a dict or python-stix2 object.""" 47 | if isinstance(obj, dict): 48 | value = obj.get("type") 49 | return value if isinstance(value, str) and value else None 50 | value = getattr(obj, "type", None) 51 | return value if isinstance(value, str) and value else None 52 | 53 | 54 | def relationship_ref(obj: Any, key: str) -> str | None: 55 | """Extract a STIX relationship reference field from dicts or python-stix2 objects.""" 56 | if isinstance(obj, dict): 57 | value = obj.get(key) 58 | return value if isinstance(value, str) and value else None 59 | value = getattr(obj, key, None) 60 | return value if isinstance(value, str) and value else None 61 | 62 | 63 | def relationship_source_ref(obj: Any) -> str | None: 64 | """Return `source_ref` from a relationship object/dict.""" 65 | return relationship_ref(obj, "source_ref") 66 | 67 | 68 | def relationship_target_ref(obj: Any) -> str | None: 69 | """Return `target_ref` from a relationship object/dict.""" 70 | return relationship_ref(obj, "target_ref") 71 | 72 | 73 | def get_stix_objects( 74 | *, 75 | source: TAXIICollectionSource | MemorySource, 76 | filter_objects: dict[str, Filter | Callable[[], Any]], 77 | stix_format: bool = True, 78 | ) -> dict[str, list[Any]]: 79 | """ 80 | Retrieve STIX objects from the specified TAXII or MemorySource collection source based on the given filters or methods. 81 | 82 | Depending on the 'stix_format' flag, this function returns the STIX objects in their original format or 83 | as parsed objects based on Pydantic models. 84 | 85 | Args: 86 | source (TAXIICollectionSource): The TAXII collection source to query for STIX objects. 87 | filter_objects (Dict[str, Union[Filter, Callable]]): A mapping of object types to their respective 88 | TAXII filters or custom methods that return STIX objects. 89 | stix_format (bool, optional): If True, returns STIX objects in their original format. If False, returns the results 90 | as parsed objects based on Pydantic models, providing a user-friendly representation. 91 | 92 | Returns 93 | ------- 94 | Dict[str, List]: A dictionary categorizing STIX objects by their types. Each key represents an object 95 | type (e.g., 'techniques', 'campaigns'), and each value is a list of STIX objects in their original format 96 | or parsed objects based on Pydantic models, depending on the 'stix_format' flag. 97 | """ 98 | stix_objects_result: dict[str, list[Any]] = {} 99 | for key, method_or_filter in filter_objects.items(): 100 | if isinstance(method_or_filter, Filter): 101 | objects = source.query(method_or_filter) 102 | else: 103 | objects = method_or_filter() 104 | 105 | if not stix_format and pydantic_model_mapping is not None: 106 | pydantic_model = pydantic_model_mapping.get(key) 107 | if pydantic_model is not None: 108 | objects = parse_stix_objects(objects, pydantic_model) 109 | 110 | stix_objects_result[key] = objects 111 | 112 | return stix_objects_result 113 | 114 | 115 | def parse_stix_objects(stix_objects: list[Any], model: type[BaseModel]) -> list[dict[str, Any]]: 116 | """ 117 | Convert a list of STIX objects to dictionaries and parse them into the specified Pydantic model. 118 | 119 | Args: 120 | stix_objects (List): The list of STIX objects to parse. 121 | model (Type[BaseModel]): The Pydantic model class to use for parsing. 122 | 123 | Returns 124 | ------- 125 | List[Dict[str, Any]]: A list of dictionaries. 126 | """ 127 | objects_as_dicts = [json.loads(obj.serialize()) if not isinstance(obj, dict) else obj for obj in stix_objects] 128 | type_adapter = TypeAdapter(list[model]) 129 | parsed_objects = type_adapter.validate_python(objects_as_dicts) 130 | return [obj.model_dump() for obj in parsed_objects] 131 | 132 | 133 | def remove_revoked_deprecated(stix_objects: list[Any]) -> list[Any]: 134 | """ 135 | Remove any revoked or deprecated objects from queries made to the data source. 136 | 137 | References 138 | ---------- 139 | - https://github.com/mitre/cti/issues/127 140 | - https://github.com/mitre/cti/blob/master/USAGE.md#removing-revoked-and-deprecated-objects 141 | 142 | Args: 143 | stix_objects (List): List of STIX objects. 144 | 145 | Returns 146 | ------- 147 | List: List of STIX objects excluding revoked and deprecated ones. 148 | """ 149 | 150 | def _field(value: Any, key: str, default: Any = False) -> Any: 151 | if isinstance(value, dict): 152 | return value.get(key, default) 153 | getter = getattr(value, "get", None) 154 | if callable(getter): 155 | try: 156 | return getter(key, default) 157 | except Exception: 158 | pass 159 | try: 160 | return value[key] # type: ignore[index] 161 | except Exception: 162 | return getattr(value, key, default) 163 | 164 | filtered: list[Any] = [] 165 | for obj in stix_objects: 166 | if _field(obj, "x_mitre_deprecated", False) is True: 167 | continue 168 | if _field(obj, "revoked", False) is True: 169 | continue 170 | filtered.append(obj) 171 | return filtered 172 | 173 | 174 | def extract_revoked(stix_objects: list[Any]) -> list[Any]: 175 | """ 176 | Extract revoked objects from STIX objects. 177 | 178 | Reference: 179 | - https://stix2.readthedocs.io/en/latest/api/datastore/stix2.datastore.filters.html 180 | 181 | Args: 182 | stix_objects (List): List of STIX objects. 183 | 184 | Returns 185 | ------- 186 | List: List of revoked STIX objects. 187 | """ 188 | return list(apply_common_filters(stix_objects, [Filter("revoked", "=", True)])) 189 | 190 | 191 | def extract_deprecated(stix_objects: list[Any]) -> list[Any]: 192 | """ 193 | Extract deprecated objects from STIX objects. 194 | 195 | Reference: 196 | - https://stix2.readthedocs.io/en/latest/api/datastore/stix2.datastore.filters.html 197 | 198 | Args: 199 | stix_objects (List): List of STIX objects. 200 | 201 | Returns 202 | ------- 203 | List: List of deprecated STIX objects. 204 | """ 205 | return list(apply_common_filters(stix_objects, [Filter("x_mitre_deprecated", "=", True)])) 206 | 207 | 208 | CHUNK_SIZE = 500 209 | 210 | 211 | def chunked_iterable(iterable: Iterable[Any], size: int = CHUNK_SIZE) -> Iterator[list[Any]]: 212 | """Yield consecutive chunks from an iterable.""" 213 | it = iter(iterable) 214 | while True: 215 | chunk = list(islice(it, size)) 216 | if not chunk: 217 | break 218 | yield chunk 219 | 220 | 221 | def query_stix_objects_by_ids( 222 | data_source: CompositeDataSource, 223 | stix_type: str, 224 | ids: Iterable[str], 225 | chunk_size: int = CHUNK_SIZE, 226 | ) -> list[Any]: 227 | """Batch-query STIX objects by type and id.""" 228 | id_list = [iid for iid in ids if isinstance(iid, str) and iid] 229 | if not id_list: 230 | return [] 231 | 232 | collected: list[Any] = [] 233 | for chunk in chunked_iterable(id_list, size=chunk_size): 234 | collected.extend( 235 | data_source.query( 236 | [ 237 | Filter("type", "=", stix_type), 238 | Filter("id", "in", list(chunk)), 239 | ] 240 | ) 241 | ) 242 | return collected 243 | 244 | 245 | # STIX JSON loading helpers 246 | 247 | def detect_bundle_spec_version(payload: dict[str, Any]) -> str | None: 248 | """Detect STIX `spec_version` from a bundle/object dict.""" 249 | spec_version = payload.get("spec_version") 250 | if isinstance(spec_version, str): 251 | return spec_version 252 | if payload.get("type") == "bundle" and isinstance(payload.get("objects"), list): 253 | for obj in payload["objects"]: 254 | if isinstance(obj, dict): 255 | inner = obj.get("spec_version") 256 | if isinstance(inner, str): 257 | return inner 258 | # Some STIX 2.0 content may omit spec_version; stix2 will infer. 259 | return None 260 | 261 | 262 | def iter_stix_dicts_from_json(payload: dict[str, Any]) -> Iterator[dict[str, Any]]: 263 | """Yield STIX object dictionaries from either a bundle or a single object.""" 264 | if payload.get("type") == "bundle" and isinstance(payload.get("objects"), list): 265 | for obj in payload["objects"]: 266 | if isinstance(obj, dict): 267 | yield obj 268 | return 269 | yield payload 270 | 271 | 272 | def parse_stix_dicts_to_objects( 273 | stix_dicts: Iterable[dict[str, Any]], 274 | *, 275 | allow_custom: bool = True, 276 | version: str | None = None, 277 | ) -> list[Any]: 278 | """Parse STIX dictionaries into python-stix2 objects.""" 279 | parsed: list[Any] = [] 280 | for obj in stix_dicts: 281 | parsed.append(stix2.parse(obj, allow_custom=allow_custom, version=version)) 282 | return parsed 283 | 284 | 285 | def load_stix_json_file(path: str | Path, *, allow_custom: bool = True) -> LoadedStix: 286 | """Load a STIX JSON file (bundle or single object) into STIX objects.""" 287 | file_path = Path(path) 288 | payload = json.loads(file_path.read_text(encoding="utf-8")) 289 | spec_version = detect_bundle_spec_version(payload) 290 | stix_dicts = list(iter_stix_dicts_from_json(payload)) 291 | objects = parse_stix_dicts_to_objects(stix_dicts, allow_custom=allow_custom, version=spec_version) 292 | return LoadedStix(spec_version=spec_version, objects=objects) 293 | 294 | 295 | def load_stix_json_files(paths: Sequence[str | Path], *, allow_custom: bool = True) -> LoadedStix: 296 | """Load multiple STIX JSON files and merge their objects.""" 297 | merged_objects: list[Any] = [] 298 | spec_version: str | None = None 299 | for p in paths: 300 | loaded = load_stix_json_file(p, allow_custom=allow_custom) 301 | merged_objects.extend(loaded.objects) 302 | spec_version = spec_version or loaded.spec_version 303 | return LoadedStix(spec_version=spec_version, objects=merged_objects) 304 | 305 | 306 | def find_json_files(root: str | Path) -> list[Path]: 307 | """Find JSON files under a directory (recursively).""" 308 | base = Path(root) 309 | return sorted([p for p in base.rglob("*.json") if p.is_file()]) 310 | -------------------------------------------------------------------------------- /docs/playground/9-Explore_Campaigns.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Exploring MITRE ATT&CK v12 Campaigns\n", 8 | "------------------\n", 9 | "Reference: https://github.com/OTRF/ATTACK-Python-Client/pull/62" 10 | ] 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "metadata": {}, 15 | "source": [ 16 | "## Import ATTACK API Client" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": 1, 22 | "metadata": {}, 23 | "outputs": [], 24 | "source": [ 25 | "from attackcti import MitreAttackClient" 26 | ] 27 | }, 28 | { 29 | "cell_type": "markdown", 30 | "metadata": {}, 31 | "source": [ 32 | "## Initialize ATT&CK Client Variable" 33 | ] 34 | }, 35 | { 36 | "cell_type": "code", 37 | "execution_count": 2, 38 | "metadata": {}, 39 | "outputs": [], 40 | "source": [ 41 | "lift = MitreAttackClient.from_attack_stix_data()" 42 | ] 43 | }, 44 | { 45 | "cell_type": "markdown", 46 | "metadata": {}, 47 | "source": [ 48 | "## Get Enterprise Techniques" 49 | ] 50 | }, 51 | { 52 | "cell_type": "code", 53 | "execution_count": 3, 54 | "metadata": {}, 55 | "outputs": [ 56 | { 57 | "data": { 58 | "text/plain": [ 59 | "52" 60 | ] 61 | }, 62 | "execution_count": 3, 63 | "metadata": {}, 64 | "output_type": "execute_result" 65 | } 66 | ], 67 | "source": [ 68 | "enterprise_campaigns = lift.get_enterprise_campaigns()\n", 69 | "len(enterprise_campaigns)" 70 | ] 71 | }, 72 | { 73 | "cell_type": "code", 74 | "execution_count": 4, 75 | "metadata": {}, 76 | "outputs": [ 77 | { 78 | "name": "stdout", 79 | "output_type": "stream", 80 | "text": [ 81 | "{\"type\": \"campaign\", \"spec_version\": \"2.1\", \"id\": \"campaign--df74f7ad-b10d-431c-9f1d-a2bc18dadefa\", \"created_by_ref\": \"identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5\", \"created\": \"2023-06-30T19:28:30.616Z\", \"modified\": \"2025-04-16T20:37:45.650Z\", \"name\": \"C0027\", \"description\": \"[C0027](https://attack.mitre.org/campaigns/C0027) was a financially-motivated campaign linked to [Scattered Spider](https://attack.mitre.org/groups/G1015) that targeted telecommunications and business process outsourcing (BPO) companies from at least June through December of 2022. During [C0027](https://attack.mitre.org/campaigns/C0027) [Scattered Spider](https://attack.mitre.org/groups/G1015) used various forms of social engineering, performed SIM swapping, and attempted to leverage access from victim environments to mobile carrier networks.(Citation: Crowdstrike TELCO BPO Campaign December 2022)\\n\", \"aliases\": [\"C0027\"], \"first_seen\": \"2022-06-01T04:00:00Z\", \"last_seen\": \"2022-12-01T05:00:00Z\", \"external_references\": [{\"source_name\": \"mitre-attack\", \"url\": \"https://attack.mitre.org/campaigns/C0027\", \"external_id\": \"C0027\"}, {\"source_name\": \"Crowdstrike TELCO BPO Campaign December 2022\", \"description\": \"Parisi, T. (2022, December 2). Not a SIMulation: CrowdStrike Investigations Reveal Intrusion Campaign Targeting Telco and BPO Companies. Retrieved June 30, 2023.\", \"url\": \"https://www.crowdstrike.com/blog/analysis-of-intrusion-campaign-targeting-telecom-and-bpo-companies/\"}], \"object_marking_refs\": [\"marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168\"], \"x_mitre_attack_spec_version\": \"3.2.0\", \"x_mitre_deprecated\": false, \"x_mitre_domains\": [\"enterprise-attack\"], \"x_mitre_first_seen_citation\": \"(Citation: Crowdstrike TELCO BPO Campaign December 2022)\", \"x_mitre_last_seen_citation\": \"(Citation: Crowdstrike TELCO BPO Campaign December 2022)\", \"x_mitre_modified_by_ref\": \"identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5\", \"x_mitre_version\": \"1.0\"}\n" 82 | ] 83 | } 84 | ], 85 | "source": [ 86 | "print(enterprise_campaigns[0])" 87 | ] 88 | }, 89 | { 90 | "cell_type": "markdown", 91 | "metadata": {}, 92 | "source": [ 93 | "## Get Mobile Campaigns" 94 | ] 95 | }, 96 | { 97 | "cell_type": "code", 98 | "execution_count": 5, 99 | "metadata": {}, 100 | "outputs": [ 101 | { 102 | "data": { 103 | "text/plain": [ 104 | "3" 105 | ] 106 | }, 107 | "execution_count": 5, 108 | "metadata": {}, 109 | "output_type": "execute_result" 110 | } 111 | ], 112 | "source": [ 113 | "mobile_campaigns = lift.get_mobile_campaigns()\n", 114 | "len(mobile_campaigns)" 115 | ] 116 | }, 117 | { 118 | "cell_type": "code", 119 | "execution_count": 6, 120 | "metadata": {}, 121 | "outputs": [ 122 | { 123 | "name": "stdout", 124 | "output_type": "stream", 125 | "text": [ 126 | "{\"type\": \"campaign\", \"spec_version\": \"2.1\", \"id\": \"campaign--d0695b5f-b761-49e0-b3e3-2e5307f8def3\", \"created_by_ref\": \"identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5\", \"created\": \"2025-03-28T14:45:30.132Z\", \"modified\": \"2025-03-28T15:23:16.915Z\", \"name\": \"Operation Triangulation\", \"description\": \"[Operation Triangulation](https://attack.mitre.org/campaigns/C0054) is a mobile campaign targeting iOS devices.(Citation: SecureList OpTriangulation 01Jun2023) The unidentified actors used zero-click exploits in iMessage attachments to gain [Initial Access](https://attack.mitre.org/tactics/TA0027), then executed exploits and validators, such as [Binary Validator](https://attack.mitre.org/software/S1215) before finally executing the [TriangleDB](https://attack.mitre.org/software/S1216) implant. \", \"aliases\": [\"Operation Triangulation\"], \"first_seen\": \"2019-01-01T08:00:00Z\", \"last_seen\": \"2023-06-01T07:00:00Z\", \"external_references\": [{\"source_name\": \"mitre-attack\", \"url\": \"https://attack.mitre.org/campaigns/C0054\", \"external_id\": \"C0054\"}, {\"source_name\": \"SecureList OpTriangulation 01Jun2023\", \"description\": \"Kuznetsov, I., et al. (2023, June 1). Operation Triangulation: iOS devices targeted with previously unknown malware. Retrieved April 18, 2024.\", \"url\": \"https://securelist.com/operation-triangulation/109842/\"}], \"object_marking_refs\": [\"marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168\"], \"x_mitre_attack_spec_version\": \"3.2.0\", \"x_mitre_deprecated\": false, \"x_mitre_domains\": [\"mobile-attack\"], \"x_mitre_first_seen_citation\": \"(Citation: SecureList OpTriangulation 01Jun2023)\", \"x_mitre_last_seen_citation\": \"(Citation: SecureList OpTriangulation 01Jun2023)\", \"x_mitre_modified_by_ref\": \"identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5\", \"x_mitre_version\": \"1.0\"}\n" 127 | ] 128 | } 129 | ], 130 | "source": [ 131 | "print(mobile_campaigns[0])" 132 | ] 133 | }, 134 | { 135 | "cell_type": "markdown", 136 | "metadata": {}, 137 | "source": [ 138 | "## Get All Campaigns" 139 | ] 140 | }, 141 | { 142 | "cell_type": "code", 143 | "execution_count": 7, 144 | "metadata": {}, 145 | "outputs": [ 146 | { 147 | "data": { 148 | "text/plain": [ 149 | "55" 150 | ] 151 | }, 152 | "execution_count": 7, 153 | "metadata": {}, 154 | "output_type": "execute_result" 155 | } 156 | ], 157 | "source": [ 158 | "all_campaigns = lift.get_campaigns()\n", 159 | "len(all_campaigns)" 160 | ] 161 | }, 162 | { 163 | "cell_type": "markdown", 164 | "metadata": {}, 165 | "source": [ 166 | "## Get Campaign by Alias" 167 | ] 168 | }, 169 | { 170 | "cell_type": "code", 171 | "execution_count": 8, 172 | "metadata": {}, 173 | "outputs": [ 174 | { 175 | "data": { 176 | "text/plain": [ 177 | "[Campaign(type='campaign', spec_version='2.1', id='campaign--78068e68-4124-4243-b6f4-76e4e5be8a06', created_by_ref='identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', created='2022-09-29T16:42:29.364Z', modified='2025-04-16T20:37:46.910Z', name='C0015', description='[C0015](https://attack.mitre.org/campaigns/C0015) was a ransomware intrusion during which the unidentified attackers used [Bazar](https://attack.mitre.org/software/S0534), [Cobalt Strike](https://attack.mitre.org/software/S0154), and [Conti](https://attack.mitre.org/software/S0575), along with other tools, over a 5 day period. Security researchers assessed the actors likely used the widely-circulated [Conti](https://attack.mitre.org/software/S0575) ransomware playbook based on the observed pattern of activity and operator errors.(Citation: DFIR Conti Bazar Nov 2021)', aliases=['C0015'], first_seen='2021-08-01T05:00:00Z', last_seen='2021-08-01T05:00:00Z', revoked=False, external_references=[ExternalReference(source_name='mitre-attack', url='https://attack.mitre.org/campaigns/C0015', external_id='C0015'), ExternalReference(source_name='DFIR Conti Bazar Nov 2021', description='DFIR Report. (2021, November 29). CONTInuing the Bazar Ransomware Story. Retrieved September 29, 2022.', url='https://thedfirreport.com/2021/11/29/continuing-the-bazar-ransomware-story/')], object_marking_refs=['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], x_mitre_attack_spec_version='3.2.0', x_mitre_contributors=['Matt Brenton, Zurich Insurance Group'], x_mitre_deprecated=False, x_mitre_domains=['enterprise-attack'], x_mitre_first_seen_citation='(Citation: DFIR Conti Bazar Nov 2021)', x_mitre_last_seen_citation='(Citation: DFIR Conti Bazar Nov 2021)', x_mitre_modified_by_ref='identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version='1.0')]" 178 | ] 179 | }, 180 | "execution_count": 8, 181 | "metadata": {}, 182 | "output_type": "execute_result" 183 | } 184 | ], 185 | "source": [ 186 | "lift.get_campaign_by_alias(alias=\"C0015\")" 187 | ] 188 | }, 189 | { 190 | "cell_type": "markdown", 191 | "metadata": {}, 192 | "source": [ 193 | "## Get Campaigns Since" 194 | ] 195 | }, 196 | { 197 | "cell_type": "code", 198 | "execution_count": 9, 199 | "metadata": {}, 200 | "outputs": [ 201 | { 202 | "data": { 203 | "text/plain": [ 204 | "56" 205 | ] 206 | }, 207 | "execution_count": 9, 208 | "metadata": {}, 209 | "output_type": "execute_result" 210 | } 211 | ], 212 | "source": [ 213 | "campaigns_since = lift.get_campaigns_since_time(timestamp=\"2017-01-31T13:49:53.935Z\")\n", 214 | "len(campaigns_since)" 215 | ] 216 | }, 217 | { 218 | "cell_type": "markdown", 219 | "metadata": {}, 220 | "source": [ 221 | "## Get Campaign By Object ID" 222 | ] 223 | }, 224 | { 225 | "cell_type": "code", 226 | "execution_count": 10, 227 | "metadata": {}, 228 | "outputs": [ 229 | { 230 | "data": { 231 | "text/plain": [ 232 | "[Campaign(type='campaign', spec_version='2.1', id='campaign--26d9ebae-de59-427f-ae9a-349456bae4b1', created_by_ref='identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', created='2022-09-07T13:40:09.750Z', modified='2025-04-16T20:37:47.239Z', name='Frankenstein', description=\"[Frankenstein](https://attack.mitre.org/campaigns/C0001) was described by security researchers as a highly-targeted campaign conducted by moderately sophisticated and highly resourceful threat actors in early 2019. The unidentified actors primarily relied on open source tools, including [Empire](https://attack.mitre.org/software/S0363). The campaign name refers to the actors' ability to piece together several unrelated open-source tool components.(Citation: Talos Frankenstein June 2019)\", aliases=['Frankenstein'], first_seen='2019-01-01T06:00:00Z', last_seen='2019-04-01T05:00:00Z', revoked=False, external_references=[ExternalReference(source_name='mitre-attack', url='https://attack.mitre.org/campaigns/C0001', external_id='C0001'), ExternalReference(source_name='Talos Frankenstein June 2019', description=\"Adamitis, D. et al. (2019, June 4). It's alive: Threat actors cobble together open-source pieces into monstrous Frankenstein campaign. Retrieved May 11, 2020.\", url='https://blog.talosintelligence.com/2019/06/frankenstein-campaign.html')], object_marking_refs=['marking-definition--fa42a846-8d90-4e51-bc29-71d5b4802168'], x_mitre_attack_spec_version='3.2.0', x_mitre_deprecated=False, x_mitre_domains=['enterprise-attack'], x_mitre_first_seen_citation='(Citation: Talos Frankenstein June 2019)', x_mitre_last_seen_citation='(Citation: Talos Frankenstein June 2019)', x_mitre_modified_by_ref='identity--c78cb6e5-0c4b-4611-8297-d1b8b55e40b5', x_mitre_version='1.1')]" 233 | ] 234 | }, 235 | "execution_count": 10, 236 | "metadata": {}, 237 | "output_type": "execute_result" 238 | } 239 | ], 240 | "source": [ 241 | "lift.get_object_by_attack_id(\"campaign\", \"C0001\")" 242 | ] 243 | }, 244 | { 245 | "cell_type": "code", 246 | "execution_count": null, 247 | "metadata": {}, 248 | "outputs": [], 249 | "source": [] 250 | } 251 | ], 252 | "metadata": { 253 | "kernelspec": { 254 | "display_name": ".venv", 255 | "language": "python", 256 | "name": "python3" 257 | }, 258 | "language_info": { 259 | "codemirror_mode": { 260 | "name": "ipython", 261 | "version": 3 262 | }, 263 | "file_extension": ".py", 264 | "mimetype": "text/x-python", 265 | "name": "python", 266 | "nbconvert_exporter": "python", 267 | "pygments_lexer": "ipython3", 268 | "version": "3.13.3" 269 | } 270 | }, 271 | "nbformat": 4, 272 | "nbformat_minor": 4 273 | } 274 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.4.2](https://github.com/OTRF/ATTACK-Python-Client/tree/0.4.2) (2024-04-09) 4 | 5 | ## What's Changed 6 | * updated CHANGELOG and setup version by @Cyb3rWard0g in https://github.com/OTRF/ATTACK-Python-Client/pull/78 7 | * Update requirements.txt to include pydantic by @thelok in https://github.com/OTRF/ATTACK-Python-Client/pull/79 8 | * Fix a bug where all groups/campaigns are returned from case insensitive search of `get_group_by_alias`/`get_campaign_by_alias` by @thelok in https://github.com/OTRF/ATTACK-Python-Client/pull/80 9 | * V0.4.2 Updated Package Requirements, Annotations, Docstrings and Models by @Cyb3rWard0g in https://github.com/OTRF/ATTACK-Python-Client/pull/81 10 | 11 | 12 | **Full Changelog**: https://github.com/OTRF/ATTACK-Python-Client/compare/0.4.1...0.4.2 13 | 14 | ## [0.4.1](https://github.com/OTRF/ATTACK-Python-Client/tree/0.4.1) (2024-04-01) 15 | 16 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.4.0...0.4.1) 17 | 18 | **Implemented enhancements:** 19 | 20 | - SSL certificate problem [\#56](https://github.com/OTRF/ATTACK-Python-Client/issues/56) 21 | - Integrating examples from MITRE CTI - USAGE docs in GitHub [\#35](https://github.com/OTRF/ATTACK-Python-Client/issues/35) 22 | - Create a function template for functions in attack\_client class [\#34](https://github.com/OTRF/ATTACK-Python-Client/issues/34) 23 | - Dynamic Interaction with stix2.v20.sdo object types [\#33](https://github.com/OTRF/ATTACK-Python-Client/issues/33) 24 | 25 | **Fixed bugs:** 26 | 27 | - AttributeError: 'function' object has no attribute 'query' when using get\_techniques\_used\_by\_group\_software [\#67](https://github.com/OTRF/ATTACK-Python-Client/issues/67) 28 | 29 | **Closed issues:** 30 | 31 | - 503 Error when client = attack\_client\(\) [\#72](https://github.com/OTRF/ATTACK-Python-Client/issues/72) 32 | - Connection Timeout Issue When Using 'attackcti' Library [\#71](https://github.com/OTRF/ATTACK-Python-Client/issues/71) 33 | - Expired certificate causes library crash [\#70](https://github.com/OTRF/ATTACK-Python-Client/issues/70) 34 | - How to Access the Cloud ATT&CK Matrix [\#68](https://github.com/OTRF/ATTACK-Python-Client/issues/68) 35 | - some external references are not available in technique data [\#32](https://github.com/OTRF/ATTACK-Python-Client/issues/32) 36 | - \[TO-DO\] Add case insensitive features to some of the search functions [\#25](https://github.com/OTRF/ATTACK-Python-Client/issues/25) 37 | 38 | **Merged pull requests:** 39 | 40 | - Updated Type Annotations and Docstrings [\#77](https://github.com/OTRF/ATTACK-Python-Client/pull/77) ([Cyb3rWard0g](https://github.com/Cyb3rWard0g)) 41 | - Removed double query method from COMPOSITE\_DS.query, fix \#67 [\#76](https://github.com/OTRF/ATTACK-Python-Client/pull/76) ([Cyb3rWard0g](https://github.com/Cyb3rWard0g)) 42 | - Improve STIX Object Handling and Documentation with Pydantic and Type Annotations [\#75](https://github.com/OTRF/ATTACK-Python-Client/pull/75) ([Cyb3rWard0g](https://github.com/Cyb3rWard0g)) 43 | - Adding `proxies` and `verify` parameters for TAXII Client [\#73](https://github.com/OTRF/ATTACK-Python-Client/pull/73) ([thelok](https://github.com/thelok)) 44 | - Update Dockerfile [\#69](https://github.com/OTRF/ATTACK-Python-Client/pull/69) ([halcyondream](https://github.com/halcyondream)) 45 | - use COMPOSITE\_DS instead of TC\_ENTERPRISE\_SOURCE in generic functions [\#66](https://github.com/OTRF/ATTACK-Python-Client/pull/66) ([rubinatorz](https://github.com/rubinatorz)) 46 | 47 | ## [0.4.0](https://github.com/OTRF/ATTACK-Python-Client/tree/0.4.0) (2023-05-23) 48 | 49 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.3.9...0.4.0) 50 | 51 | **Merged pull requests:** 52 | 53 | - Added support for Mobile data sources/components [\#65](https://github.com/OTRF/ATTACK-Python-Client/pull/65) ([rubinatorz](https://github.com/rubinatorz)) 54 | 55 | ## [0.3.9](https://github.com/OTRF/ATTACK-Python-Client/tree/0.3.9) (2023-04-13) 56 | 57 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.3.8...0.3.9) 58 | 59 | **Merged pull requests:** 60 | 61 | - Added ICS campaigns and some ICS fixes [\#64](https://github.com/OTRF/ATTACK-Python-Client/pull/64) ([rubinatorz](https://github.com/rubinatorz)) 62 | 63 | ## [0.3.8](https://github.com/OTRF/ATTACK-Python-Client/tree/0.3.8) (2022-11-19) 64 | 65 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.3.7...0.3.8) 66 | 67 | **Implemented enhancements:** 68 | 69 | - Should PRE-attack be removed? [\#59](https://github.com/OTRF/ATTACK-Python-Client/issues/59) 70 | 71 | **Merged pull requests:** 72 | 73 | - Add support for campaings entity added in MITRE v12 [\#62](https://github.com/OTRF/ATTACK-Python-Client/pull/62) ([dadokkio](https://github.com/dadokkio)) 74 | - added include\_pre\_attack parameter to attack\_client constructor [\#61](https://github.com/OTRF/ATTACK-Python-Client/pull/61) ([rubinatorz](https://github.com/rubinatorz)) 75 | 76 | ## [0.3.7](https://github.com/OTRF/ATTACK-Python-Client/tree/0.3.7) (2022-07-05) 77 | 78 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.3.6...0.3.7) 79 | 80 | **Closed issues:** 81 | 82 | - attack\_client not workning \(Err\_connection\) [\#58](https://github.com/OTRF/ATTACK-Python-Client/issues/58) 83 | - Bug: enrich\_data\_sources is not working [\#57](https://github.com/OTRF/ATTACK-Python-Client/issues/57) 84 | 85 | ## [0.3.6](https://github.com/OTRF/ATTACK-Python-Client/tree/0.3.6) (2022-01-20) 86 | 87 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.3.4.4...0.3.6) 88 | 89 | **Implemented enhancements:** 90 | 91 | - Removed Try Except features and set module to directly use CompositeDataSource queries [\#52](https://github.com/OTRF/ATTACK-Python-Client/issues/52) 92 | - Updated SANS CTI Summit 2022 Notebook [\#51](https://github.com/OTRF/ATTACK-Python-Client/issues/51) 93 | - Remove 'Pre' from get\_stix\_objects\(\) function [\#49](https://github.com/OTRF/ATTACK-Python-Client/issues/49) 94 | - Update Navigator version in export\_groups\_navigator\_layers\(\) function to 4.5.5 [\#48](https://github.com/OTRF/ATTACK-Python-Client/issues/48) 95 | - Update Jupyterbook config and toc file [\#47](https://github.com/OTRF/ATTACK-Python-Client/issues/47) 96 | - Update Docs: Jupyter Notebooks explaining most of the functions available in the library [\#44](https://github.com/OTRF/ATTACK-Python-Client/issues/44) 97 | - specify and update README.md file and requirements section [\#28](https://github.com/OTRF/ATTACK-Python-Client/issues/28) 98 | - New parameters and Functions [\#41](https://github.com/OTRF/ATTACK-Python-Client/pull/41) ([Cyb3rPandaH](https://github.com/Cyb3rPandaH)) 99 | 100 | **Fixed bugs:** 101 | 102 | - Remove function 'remove\_revoked\(\)' from available functions [\#46](https://github.com/OTRF/ATTACK-Python-Client/issues/46) 103 | - Data sources enrichment function removes data sources metadata from techniques that do not have 'detects` relationships [\#45](https://github.com/OTRF/ATTACK-Python-Client/issues/45) 104 | - Rename enrich\_data\_source function to enrich\_techniques\_data\_sources in get\_enterprise\_techniques [\#42](https://github.com/OTRF/ATTACK-Python-Client/issues/42) 105 | - get\_software\_used\_by\_group returns all tools for groups with no actual tools/ software [\#27](https://github.com/OTRF/ATTACK-Python-Client/issues/27) 106 | 107 | **Merged pull requests:** 108 | 109 | - SANS CTI Summit 2022 Notebook \(Spanish\) [\#50](https://github.com/OTRF/ATTACK-Python-Client/pull/50) ([Cyb3rPandaH](https://github.com/Cyb3rPandaH)) 110 | - Update attack\_api.py [\#40](https://github.com/OTRF/ATTACK-Python-Client/pull/40) ([Cyb3rPandaH](https://github.com/Cyb3rPandaH)) 111 | - updated enterprise pre mobile and ics main functions and revoked and deprecated functions [\#39](https://github.com/OTRF/ATTACK-Python-Client/pull/39) ([Cyb3rWard0g](https://github.com/Cyb3rWard0g)) 112 | - added data sources function and field mappings [\#38](https://github.com/OTRF/ATTACK-Python-Client/pull/38) ([Cyb3rWard0g](https://github.com/Cyb3rWard0g)) 113 | - Add x-mitre-data-component [\#37](https://github.com/OTRF/ATTACK-Python-Client/pull/37) ([ZikyHD](https://github.com/ZikyHD)) 114 | - Update CONTRIBUTING.md [\#31](https://github.com/OTRF/ATTACK-Python-Client/pull/31) ([thegautamkumarjaiswal](https://github.com/thegautamkumarjaiswal)) 115 | - Feature Add and Update [\#26](https://github.com/OTRF/ATTACK-Python-Client/pull/26) ([thegautamkumarjaiswal](https://github.com/thegautamkumarjaiswal)) 116 | - Update for add proxy [\#10](https://github.com/OTRF/ATTACK-Python-Client/pull/10) ([charly837](https://github.com/charly837)) 117 | 118 | ## [0.3.4.4](https://github.com/OTRF/ATTACK-Python-Client/tree/0.3.4.4) (2021-07-03) 119 | 120 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.3.4.3...0.3.4.4) 121 | 122 | **Closed issues:** 123 | 124 | - Fail to convert "all\_techniques" to json file [\#23](https://github.com/OTRF/ATTACK-Python-Client/issues/23) 125 | - Failed to pass a STIX object \(to another function\) that was retrieved by get\_object\_by\_attack\_id\(\) and get\_group\_by\_alias\(\) [\#20](https://github.com/OTRF/ATTACK-Python-Client/issues/20) 126 | - group\_references missing [\#3](https://github.com/OTRF/ATTACK-Python-Client/issues/3) 127 | 128 | **Merged pull requests:** 129 | 130 | - added better support to handle stix filter results [\#30](https://github.com/OTRF/ATTACK-Python-Client/pull/30) ([Cyb3rWard0g](https://github.com/Cyb3rWard0g)) 131 | 132 | ## [0.3.4.3](https://github.com/OTRF/ATTACK-Python-Client/tree/0.3.4.3) (2020-11-24) 133 | 134 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.3.4...0.3.4.3) 135 | 136 | **Closed issues:** 137 | 138 | - Remove pre-ATT&CK or mark it as deprecated in the documentation [\#22](https://github.com/OTRF/ATTACK-Python-Client/issues/22) 139 | 140 | ## [0.3.4](https://github.com/OTRF/ATTACK-Python-Client/tree/0.3.4) (2020-11-24) 141 | 142 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.3.3...0.3.4) 143 | 144 | **Implemented enhancements:** 145 | 146 | - Update SIX to six-1.15.0: No module named 'six.moves.collections\_abc' [\#19](https://github.com/OTRF/ATTACK-Python-Client/issues/19) 147 | - Ability to retreive CAPEC IDs [\#1](https://github.com/OTRF/ATTACK-Python-Client/issues/1) 148 | 149 | **Closed issues:** 150 | 151 | - Add API for ICS domain [\#21](https://github.com/OTRF/ATTACK-Python-Client/issues/21) 152 | - KeyError: 'v21' [\#18](https://github.com/OTRF/ATTACK-Python-Client/issues/18) 153 | 154 | ## [0.3.3](https://github.com/OTRF/ATTACK-Python-Client/tree/0.3.3) (2020-08-21) 155 | 156 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.3.2...0.3.3) 157 | 158 | **Fixed bugs:** 159 | 160 | - get\_techniques\_used\_by\_all\_groups is broken by the new subtechniques change [\#14](https://github.com/OTRF/ATTACK-Python-Client/issues/14) 161 | 162 | **Closed issues:** 163 | 164 | - Tactic for T1506 is not present when calling `get_enterprise(stix_format=False)` [\#17](https://github.com/OTRF/ATTACK-Python-Client/issues/17) 165 | 166 | **Merged pull requests:** 167 | 168 | - Add requirements.txt [\#16](https://github.com/OTRF/ATTACK-Python-Client/pull/16) ([Neo23x0](https://github.com/Neo23x0)) 169 | - New function to remove deprecated STIX objects [\#15](https://github.com/OTRF/ATTACK-Python-Client/pull/15) ([marcusbakker](https://github.com/marcusbakker)) 170 | 171 | ## [0.3.2](https://github.com/OTRF/ATTACK-Python-Client/tree/0.3.2) (2020-04-03) 172 | 173 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.2.6...0.3.2) 174 | 175 | **Closed issues:** 176 | 177 | - MITRE TAXII server doesn't support 2.1, but 2.0. [\#12](https://github.com/OTRF/ATTACK-Python-Client/issues/12) 178 | - The system cannot find the path specified: 'C:\\Program Files \(x86\)\\Microsoft Visual Studio 14.0\\VC\\PlatformSDK\\lib' [\#9](https://github.com/OTRF/ATTACK-Python-Client/issues/9) 179 | 180 | **Merged pull requests:** 181 | 182 | - Support for local STIX objects [\#11](https://github.com/OTRF/ATTACK-Python-Client/pull/11) ([rubinatorz](https://github.com/rubinatorz)) 183 | 184 | ## [0.2.6](https://github.com/OTRF/ATTACK-Python-Client/tree/0.2.6) (2019-05-06) 185 | 186 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.2.3...0.2.6) 187 | 188 | ## [0.2.3](https://github.com/OTRF/ATTACK-Python-Client/tree/0.2.3) (2019-05-02) 189 | 190 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.2.1...0.2.3) 191 | 192 | ## [0.2.1](https://github.com/OTRF/ATTACK-Python-Client/tree/0.2.1) (2018-11-21) 193 | 194 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/0.1.7...0.2.1) 195 | 196 | **Closed issues:** 197 | 198 | - Jupyter notebooks Python 2 compatibility [\#7](https://github.com/OTRF/ATTACK-Python-Client/issues/7) 199 | 200 | **Merged pull requests:** 201 | 202 | - Fix duplicate in requirements.txt [\#6](https://github.com/OTRF/ATTACK-Python-Client/pull/6) ([2xyo](https://github.com/2xyo)) 203 | 204 | ## [0.1.7](https://github.com/OTRF/ATTACK-Python-Client/tree/0.1.7) (2018-11-06) 205 | 206 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/1.3.6...0.1.7) 207 | 208 | **Fixed bugs:** 209 | 210 | - KeyError: 'created\_by\_ref' [\#4](https://github.com/OTRF/ATTACK-Python-Client/issues/4) 211 | 212 | **Closed issues:** 213 | 214 | - get\_all\_enterprise\(\) fails [\#5](https://github.com/OTRF/ATTACK-Python-Client/issues/5) 215 | 216 | ## [1.3.6](https://github.com/OTRF/ATTACK-Python-Client/tree/1.3.6) (2018-10-27) 217 | 218 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/1.3.4...1.3.6) 219 | 220 | ## [1.3.4](https://github.com/OTRF/ATTACK-Python-Client/tree/1.3.4) (2018-06-15) 221 | 222 | [Full Changelog](https://github.com/OTRF/ATTACK-Python-Client/compare/1479ef0fade015ad1ae522d4a1e91c5fe683a036...1.3.4) 223 | 224 | **Fixed bugs:** 225 | 226 | - using dict\(\) on a stix2 object will not correctly serialize datetime properties [\#2](https://github.com/OTRF/ATTACK-Python-Client/issues/2) 227 | 228 | 229 | 230 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 231 | -------------------------------------------------------------------------------- /src/attackcti/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | MITRE ATT&CK Python Client. 3 | 4 | This module provides a high-level client for accessing and interacting with MITRE ATT&CK data. 5 | It includes support for querying data from local STIX bundles or the MITRE ATT&CK TAXII 2.1 server. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | from pathlib import Path 11 | from typing import Any, Dict 12 | 13 | from pydantic import ValidationError 14 | 15 | from .core.query_client import QueryClient 16 | from .domains.enterprise import EnterpriseClient 17 | from .domains.ics import ICSClient 18 | from .domains.mobile import MobileClient 19 | from .legacy import attach_legacy_methods 20 | from .models import STIXLocalPaths 21 | from .sources import MitreAttackSource 22 | from .utils.downloader import STIXDownloader 23 | 24 | # os.environ['http_proxy'] = "http://xxxxxxx" 25 | # os.environ['https_proxy'] = "https://xxxxxxx" 26 | 27 | 28 | class MitreAttackClient: 29 | """High-level client for accessing MITRE ATT&CK data.""" 30 | 31 | def __init__( 32 | self, 33 | local_paths=None, 34 | proxies=None, 35 | verify=True, 36 | connect_taxii: bool = True, 37 | *, 38 | collection_url: str | None = None, 39 | attack_source: MitreAttackSource | None = None, 40 | ): 41 | """Initialize the ATT&CK client. 42 | 43 | Parameters 44 | ---------- 45 | local_paths : dict[str, str] | None, optional 46 | Mapping of domain name to a local directory or JSON bundle path. 47 | Keys are typically ``enterprise``, ``mobile``, and ``ics``. 48 | proxies : dict | None, optional 49 | Requests proxy configuration for TAXII (when used). 50 | verify : bool, optional 51 | Whether to verify TLS certificates for TAXII requests. 52 | connect_taxii : bool, optional 53 | When `True`, allow TAXII initialization/fallback when local sources 54 | are missing. When `False`, do not perform any network calls. 55 | collection_url : str | None, optional 56 | Base TAXII collections URL (ending in ``/collections/``). If omitted, 57 | uses the MITRE default. 58 | attack_source : MitreAttackSource | None, optional 59 | Pre-loaded sources container. When provided, `local_paths` and 60 | `connect_taxii` are ignored and this container is used. 61 | 62 | Raises 63 | ------ 64 | ValueError 65 | If `local_paths` is provided but fails validation. 66 | """ 67 | self._connect_taxii = connect_taxii 68 | self._taxii_collection_url = collection_url 69 | self.mode: str = "empty" 70 | self.spec_version: str | None = None 71 | self._source_spec_versions: dict[str, str | None] = {} 72 | 73 | if attack_source is not None: 74 | self._init_from_source(attack_source) 75 | return 76 | 77 | # Validate local_paths with Pydantic 78 | if local_paths: 79 | try: 80 | self.local_paths = STIXLocalPaths(**local_paths) 81 | except ValidationError as e: 82 | raise ValueError(f"Invalid local_paths: {e}") from e 83 | 84 | # Initialize data sources 85 | self.init_data_sources(self.local_paths if local_paths else None, proxies, verify) 86 | 87 | def _init_from_source(self, sources: MitreAttackSource) -> None: 88 | """Populate client attributes from a pre-loaded source container. 89 | 90 | Parameters 91 | ---------- 92 | sources : MitreAttackSource 93 | Container of loaded sources to attach to this client. 94 | """ 95 | self.sources = sources 96 | self.TC_ENTERPRISE_SOURCE = sources.enterprise 97 | self.TC_MOBILE_SOURCE = sources.mobile 98 | self.TC_ICS_SOURCE = sources.ics 99 | self.COMPOSITE_DS = sources.composite 100 | self._source_spec_versions = sources.versions 101 | self.mode = sources.mode 102 | self.spec_version = sources.spec_version 103 | 104 | def init_data_sources(self, local_paths: STIXLocalPaths | None, proxies: dict | None, verify: bool) -> None: 105 | """Initialize the underlying domain sources. 106 | 107 | Parameters 108 | ---------- 109 | local_paths : STIXLocalPaths | None 110 | Validated local paths to bundles/directories for each domain. 111 | proxies : dict | None 112 | Requests proxy configuration for TAXII (when used). 113 | verify : bool 114 | Whether to verify TLS certificates for TAXII requests. 115 | """ 116 | enterprise = local_paths.enterprise if local_paths else None 117 | mobile = local_paths.mobile if local_paths else None 118 | ics = local_paths.ics if local_paths else None 119 | 120 | sources = MitreAttackSource.load( 121 | enterprise=enterprise, 122 | mobile=mobile, 123 | ics=ics, 124 | connect_taxii=self._connect_taxii, 125 | proxies=proxies, 126 | verify=verify, 127 | collection_url=self._taxii_collection_url, 128 | ) 129 | self._init_from_source(sources) 130 | 131 | @classmethod 132 | def from_local( 133 | cls, 134 | *, 135 | enterprise: str | None = None, 136 | mobile: str | None = None, 137 | ics: str | None = None, 138 | ) -> "MitreAttackClient": 139 | """Create a client backed by local STIX bundles (STIX 2.0 or 2.1). 140 | 141 | Parameters 142 | ---------- 143 | enterprise : str | None, optional 144 | Path to an enterprise STIX JSON file or directory of JSON files. 145 | mobile : str | None, optional 146 | Path to a mobile STIX JSON file or directory of JSON files. 147 | ics : str | None, optional 148 | Path to an ICS STIX JSON file or directory of JSON files. 149 | 150 | Returns 151 | ------- 152 | MitreAttackClient 153 | Client initialized in local mode using the provided bundles. 154 | """ 155 | source = MitreAttackSource.load( 156 | enterprise=enterprise, 157 | mobile=mobile, 158 | ics=ics, 159 | connect_taxii=False, 160 | proxies=None, 161 | verify=True, 162 | collection_url=None, 163 | ) 164 | return cls(attack_source=source) 165 | 166 | @classmethod 167 | def from_attack_stix_data( 168 | cls, 169 | *, 170 | download_dir: str = ".attackcti/stix-2.1", 171 | release: str | None = None, 172 | domains: tuple[str, ...] = ("enterprise", "mobile", "ics"), 173 | pretty_print: bool = False, 174 | force_download: bool = False, 175 | ) -> "MitreAttackClient": 176 | """Download ATT&CK STIX 2.1 bundles and initialize the client from them. 177 | 178 | This is a convenience helper for the common workflow: 179 | download STIX bundles from `mitre-attack/attack-stix-data`, then load them 180 | in local mode. 181 | 182 | Parameters 183 | ---------- 184 | download_dir : str, optional 185 | Root directory to store downloaded STIX bundles. Defaults to 186 | ``.attackcti/stix-2.1`` (relative to the current working directory). 187 | release : str | None, optional 188 | ATT&CK release to download (e.g., ``"18.1"``). When `None`, downloads 189 | the latest bundle files. 190 | domains : tuple[str, ...], optional 191 | Domains to download/load. Each value must be one of 192 | ``("enterprise", "mobile", "ics")``. 193 | pretty_print : bool, optional 194 | When `True`, rewrite downloaded JSON with indentation. 195 | force_download : bool, optional 196 | When `True`, always download even if a file exists in the target 197 | location. When `False`, reuse existing files if present. 198 | 199 | Returns 200 | ------- 201 | MitreAttackClient 202 | Client initialized in local mode using the downloaded bundles. 203 | 204 | Raises 205 | ------ 206 | ValueError 207 | If `domains` contains an unsupported value. 208 | """ 209 | allowed = {"enterprise", "mobile", "ics"} 210 | unknown = [d for d in domains if d not in allowed] 211 | if unknown: 212 | raise ValueError(f"Unsupported domains: {unknown}. Valid domains are {sorted(allowed)}") 213 | 214 | expanded_dir = str(Path(download_dir).expanduser().resolve()) 215 | downloader = STIXDownloader(download_dir=expanded_dir, stix_version="2.1") 216 | for domain in domains: 217 | downloader.download_attack_data( 218 | domain=domain, 219 | release=release, 220 | pretty_print=pretty_print, 221 | force=force_download, 222 | ) 223 | 224 | return cls.from_local( 225 | enterprise=downloader.downloaded_file_paths.get("enterprise"), 226 | mobile=downloader.downloaded_file_paths.get("mobile"), 227 | ics=downloader.downloaded_file_paths.get("ics"), 228 | ) 229 | 230 | @classmethod 231 | def from_taxii( 232 | cls, 233 | *, 234 | proxies: dict | None = None, 235 | verify: bool = True, 236 | collection_url: str | None = None, 237 | ) -> "MitreAttackClient": 238 | """Create a client backed by the MITRE ATT&CK TAXII 2.1 server. 239 | 240 | Parameters 241 | ---------- 242 | proxies : dict | None, optional 243 | Requests proxy configuration. 244 | verify : bool, optional 245 | Whether to verify TLS certificates. 246 | collection_url : str | None, optional 247 | Base TAXII collections URL (ending in ``/collections/``). If omitted, 248 | uses the MITRE default. 249 | 250 | Returns 251 | ------- 252 | MitreAttackClient 253 | Client initialized in TAXII mode. 254 | """ 255 | source = MitreAttackSource.load( 256 | enterprise=None, 257 | mobile=None, 258 | ics=None, 259 | connect_taxii=True, 260 | proxies=proxies, 261 | verify=verify, 262 | collection_url=collection_url, 263 | ) 264 | return cls(attack_source=source) 265 | 266 | def _get_cached_subclient(self, attr_name: str, cls: type) -> Any: 267 | """Return a lazily-created subclient stored on this client. 268 | 269 | Parameters 270 | ---------- 271 | attr_name : str 272 | Attribute name to cache the client under. 273 | cls : type 274 | Subclient class to instantiate when missing. 275 | 276 | Returns 277 | ------- 278 | Any 279 | Cached subclient instance. 280 | """ 281 | cached = getattr(self, attr_name, None) 282 | if cached is None: 283 | cached = cls(self) 284 | setattr(self, attr_name, cached) 285 | return cached 286 | 287 | @property 288 | def enterprise(self) -> EnterpriseClient: 289 | """Return the EnterpriseClient instance (cached). 290 | 291 | Returns 292 | ------- 293 | EnterpriseClient 294 | The client for interacting with enterprise-related data. 295 | """ 296 | cached = getattr(self, "_enterprise_client", None) 297 | if cached is None: 298 | cached = EnterpriseClient( 299 | data_source=self.TC_ENTERPRISE_SOURCE, 300 | ) 301 | self._enterprise_client = cached 302 | return cached 303 | 304 | @property 305 | def mobile(self) -> MobileClient: 306 | """Return the MobileClient instance (cached). 307 | 308 | Returns 309 | ------- 310 | MobileClient 311 | The client for interacting with mobile-related data. 312 | """ 313 | cached = getattr(self, "_mobile_client", None) 314 | if cached is None: 315 | cached = MobileClient( 316 | data_source=self.TC_MOBILE_SOURCE, 317 | ) 318 | self._mobile_client = cached 319 | return cached 320 | 321 | @property 322 | def ics(self) -> ICSClient: 323 | """Return the ICSClient instance (cached). 324 | 325 | Returns 326 | ------- 327 | ICSClient 328 | The client for interacting with ICS-related data. 329 | """ 330 | cached = getattr(self, "_ics_client", None) 331 | if cached is None: 332 | cached = ICSClient( 333 | data_source=self.TC_ICS_SOURCE, 334 | ) 335 | self._ics_client = cached 336 | return cached 337 | 338 | @property 339 | def query(self) -> QueryClient: 340 | """Return the QueryClient instance (cached). 341 | 342 | The QueryClient provides methods for querying and interacting with the 343 | composite data source, including enterprise, mobile, and ICS domains. 344 | 345 | Returns 346 | ------- 347 | QueryClient 348 | The client for cross-domain queries. 349 | """ 350 | cached = getattr(self, "_query_client", None) 351 | if cached is None: 352 | cached = QueryClient( 353 | self.COMPOSITE_DS, 354 | ) 355 | self._query_client = cached 356 | return cached 357 | 358 | def get_attack(self, stix_format: bool = True) -> Dict[str, Dict]: 359 | """Return objects from enterprise, mobile, and ICS matrices. 360 | 361 | Parameters 362 | ---------- 363 | stix_format : bool, optional 364 | When `True`, return STIX objects; when `False`, return parsed dicts 365 | based on the corresponding Pydantic models. 366 | 367 | Returns 368 | ------- 369 | dict[str, dict] 370 | Mapping with keys ``enterprise``, ``mobile``, and ``ics`` containing 371 | the corresponding STIX objects (or parsed dicts). 372 | """ 373 | attack_stix_objects = dict() 374 | attack_stix_objects['enterprise'] = self.enterprise.get_enterprise(stix_format) 375 | attack_stix_objects['mobile'] = self.mobile.get_mobile(stix_format) 376 | attack_stix_objects['ics'] = self.ics.get_ics(stix_format) 377 | 378 | return attack_stix_objects 379 | 380 | 381 | attach_legacy_methods(MitreAttackClient) 382 | -------------------------------------------------------------------------------- /src/attackcti/core/objects/techniques.py: -------------------------------------------------------------------------------- 1 | """Cross-domain technique query helpers.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Any, Callable, Dict, List, Union 6 | 7 | from stix2 import CompositeDataSource, Filter 8 | from stix2.v21.sdo import AttackPattern as AttackPatternV21 9 | 10 | from ...models import Technique as TechniqueModel 11 | from ...utils.stix import parse_stix_objects, remove_revoked_deprecated 12 | 13 | 14 | class TechniquesClient: 15 | """Query techniques from a composite STIX data source.""" 16 | 17 | def __init__( 18 | self, 19 | *, 20 | data_source: CompositeDataSource, 21 | remove_fn: Callable = remove_revoked_deprecated, 22 | parse_fn: Callable = parse_stix_objects, 23 | enrich_with_detections_fn: Callable | None = None, 24 | enrich_data_components_fn: Callable | None = None, 25 | ) -> None: 26 | """Initialize the client with a data source and helper callbacks. 27 | 28 | Parameters 29 | ---------- 30 | data_source : CompositeDataSource 31 | Composite STIX2 data source. 32 | remove_fn : Callable 33 | Callable used to remove revoked/deprecated objects. 34 | parse_fn : Callable 35 | Callable used to parse STIX objects into Pydantic-backed dicts. 36 | enrich_with_detections_fn : Callable, optional 37 | Callable used to enrich techniques with detection-strategy/analytic context. 38 | enrich_data_components_fn : Callable, optional 39 | Callable used to enrich techniques with data component context. 40 | """ 41 | self._data_source = data_source 42 | self._remove_fn = remove_fn 43 | self._parse_fn = parse_fn 44 | self._enrich_with_detections_fn = enrich_with_detections_fn 45 | self._enrich_data_components_fn = enrich_data_components_fn 46 | 47 | def set_enrich_with_detections_fn(self, enrich_fn: Callable | None) -> None: 48 | """Set the default detection enrichment callback for techniques. 49 | 50 | Parameters 51 | ---------- 52 | enrich_fn : Callable | None 53 | Callable that accepts a list of techniques and returns an enriched list. 54 | Set to `None` to disable enrichment. 55 | """ 56 | self._enrich_with_detections_fn = enrich_fn 57 | 58 | def set_enrich_data_components_fn(self, enrich_fn: Callable | None) -> None: 59 | """Set the default data component enrichment callback for techniques. 60 | 61 | Parameters 62 | ---------- 63 | enrich_fn : Callable | None 64 | Callable that accepts a list of techniques and returns an enriched list. 65 | Set to `None` to disable enrichment. 66 | """ 67 | self._enrich_data_components_fn = enrich_fn 68 | 69 | def get_techniques( 70 | self, 71 | *, 72 | skip_revoked_deprecated: bool = True, 73 | include_subtechniques: bool = True, 74 | enrich_detections: bool = False, 75 | enrich_data_components: bool = False, 76 | stix_format: bool = True, 77 | ) -> List[Union[AttackPatternV21, Dict[str, Any]]]: 78 | """Return techniques across domains. 79 | 80 | Parameters 81 | ---------- 82 | skip_revoked_deprecated : bool, optional 83 | When `True`, omit revoked/deprecated techniques. 84 | include_subtechniques : bool, optional 85 | When `True`, include sub-techniques. 86 | enrich_detections : bool, optional 87 | When `True`, enrich techniques with detection-strategy and analytic context 88 | (applied only if an enrichment callback is configured). 89 | enrich_data_components : bool, optional 90 | When `True`, enrich techniques with data component context (applied only if 91 | an enrichment callback is configured). 92 | stix_format : bool, optional 93 | When `True`, return STIX objects/dicts; when `False`, return dictionaries 94 | parsed to the `Technique` Pydantic model. 95 | 96 | Returns 97 | ------- 98 | list[AttackPatternV21 | dict[str, Any]] 99 | Technique objects in the requested format. 100 | """ 101 | if include_subtechniques: 102 | all_techniques = self._data_source.query([Filter("type", "=", "attack-pattern")]) 103 | else: 104 | all_techniques = self._data_source.query([Filter("type", "=", "attack-pattern"), Filter("x_mitre_is_subtechnique", "=", False)]) 105 | 106 | if skip_revoked_deprecated: 107 | all_techniques = self._remove_fn(all_techniques) 108 | if enrich_data_components: 109 | enrich_detections = True 110 | if enrich_detections and self._enrich_with_detections_fn is not None: 111 | all_techniques = self._enrich_with_detections_fn(all_techniques) 112 | if enrich_data_components and self._enrich_data_components_fn is not None: 113 | all_techniques = self._enrich_data_components_fn(all_techniques) 114 | if not stix_format: 115 | all_techniques = self._parse_fn(all_techniques, TechniqueModel) 116 | return all_techniques 117 | 118 | def get_technique_by_name( 119 | self, 120 | name: str, 121 | *, 122 | case: bool = True, 123 | skip_revoked_deprecated: bool = True, 124 | stix_format: bool = True 125 | ) -> List[Union[AttackPatternV21, Dict[str, Any]]]: 126 | """Return techniques matching a given name. 127 | 128 | Parameters 129 | ---------- 130 | name : str 131 | Technique name to match. 132 | case : bool, optional 133 | When `True`, perform an exact case-sensitive match; when `False`, perform a 134 | case-insensitive containment match. 135 | skip_revoked_deprecated : bool, optional 136 | When `True`, omit revoked/deprecated techniques. 137 | stix_format : bool, optional 138 | When `True`, return STIX objects/dicts; when `False`, return dictionaries 139 | parsed to the `Technique` Pydantic model. 140 | 141 | Returns 142 | ------- 143 | list[AttackPatternV21 | dict[str, Any]] 144 | Matching technique objects in the requested format. 145 | """ 146 | if not case: 147 | filter_objects = [Filter("type", "=", "attack-pattern"), Filter("name", "contains", name)] 148 | matched = self._data_source.query(filter_objects) 149 | else: 150 | filter_objects = [Filter("type", "=", "attack-pattern"), Filter("name", "=", name)] 151 | matched = self._data_source.query(filter_objects) 152 | 153 | if skip_revoked_deprecated: 154 | matched = self._remove_fn(matched) 155 | 156 | if not stix_format: 157 | matched = self._parse_fn(matched, TechniqueModel) 158 | return matched 159 | 160 | def get_techniques_by_data_components( 161 | self, 162 | *data_components: str, 163 | stix_format: bool = True, 164 | ) -> List[Union[AttackPatternV21, Dict[str, Any]]]: 165 | """Return techniques that reference specific data components via log sources. 166 | 167 | This uses the modern detection enrichment path: 168 | `Technique -> Detection strategy -> Analytic -> Log source references -> Data component ref`. 169 | It performs a case-insensitive containment match on data component names attached under 170 | `x_attackcti_data_component` (enabled by `enrich_data_components=True`). 171 | 172 | Parameters 173 | ---------- 174 | data_components : str 175 | One or more substrings to match against data component names. 176 | stix_format : bool, optional 177 | When `True`, return STIX objects/dicts; when `False`, return dictionaries parsed 178 | to the `Technique` Pydantic model. 179 | 180 | Returns 181 | ------- 182 | list[AttackPatternV21 | dict[str, Any]] 183 | Techniques whose detection graph contains matching data component names. 184 | """ 185 | if not data_components: 186 | return [] 187 | 188 | # Ensure the detection graph and data components are attached. 189 | techniques = self.get_techniques(enrich_detections=True, enrich_data_components=True, stix_format=True) 190 | terms = [dc.lower() for dc in data_components if isinstance(dc, str) and dc] 191 | if not terms: 192 | return [] # pragma: no cover 193 | 194 | results: list[Any] = [] 195 | for technique in techniques: 196 | t_dict = technique if isinstance(technique, dict) else technique._inner # type: ignore[attr-defined] 197 | strategies = t_dict.get("x_attackcti_detection_strategies") or [] 198 | matched = False 199 | for strategy in strategies: 200 | for analytic in strategy.get("x_attackcti_analytics") or []: 201 | for log_source in analytic.get("x_attackcti_log_sources") or []: 202 | comp = log_source.get("x_attackcti_data_component") 203 | name = comp.get("name") if isinstance(comp, dict) else None 204 | if isinstance(name, str) and any(term in name.lower() for term in terms): 205 | matched = True 206 | break 207 | if matched: 208 | break 209 | if matched: 210 | break 211 | if matched: 212 | results.append(technique) 213 | 214 | if not stix_format: 215 | results = self._parse_fn(results, TechniqueModel) 216 | return results 217 | 218 | 219 | def get_techniques_by_content(self, *, content: str, stix_format: bool = True) -> List[Union[AttackPatternV21, Dict[str, Any]]]: 220 | """Return techniques whose descriptions contain the provided content. 221 | 222 | Parameters 223 | ---------- 224 | content : str 225 | Substring to search for in technique descriptions. 226 | stix_format : bool, optional 227 | When `True`, return STIX objects/dicts; when `False`, return dictionaries 228 | parsed to the `Technique` Pydantic model. 229 | 230 | Returns 231 | ------- 232 | list[AttackPatternV21 | dict[str, Any]] 233 | Matching technique objects in the requested format. 234 | """ 235 | all_techniques = self.get_techniques(stix_format=True) 236 | matched: list[Any] = [] 237 | for tech in all_techniques: 238 | description = tech.get("description", "").lower() 239 | if content.lower() in description: 240 | matched.append(tech) 241 | if not stix_format: 242 | matched = self._parse_fn(matched, TechniqueModel) 243 | return matched 244 | 245 | 246 | def get_techniques_by_platform(self, *, name: str, case: bool = True, stix_format: bool = True) -> List[Union[AttackPatternV21, Dict[str, Any]]]: 247 | """Return techniques targeting a given platform. 248 | 249 | Parameters 250 | ---------- 251 | name : str 252 | Platform name to match. 253 | case : bool, optional 254 | When `True`, use a STIX filter containment match; when `False`, perform a 255 | case-insensitive containment match in Python. 256 | stix_format : bool, optional 257 | When `True`, return STIX objects/dicts; when `False`, return dictionaries 258 | parsed to the `Technique` Pydantic model. 259 | 260 | Returns 261 | ------- 262 | list[AttackPatternV21 | dict[str, Any]] 263 | Matching technique objects in the requested format. 264 | """ 265 | if not case: 266 | all_techniques = self.get_techniques(stix_format=True) 267 | matched = [] 268 | for tech in all_techniques: 269 | if "x_mitre_platforms" in tech.keys(): 270 | for platform in tech["x_mitre_platforms"]: 271 | if name.lower() in platform.lower(): 272 | matched.append(tech) 273 | else: 274 | filter_objects = [Filter("type", "=", "attack-pattern"), Filter("x_mitre_platforms", "contains", name)] 275 | matched = self._data_source.query(filter_objects) 276 | if not stix_format: 277 | matched = self._parse_fn(matched, TechniqueModel) 278 | return matched 279 | 280 | 281 | def get_techniques_by_tactic(self, *, name: str, case: bool = True, stix_format: bool = True) -> List[Union[AttackPatternV21, Dict[str, Any]]]: 282 | """Return techniques mapped to a given tactic (kill chain phase). 283 | 284 | Parameters 285 | ---------- 286 | name : str 287 | Tactic/phase name to match. 288 | case : bool, optional 289 | When `True`, use a STIX filter exact match; when `False`, perform a 290 | case-insensitive match in Python. 291 | stix_format : bool, optional 292 | When `True`, return STIX objects/dicts; when `False`, return dictionaries 293 | parsed to the `Technique` Pydantic model. 294 | 295 | Returns 296 | ------- 297 | list[AttackPatternV21 | dict[str, Any]] 298 | Matching technique objects in the requested format. 299 | """ 300 | if not case: 301 | all_techniques = self.get_techniques(stix_format=True) 302 | matched = [] 303 | for tech in all_techniques: 304 | if "kill_chain_phases" in tech.keys(): 305 | if name.lower() in tech["kill_chain_phases"][0]["phase_name"].lower(): 306 | matched.append(tech) 307 | else: 308 | filter_objects = [Filter("type", "=", "attack-pattern"), Filter("kill_chain_phases.phase_name", "=", name)] 309 | matched = self._data_source.query(filter_objects) 310 | if not stix_format: 311 | matched = self._parse_fn(matched, TechniqueModel) 312 | return matched 313 | 314 | 315 | def get_techniques_since_time(self, *, timestamp: str, stix_format: bool = True) -> List[Union[AttackPatternV21, Dict[str, Any]]]: 316 | """Return techniques created after the provided timestamp. 317 | 318 | Parameters 319 | ---------- 320 | timestamp : str 321 | Timestamp string to filter by (STIX `created` field). 322 | stix_format : bool, optional 323 | When `True`, return STIX objects/dicts; when `False`, return dictionaries 324 | parsed to the `Technique` Pydantic model. 325 | 326 | Returns 327 | ------- 328 | list[AttackPatternV21 | dict[str, Any]] 329 | Matching technique objects in the requested format. 330 | """ 331 | filter_objects = [Filter("type", "=", "attack-pattern"), Filter("created", ">", timestamp)] 332 | matched = self._data_source.query(filter_objects) 333 | if not stix_format: 334 | matched = self._parse_fn(matched, TechniqueModel) 335 | return matched 336 | --------------------------------------------------------------------------------