diff --git a/app/calderaapi_2.py b/app/calderaapi_2.py new file mode 100644 index 0000000..9d84701 --- /dev/null +++ b/app/calderaapi_2.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" Direct API to the caldera server. Not abstract simplification methods. Compatible with Caldera 2.8.1 """ + +import json +import requests +import simplejson + + +class CalderaAPI: + """ API to Caldera 2.8.1 """ + + def __init__(self, server: str, attack_logger, config=None, apikey=None): + """ + + @param server: Caldera server url/ip + @param attack_logger: The attack logger to use + @param config: The configuration + """ + # print(server) + self.url = server if server.endswith("/") else server + "/" + self.attack_logger = attack_logger + + self.config = config + + if self.config: + self.apikey = self.config.caldera_apikey() + else: + self.apikey = apikey + + def __contact_server__(self, payload, rest_path: str = "api/rest", method: str = "post"): + """ + + @param payload: payload as dict to send to the server + @param rest_path: specific path for this rest api + @param method: http method to use + """ + url = self.url + rest_path + header = {"KEY": self.apikey, + "Content-Type": "application/json"} + if method.lower() == "post": + request = requests.post(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "put": + request = requests.put(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "get": + request = requests.get(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "delete": + request = requests.delete(url, headers=header, data=json.dumps(payload)) + else: + raise ValueError + try: + res = request.json() + except simplejson.errors.JSONDecodeError as exception: # type: ignore + print("!!! Error !!!!") + print(payload) + print(request.text) + print("!!! Error !!!!") + raise exception + + return res + + def list_operations(self): + """ Return operations """ + + payload = {"index": "operations"} + return self.__contact_server__(payload) + + def list_abilities(self): + """ Return all ablilities """ + # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"abilities"}' + + payload = {"index": "abilities"} + return self.__contact_server__(payload) + + def list_agents(self): + """ List running agents + + """ + # TODO: Add filters for specific platforms/executors : , platform_filter=None, executor_filter=None as parameters + # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"agents"}' + payload = {"index": "agents"} + + agents = self.__contact_server__(payload) + return agents + + def list_sources(self): + """ List stored facts + + """ + # TODO: Add filters for specific platforms/executors : , platform_filter=None, executor_filter=None as parameters + # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"agents"}' + payload = {"index": "sources"} + + facts = self.__contact_server__(payload) + return facts + + def list_adversaries(self): + """ List registered adversaries """ + # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"adversaries"}' + payload = {"index": "adversaries"} + return self.__contact_server__(payload) + + def list_objectives(self): + """ List registered objectives """ + # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"objectives"}' + payload = {"index": "objectives"} + return self.__contact_server__(payload) + + def add_sources(self, name: str, parameters): + """ Adds a data source and seeds it with facts """ + + payload = {"index": "sources", + "name": name, + # "id": "123456-1234-1234-1234-12345678", + "rules": [], + "relationships": [] + } + + facts = [] + if parameters is not None: + for key, value in parameters.items(): + facts.append({"trait": key, "value": value}) + + # TODO: We need something better than a dict here as payload to have strong typing + payload["facts"] = facts # type: ignore + + print(payload) + return self.__contact_server__(payload, method="put") + + def add_operation(self, **kwargs): + """ Adds a new operation + + @param name: Name of the operation + @param advid: Adversary id + @param group: agent group to attack + @param state: state to initially set + @param obfuscator: obfuscator to use for the attack + @param jitter: jitter to use for the attack + @param parameters: parameters to pass to the ability + """ + + # name: str, advid: str, group: str = "red", state: str = "running", obfuscator: str = "plain-text", jitter: str = '4/8', parameters=None + name: str = kwargs.get("name") + advid: str = kwargs.get("adversary_id") + group: str = kwargs.get("group", "red") + state: str = kwargs.get("state", "running") + obfuscator: str = kwargs.get("obfuscator", "plain-text") + jitter: str = kwargs.get("jitter", "4/8") + parameters = kwargs.get("parameters", None) + + # Add operation: curl -X PUT -H "KEY:$KEY" http://127.0.0.1:8888/api/rest -d '{"index":"operations","name":"testoperation1"}' + # observed from GUI sniffing: PUT {'name': 'schnuffel2', 'group': 'red', 'adversary_id': '0f4c3c67-845e-49a0-927e-90ed33c044e0', 'state': 'running', 'planner': 'atomic', 'autonomous': '1', 'obfuscator': 'plain-text', 'auto_close': '1', 'jitter': '4/8', 'source': 'Alice Filters', 'visibility': '50'} + + sources_name = "source_" + name + self.add_sources(sources_name, parameters) + + # To verify: + # print(self.get_source(sources_name)) + + payload = {"index": "operations", + "name": name, + "state": state, + "autonomous": 1, + 'obfuscator': obfuscator, + 'auto_close': '1', + 'jitter': jitter, + 'source': sources_name, + 'visibility': '50', + "group": group, + # + "planner": "atomic", + "adversary_id": advid, + } + + return self.__contact_server__(payload, method="put") + + def view_operation_report(self, opid: str): + """ views the operation report + + @param opid: Operation id to look for + """ + + # let postData = selectedOperationId ? {'index':'operation_report', 'op_id': selectedOperationId, 'agent_output': Number(agentOutput)} : null; + # checking it (from snifffing protocol at the server): POST {'id': 539687} + payload = {"index": "operation_report", + "op_id": opid, + 'agent_output': 1 + } + return self.__contact_server__(payload) + + def set_operation_state(self, operation_id: str, state: str = "running"): + """ Executes an operation on a server + + @param operation_id: The operation to modify + @param state: The state to set this operation into + """ + + # TODO: Change state of an operation: curl -X POST -H "KEY:ADMIN123" http://localhost:8888/api/rest -d '{"index":"operation", "op_id":123, "state":"finished"}' + # curl -X POST -H "KEY:ADMIN123" http://localhost:8888/api/rest -d '{"index":"operation", "op_id":123, "state":"finished"}' + + if state not in ["running", "finished", "paused", "run_one_link", "cleanup"]: + raise ValueError + + payload = {"index": "operation", + "op_id": operation_id, + "state": state} + return self.__contact_server__(payload) + + def add_adversary(self, name: str, ability: str, description: str = "created automatically"): + """ Adds a new adversary + + @param name: Name of the adversary + @param ability: One ability for this adversary + @param description: Description of this adversary + """ + + # Add operation: curl -X PUT -H "KEY:$KEY" http://127.0.0.1:8888/api/rest -d '{"index":"operations","name":"testoperation1"}' + + # Sniffed from gui: + # Rest core: PUT adversaries {'name': 'removeme', 'description': 'description', 'atomic_ordering': [{'id': 'bd527b63-9f9e-46e0-9816-b8434d2b8989'}], 'id': '558932cb-3ac6-43d2-b821-2db0fa8ad469', 'objective': ''} + # Returns: [{'name': 'removeme', 'adversary_id': '558932cb-3ac6-43d2-b821-2db0fa8ad469', 'description': 'description', 'tags': [], 'atomic_ordering': ['bd527b63-9f9e-46e0-9816-b8434d2b8989'], 'objective': '495a9828-cab1-44dd-a0ca-66e58177d8cc'}] + + payload = {"index": "adversaries", + "name": name, + "description": description, + "atomic_ordering": [{"id": ability}], + # + "objective": '495a9828-cab1-44dd-a0ca-66e58177d8cc' # default objective + # "objective": '' + } + return self.__contact_server__(payload, method="put") + + # curl -X DELETE http://localhost:8888/api/rest -d '{"index":"operations","id":"$operation_id"}' + def delete_operation(self, opid: str): + """ Delete operation by id + + @param opid: Operation id + """ + payload = {"index": "operations", + "id": opid} + return self.__contact_server__(payload, method="delete") + + def delete_adversary(self, adid: str): + """ Delete adversary by id + + @param adid: Adversary id + """ + payload = {"index": "adversaries", + "adversary_id": [{"adversary_id": adid}]} + return self.__contact_server__(payload, method="delete") + + def delete_agent(self, paw: str): + """ Delete a specific agent from the kali db. implant may still be running and reconnect + + @param paw: The Id of the agent to delete + """ + payload = {"index": "adversaries", + "paw": paw} + return self.__contact_server__(payload, method="delete") + + def kill_agent(self, paw: str): + """ Send a message to an agent to kill itself + + @param paw: The Id of the agent to delete + """ + + payload = {"index": "agents", + "paw": paw, + "watchdog": 1, + "sleep_min": 3, + "sleep_max": 3} + + return self.__contact_server__(payload, method="put") diff --git a/app/calderaapi_4.py b/app/calderaapi_4.py new file mode 100644 index 0000000..712d9d2 --- /dev/null +++ b/app/calderaapi_4.py @@ -0,0 +1,710 @@ +#!/usr/bin/env python3 + +""" Remote control a caldera 4 server. Starting compatible to the old control 2.8 calderacontrol. Maybe it will stop being compatible if refactoring is an option """ + +import json + +from pprint import pformat +from typing import Optional, Union +import requests +import simplejson +from pydantic.dataclasses import dataclass +from pydantic import conlist # pylint: disable=no-name-in-module + +# from app.exceptions import CalderaError +# from app.interface_sfx import CommandlineColors + + +# TODO: Ability deserves an own class. +# TODO: Support all Caldera agents: "Sandcat (GoLang)","Elasticat (Blue Python/ Elasticsearch)","Manx (Reverse Shell TCP)","Ragdoll (Python/HTML)" + +@dataclass +class Variation: + description: str + command: str + + +@dataclass +class ParserConfig: + source: str + edge: str + target: str + custom_parser_vals: dict # undocumented ! Needs improvement ! TODO + + +@dataclass +class Parser: + module: str + relationships: list[ParserConfig] # undocumented ! Needs improvement ! TODO + parserconfigs: Optional[list[ParserConfig]] = None + + +@dataclass +class Requirement: + module: str + relationship_match: list[dict] + + +@dataclass +class AdditionalInfo: + additionalProp1: Optional[str] = None # pylint: disable=invalid-name + additionalProp2: Optional[str] = None # pylint: disable=invalid-name + additionalProp3: Optional[str] = None # pylint: disable=invalid-name + + +@dataclass +class Executor: + build_target: Optional[str] # Why can this be None ? + language: Optional[str] # Why can this be None ? + payloads: list[str] + variations: list[Variation] + additional_info: Optional[AdditionalInfo] + parsers: list[Parser] + cleanup: list[str] + name: str + timeout: int + code: Optional[str] # Why can this be None ? + uploads: list[str] + platform: str + command: Optional[str] + + def get(self, akey, default=None): + """ Get a specific element out of the internal data representation, behaves like the well know 'get' """ + if akey in self.__dict__: + return self.__dict__[akey] + + return default + + +@dataclass +class Ability: + """ An ability is an exploit, a TTP, an attack step ...more or less... """ + description: str + plugin: str + technique_name: str + requirements: list[Requirement] + additional_info: AdditionalInfo + singleton: bool + buckets: list[str] + access: dict + executors: list[Executor] + name: str + technique_id: str + tactic: str + repeatable: str + ability_id: str + privilege: Optional[str] = None + + def get(self, akey, default=None): + """ Get a specific element out of the internal data representation, behaves like the well know 'get' """ + if akey in self.__dict__: + return self.__dict__[akey] + + return default + + +@dataclass +class AbilityList: + """ A list of exploits """ + abilities: conlist(Ability, min_items=1) + + def get_data(self): + return self.abilities + + +@dataclass +class Obfuscator: + """ An obfuscator hides the attack by encryption/encoding """ + description: str + name: str + module: Optional[str] = None # Documentation error !!! + + +@dataclass +class ObfuscatorList: + """ A list of obfuscators """ + obfuscators: conlist(Obfuscator, min_items=1) + + def get_data(self): + return self.obfuscators + + +@dataclass +class Adversary: + """ An adversary is a defined attacker """ + has_repeatable_abilities: bool + adversary_id: str + description: str + name: str + atomic_ordering: list[str] + objective: str + tags: list[str] + plugin: Optional[str] = None + + def get(self, akey, default=None): + """ Get a specific element out of the internal data representation, behaves like the well know 'get' """ + if akey in self.__dict__: + return self.__dict__[akey] + + return default + + +@dataclass +class AdversaryList: + """ A list of adversary """ + adversaries: conlist(Adversary, min_items=1) + + def get_data(self): + return self.adversaries + + +@dataclass +class Fact: + unique: str + name: str + score: int + limit_count: int + relationships: list[str] + source: str + trait: str + links: list[str] + created: str + origin_type: Optional[str] = None + value: Optional[str] = None + technique_id: Optional[str] = None + collected_by: Optional[str] = None + + def get(self, akey, default=None): + """ Get a specific element out of the internal data representation, behaves like the well know 'get' """ + if akey in self.__dict__: + return self.__dict__[akey] + + return default + + +@dataclass +class Relationship: + target: Fact + unique: str + score: int + edge: str + origin: str + source: Fact + + +@dataclass +class Visibility: + score: int + adjustments: list[int] + + +@dataclass +class Link: + pin: int + ability: Ability + paw: str + status: int + finish: str + decide: str + output: str + visibility: Visibility + pid: str + host: str + executor: Executor + unique: str + score: int + used: list[Fact] + facts: list[Fact] + agent_reported_time: str + id: str # pylint: disable=invalid-name + collect: str + command: str + cleanup: int + relationships: list[Relationship] + jitter: int + deadman: bool + + +@dataclass +class Agent: + """ A representation of an agent on the target (agent = implant) """ + paw: str + location: str + platform: str + last_seen: str # Error in document + host_ip_addrs: list[str] + group: str + architecture: str + pid: int + server: str + trusted: bool + username: str + host: str + ppid: int + created: str + links: list[Link] + sleep_max: int + exe_name: str + display_name: str + sleep_min: int + contact: str + deadman_enabled: bool + proxy_receivers: AdditionalInfo + origin_link_id: str + executors: list[str] + watchdog: int + proxy_chain: list[list[str]] + available_contacts: list[str] + upstream_dest: str + pending_contact: str + privilege: Optional[str] = None # Error, not documented + + def get(self, akey, default=None): + """ Get a specific element out of the internal data representation, behaves like the well know 'get' """ + if akey in self.__dict__: + return self.__dict__[akey] + + return default + + +@dataclass +class AgentList: + """ A list of agents """ + agents: list[Agent] + + def get_data(self): + return self.agents + + +@dataclass +class Rule: + match: str + trait: str + action: Optional[str] = None + + +@dataclass +class Adjustment: + offset: int + trait: str + value: str + ability_id: str + + +@dataclass +class Source: + name: str + plugin: str + facts: list[Fact] + rules: list[Rule] + relationships: list[Relationship] + id: str # pylint: disable=invalid-name + adjustments: Optional[list[Adjustment]] = None + + def get(self, akey, default=None): + """ Get a specific element out of the internal data representation, behaves like the well know 'get' """ + if akey in self.__dict__: + return self.__dict__[akey] + + return default + + +@dataclass +class SourceList: + sources: list[Source] + + def get_data(self): + return self.sources + + +@dataclass +class Planner: + """ A logic defining the order in which attack steps are executed """ + name: str + plugin: str + id: str # pylint: disable=invalid-name + stopping_conditions: list[Fact] + params: dict + description: str + allow_repeatable_abilities: bool + module: Optional[str] = None + ignore_enforcement_module: Optional[list[str]] = None + ignore_enforcement_modules: Optional[list[str]] = None # Maybe error in Caldera 4 ? + + +@dataclass +class PlannerList: + planners: list[Planner] + + def get_data(self): + return self.planners + + +@dataclass +class Goal: + target: str + count: int + achieved: bool + operator: str + value: str + + +@dataclass +class Objective: + percentage: int + name: str + goals: list[Goal] + description: str + id: str # pylint: disable=invalid-name + + def get(self, akey, default=None): + """ Get a specific element out of the internal data representation, behaves like the well know 'get' """ + if akey in self.__dict__: + return self.__dict__[akey] + + return default + + +@dataclass +class Operation: + """ An attack operation collecting all the relevant items (obfuscator, adversary, planner) """ + obfuscator: str + state: str + jitter: str + autonomous: int + name: str + source: Source + adversary: Adversary + objective: Union[Objective, str] # Maybe Error in caldera 4: Creating a Operation returns a objective ID, not an objective object + host_group: list[Agent] + start: str + group: str + use_learning_parsers: bool + planner: Planner + visibility: int + id: str # pylint: disable=invalid-name + auto_close: bool + chain: Optional[list] = None + + def get(self, akey, default=None): + """ Get a specific element out of the internal data representation, behaves like the well know 'get' """ + if akey in self.__dict__: + return self.__dict__[akey] + + return default + + +@dataclass +class OperationList: + operations: conlist(Operation) + + def get_data(self): + return self.operations + + +@dataclass +class ObjectiveList: + objectives: conlist(Objective) + + def get_data(self): + return self.objectives + + +class CalderaAPI(): + """ Remote control Caldera through REST api """ + + def __init__(self, server: str, attack_logger, config=None, apikey=None): + """ + + @param server: Caldera server url/ip + @param attack_logger: The attack logger to use + @param config: The configuration + """ + self.url = server if server.endswith("/") else server + "/" + self.attack_logger = attack_logger + + self.config = config + + if self.config: + self.apikey = self.config.caldera_apikey() + else: + self.apikey = apikey + + def __contact_server__(self, payload, rest_path: str = "api/v2/abilities", method: str = "get"): + """ + + @param payload: payload as dict to send to the server + @param rest_path: specific path for this rest api + @param method: http method to use + """ + url = self.url + rest_path + header = {"KEY": "ADMIN123", + "accept": "application/json", + "Content-Type": "application/json"} + if method.lower() == "post": + j = json.dumps(payload) + request = requests.post(url, headers=header, data=j) + elif method.lower() == "put": + request = requests.put(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "get": + request = requests.get(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "head": + request = requests.head(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "delete": + request = requests.delete(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "patch": + request = requests.patch(url, headers=header, data=json.dumps(payload)) + else: + raise ValueError + try: + if request.status_code == 200: + res = request.json() + # Comment: Sometimes we get a 204: succcess, but not content in response + elif request.status_code == 204: + res = {"result": "ok", + "http_status_code": 204} + else: + print(f"Status code: {request.status_code} {request.json()}") + res = request.json() + + except simplejson.errors.JSONDecodeError as exception: # type: ignore + print("!!! Error !!!!") + print(payload) + print(request.text) + print("!!! Error !!!!") + raise exception + + return res + + def list_abilities(self): + """ Return all ablilities """ + + payload = None + data = {"abilities": self.__contact_server__(payload, method="get", rest_path="api/v2/abilities")} + abilities = AbilityList(**data) + return abilities.get_data() + + def list_obfuscators(self): + """ Return all obfuscators """ + + payload = None + data = {"obfuscators": self.__contact_server__(payload, method="get", rest_path="api/v2/obfuscators")} + obfuscators = ObfuscatorList(**data) + return obfuscators.get_data() + + def list_adversaries(self): + """ Return all adversaries """ + + payload = None + data = {"adversaries": self.__contact_server__(payload, method="get", rest_path="api/v2/adversaries")} + adversaries = AdversaryList(**data) + return adversaries.get_data() + + def list_sources(self): + """ Return all sources """ + + payload = None + data = {"sources": self.__contact_server__(payload, method="get", rest_path="api/v2/sources")} + sources = SourceList(**data) + return sources.get_data() + + def list_planners(self): + """ Return all planners """ + + payload = None + data = {"planners": self.__contact_server__(payload, method="get", rest_path="api/v2/planners")} + planners = PlannerList(**data) + return planners.get_data() + + def list_operations(self): + """ Return all operations """ + + payload = None + data = {"operations": self.__contact_server__(payload, method="get", rest_path="api/v2/operations")} + operations = OperationList(**data) + return operations.get_data() + + def set_operation_state(self, operation_id: str, state: str = "running"): + """ Executes an operation on a server + + @param operation_id: The operation to modify + @param state: The state to set this operation into + """ + + # TODO: Change state of an operation: curl -X POST -H "KEY:ADMIN123" http://localhost:8888/api/rest -d '{"index":"operation", "op_id":123, "state":"finished"}' + # curl -X POST -H "KEY:ADMIN123" http://localhost:8888/api/rest -d '{"index":"operation", "op_id":123, "state":"finished"}' + + if state not in ["running", "finished", "paused", "run_one_link", "cleanup"]: + raise ValueError + + payload = {"state": state} + return self.__contact_server__(payload, method="patch", rest_path=f"api/v2/operations/{operation_id}") + + def list_agents(self): + """ Return all agents """ + + payload = None + data = {"agents": self.__contact_server__(payload, method="get", rest_path="api/v2/agents")} + agents = AgentList(**data) + return agents.get_data() + + def list_objectives(self): + """ Return all objectivs """ + + payload = None + data = {"objectives": self.__contact_server__(payload, method="get", rest_path="api/v2/objectives")} + objectives = ObjectiveList(**data) + return objectives.get_data() + + def add_adversary(self, name: str, ability: str, description: str = "created automatically"): + """ Adds a new adversary + + :param name: Name of the adversary + :param ability: Ability ID to add + :param description: Human readable description + :return: + """ + payload = { + # "adversary_id": "string", + "atomic_ordering": [ + ability + ], + "name": name, + # "plugin": "string", + "objective": '495a9828-cab1-44dd-a0ca-66e58177d8cc', # default objective + # "tags": [ + # "string" + # ], + "description": description + } + data = {"agents": self.__contact_server__(payload, method="post", rest_path="api/v2/adversaries")} + # agents = AgentList(**data) + return data + + def delete_adversary(self, adversary_id: str): + """ Deletes an adversary + + :param adversary_id: The id of this adversary + :return: + """ + payload = None + data = {"agents": self.__contact_server__(payload, method="delete", rest_path=f"api/v2/adversaries/{adversary_id}")} + return data + + def delete_agent(self, agent_paw: str): + """ Deletes an agent + + :param agent_paw: the paw to delete + :return: + """ + payload = None + data = {"agents": self.__contact_server__(payload, method="delete", rest_path=f"api/v2/agents/{agent_paw}")} + return data + + def kill_agent(self, agent_paw: str): + """ Kills an agent on the target + + :param agent_paw: The paw identifying this agent + :return: + """ + payload = {"watchdog": 1, + "sleep_min": 3, + "sleep_max": 3} + data = self.__contact_server__(payload, method="patch", rest_path=f"api/v2/agents/{agent_paw}") + return data + + def add_operation(self, **kwargs): + """ Adds a new operation + + :param kwargs: + :return: + """ + + # name, adversary_id, source_id = "basic", planner_id = "atomic", group = "", state: str = "running", obfuscator: str = "plain-text", jitter: str = '4/8' + + name: str = kwargs.get("name") + adversary_id: str = kwargs.get("adversary_id") + source_id: str = kwargs.get("source_id", "basic") + planner_id: str = kwargs.get("planner_id", "atomic") + group: str = kwargs.get("group", "") + state: str = kwargs.get("state", "running") + obfuscator: str = kwargs.get("obfuscator", "plain-text") + jitter: str = kwargs.get("jitter", "4/8") + + payload = {"name": name, + "group": group, + "adversary": {"adversary_id": adversary_id}, + "auto_close": False, + "state": state, + "autonomous": 1, + "planner": {"id": planner_id}, + "source": {"id": source_id}, + "use_learning_parsers": True, + "obfuscator": obfuscator, + "jitter": jitter, + "visibility": "51"} + data = {"operations": [self.__contact_server__(payload, method="post", rest_path="api/v2/operations")]} + operations = OperationList(**data) + return operations + + def delete_operation(self, operation_id): + """ Deletes an operation + + :param operation_id: The Id of the operation to delete + :return: + """ + + payload = {} + + data = self.__contact_server__(payload, method="delete", rest_path=f"api/v2/operations/{operation_id}") + + return data + + def view_operation_report(self, operation_id): + """ Views the report of a finished operation + + :param operation_id: The id of this operation + :return: + """ + + payload = { + "enable_agent_output": True + } + + data = self.__contact_server__(payload, method="post", rest_path=f"api/v2/operations/{operation_id}/report") + + return data + + def get_ability(self, abid: str): + """" Return an ability by id + + @param abid: Ability id + """ + + res = [] + + print(f"Number of abilities: {len(self.list_abilities())}") + + with open("debug_removeme.txt", "wt") as fh: + fh.write(pformat(self.list_abilities())) + + for ability in self.list_abilities()["abilities"]: + if ability.get("ability_id", None) == abid or ability.get("auto_generated_guid", None) == abid: + res.append(ability) + return res + + def pretty_print_ability(self, abi): + """ Pretty pritns an ability + + @param abi: A ability dict + """ + + print(""" + TTP: {technique_id} + Technique name: {technique_name} + Tactic: {tactic} + Name: {name} + ID: {ability_id} + Description: {description} + + """.format(**abi)) diff --git a/app/calderacontrol.py b/app/calderacontrol.py index 4052d81..dddc880 100644 --- a/app/calderacontrol.py +++ b/app/calderacontrol.py @@ -2,43 +2,26 @@ """ Remote control a caldera server """ -import json import os import time from pprint import pprint, pformat from typing import Optional import requests -import simplejson from app.exceptions import CalderaError from app.interface_sfx import CommandlineColors +# from app.calderaapi_2 import CalderaAPI +from app.calderaapi_4 import CalderaAPI + # TODO: Ability deserves an own class. # TODO: Support all Caldera agents: "Sandcat (GoLang)","Elasticat (Blue Python/ Elasticsearch)","Manx (Reverse Shell TCP)","Ragdoll (Python/HTML)" -class CalderaControl(): +class CalderaControl(CalderaAPI): """ Remote control Caldera through REST api """ - def __init__(self, server: str, attack_logger, config=None, apikey=None): - """ - - @param server: Caldera server url/ip - @param attack_logger: The attack logger to use - @param config: The configuration - """ - # print(server) - self.url = server if server.endswith("/") else server + "/" - self.attack_logger = attack_logger - - self.config = config - - if self.config: - self.apikey = self.config.caldera_apikey() - else: - self.apikey = apikey - def fetch_client(self, platform: str = "windows", file: str = "sandcat.go", target_dir: str = ".", extension: str = ""): """ Downloads the appropriate Caldera client @@ -57,98 +40,11 @@ class CalderaControl(): # print(r.headers) return filename - def __contact_server__(self, payload, rest_path: str = "api/rest", method: str = "post"): - """ - - @param payload: payload as dict to send to the server - @param rest_path: specific path for this rest api - @param method: http method to use - """ - url = self.url + rest_path - header = {"KEY": self.apikey, - "Content-Type": "application/json"} - if method.lower() == "post": - request = requests.post(url, headers=header, data=json.dumps(payload)) - elif method.lower() == "put": - request = requests.put(url, headers=header, data=json.dumps(payload)) - elif method.lower() == "get": - request = requests.get(url, headers=header, data=json.dumps(payload)) - elif method.lower() == "delete": - request = requests.delete(url, headers=header, data=json.dumps(payload)) - else: - raise ValueError - try: - res = request.json() - except simplejson.errors.JSONDecodeError as exception: # type: ignore - print("!!! Error !!!!") - print(payload) - print(request.text) - print("!!! Error !!!!") - raise exception - - return res - - # ############## List - def list_links(self, opid: str): - """ List links associated with an operation - - @param opid: operation id to list links for - """ - - payload = {"index": "link", - "op_id": opid} - return self.__contact_server__(payload) - - def list_results(self, linkid: str): - """ List results for a link - - @param linkid: ID of the link - """ - - payload = {"index": "result", - "link_id": linkid} - return self.__contact_server__(payload) - - def list_operations(self): - """ Return operations """ - - payload = {"index": "operations"} - return self.__contact_server__(payload) - - def list_abilities(self): - """ Return all ablilities """ - # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"abilities"}' - - payload = {"index": "abilities"} - return self.__contact_server__(payload) - - def list_agents(self): - """ List running agents - - """ - # TODO: Add filters for specific platforms/executors : , platform_filter=None, executor_filter=None as parameters - # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"agents"}' - payload = {"index": "agents"} - - agents = self.__contact_server__(payload) - return agents - - def list_sources(self): - """ List stored facts - - """ - # TODO: Add filters for specific platforms/executors : , platform_filter=None, executor_filter=None as parameters - # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"agents"}' - payload = {"index": "sources"} - - facts = self.__contact_server__(payload) - return facts - def list_sources_for_name(self, name: str): """ List facts in a source pool with a specific name """ for i in self.list_sources(): - if i["name"] == name: + if i.get("name") == name: return i return None @@ -164,31 +60,19 @@ class CalderaControl(): return {} res = {} - for i in source["facts"]: - res[i["trait"]] = {"value": i["value"], - "technique_id": i["technique_id"], - "collected_by": i["collected_by"] - } + for i in source.get("facts"): + res[i.get("trait")] = {"value": i.get("value"), + "technique_id": i.get("technique_id"), + "collected_by": i.get("collected_by") + } return res def list_paws_of_running_agents(self): """ Returns a list of all paws of running agents """ - return [i["paw"] for i in self.list_agents()] - - def list_adversaries(self): - """ List registered adversaries """ - # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"adversaries"}' - payload = {"index": "adversaries"} - return self.__contact_server__(payload) - - def list_objectives(self): - """ List registered objectives """ - # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"objectives"}' - payload = {"index": "objectives"} - return self.__contact_server__(payload) + return [i.get("paw") for i in self.list_agents()] # 2.8.1 version + # return [i.paw for i in self.list_agents()] # 4* version # ######### Get one specific item - def get_operation(self, name: str): """ Gets an operation by name @@ -196,7 +80,7 @@ class CalderaControl(): """ for operation in self.list_operations(): - if operation["name"] == name: + if operation.get("name") == name: return operation return None @@ -206,7 +90,7 @@ class CalderaControl(): @param name: Name to look for """ for adversary in self.list_adversaries(): - if adversary["name"] == name: + if adversary.get("name") == name: return adversary return None @@ -216,22 +100,10 @@ class CalderaControl(): @param name: Name to filter for """ for objective in self.list_objectives(): - if objective["name"] == name: + if objective.get("name") == name: return objective return None - # ######### Get by id - - def get_source(self, source_name: str): - """ Retrieves data source and detailed facts - - @param: The name of the source - """ - - payload = {"index": "sources", - "name": source_name} - return self.__contact_server__(payload) - def get_ability(self, abid: str): """" Return an ability by id @@ -262,12 +134,17 @@ class CalderaControl(): abilities = self.get_ability(abid) for ability in abilities: - if ability["platform"] == platform: + if ability.get("platform") == platform: return True if platform in ability.get("supported_platforms", []): return True if platform in ability.get("platforms", []): return True + executors = ability.get("executors") # For Caldera 4.* + if executors is not None: + for executor in executors: + if executor.get("platform") == platform: + return True print(self.get_ability(abid)) return False @@ -276,18 +153,13 @@ class CalderaControl(): @param op_id: Operation id """ - payload = {"index": "operations", - "id": op_id} - return self.__contact_server__(payload) - - def get_result_by_id(self, linkid: str): - """ Get the result from a link id + operations = self.list_operations() - @param linkid: link id - """ - payload = {"index": "result", - "link_id": linkid} - return self.__contact_server__(payload) + if operations is not None: + for an_operation in operations: + if an_operation.get("id") == op_id: + return [an_operation] + return [] def get_linkid(self, op_id: str, paw: str, ability_id: str): """ Get the id of a link identified by paw and ability_id @@ -309,20 +181,6 @@ class CalderaControl(): # ######### View - def view_operation_report(self, opid: str): - """ views the operation report - - @param opid: Operation id to look for - """ - - # let postData = selectedOperationId ? {'index':'operation_report', 'op_id': selectedOperationId, 'agent_output': Number(agentOutput)} : null; - # checking it (from snifffing protocol at the server): POST {'id': 539687} - payload = {"index": "operation_report", - "op_id": opid, - 'agent_output': 1 - } - return self.__contact_server__(payload) - def view_operation_output(self, opid: str, paw: str, ability_id: str): """ Gets the output of an executed ability @@ -332,200 +190,24 @@ class CalderaControl(): """ orep = self.view_operation_report(opid) + # print(orep) if paw not in orep["steps"]: print("Broken operation report:") pprint(orep) print(f"Could not find {paw} in {orep['steps']}") raise CalderaError # print("oprep: " + str(orep)) - for a_step in orep["steps"][paw]["steps"]: - if a_step["ability_id"] == ability_id: + for a_step in orep.get("steps").get(paw).get("steps"): + if a_step.get("ability_id") == ability_id: try: - return a_step["output"] + return a_step.get("output") except KeyError as exception: raise CalderaError from exception # print(f"Did not find ability {ability_id} in caldera operation output") return None - # ######### Add - - def add_sources(self, name: str, parameters): - """ Adds a data source and seeds it with facts """ - - payload = {"index": "sources", - "name": name, - # "id": "123456-1234-1234-1234-12345678", - "rules": [], - "relationships": [] - } - - facts = [] - if parameters is not None: - for key, value in parameters.items(): - facts.append({"trait": key, "value": value}) - - # TODO: We need something better than a dict here as payload to have strong typing - payload["facts"] = facts # type: ignore - - print(payload) - return self.__contact_server__(payload, method="put") - - def add_operation(self, name: str, advid: str, group: str = "red", state: str = "running", obfuscator: str = "plain-text", jitter: str = '4/8', parameters=None): - """ Adds a new operation - - @param name: Name of the operation - @param advid: Adversary id - @param group: agent group to attack - @param state: state to initially set - @param obfuscator: obfuscator to use for the attack - @param jitter: jitter to use for the attack - @param parameters: parameters to pass to the ability - """ - - # Add operation: curl -X PUT -H "KEY:$KEY" http://127.0.0.1:8888/api/rest -d '{"index":"operations","name":"testoperation1"}' - # observed from GUI sniffing: PUT {'name': 'schnuffel2', 'group': 'red', 'adversary_id': '0f4c3c67-845e-49a0-927e-90ed33c044e0', 'state': 'running', 'planner': 'atomic', 'autonomous': '1', 'obfuscator': 'plain-text', 'auto_close': '1', 'jitter': '4/8', 'source': 'Alice Filters', 'visibility': '50'} - - sources_name = "source_" + name - self.add_sources(sources_name, parameters) - - # To verify: - # print(self.get_source(sources_name)) - - payload = {"index": "operations", - "name": name, - "state": state, - "autonomous": 1, - 'obfuscator': obfuscator, - 'auto_close': '1', - 'jitter': jitter, - 'source': sources_name, - 'visibility': '50', - "group": group, - # - "planner": "atomic", - "adversary_id": advid, - } - - return self.__contact_server__(payload, method="put") - - def add_adversary(self, name: str, ability: str, description: str = "created automatically"): - """ Adds a new adversary - - @param name: Name of the adversary - @param ability: One ability for this adversary - @param description: Description of this adversary - """ - - # Add operation: curl -X PUT -H "KEY:$KEY" http://127.0.0.1:8888/api/rest -d '{"index":"operations","name":"testoperation1"}' - - # Sniffed from gui: - # Rest core: PUT adversaries {'name': 'removeme', 'description': 'description', 'atomic_ordering': [{'id': 'bd527b63-9f9e-46e0-9816-b8434d2b8989'}], 'id': '558932cb-3ac6-43d2-b821-2db0fa8ad469', 'objective': ''} - # Returns: [{'name': 'removeme', 'adversary_id': '558932cb-3ac6-43d2-b821-2db0fa8ad469', 'description': 'description', 'tags': [], 'atomic_ordering': ['bd527b63-9f9e-46e0-9816-b8434d2b8989'], 'objective': '495a9828-cab1-44dd-a0ca-66e58177d8cc'}] - - payload = {"index": "adversaries", - "name": name, - "description": description, - "atomic_ordering": [{"id": ability}], - # - "objective": '495a9828-cab1-44dd-a0ca-66e58177d8cc' # default objective - # "objective": '' - } - return self.__contact_server__(payload, method="put") - - # ######### Execute - - # TODO View the abilities a given agent could execute. curl -H "key:$API_KEY" -X POST localhost:8888/plugin/access/abilities -d '{"paw":"$PAW"}' - - def execute_ability(self, paw: str, ability_id: str, obfuscator: str = "plain-text", parameters=None): - """ Executes an ability on a target. This happens outside of the scop of an operation. You will get no result of the ability back - - @param paw: Paw of the target - @param ability_id: ability to execute - @param obfuscator: Obfuscator to use - @param parameters: parameters to pass to the ability - """ - - # curl -H "key:ADMIN123" -X POST localhost:8888/plugin/access/exploit -d '{"paw":"$PAW","ability_id":"$ABILITY_ID"}'``` - # You can optionally POST an obfuscator and/or a facts dictionary with key/value pairs to fill in any variables the chosen ability requires. - # {"paw":"$PAW","ability_id":"$ABILITY_ID","obfuscator":"base64","facts":[{"trait":"username","value":"admin"},{"trait":"password", "value":"123"}]} - payload = {"paw": paw, - "ability_id": ability_id, - "obfuscator": obfuscator} - - facts = [] - if parameters is not None: - for key, value in parameters.items(): - facts.append({"trait": key, "value": value}) - - # TODO. We need something better than a dict here for strong typing - payload["facts"] = facts # type: ignore - - # print(payload) - - return self.__contact_server__(payload, rest_path="plugin/access/exploit_ex") - - def execute_operation(self, operation_id: str, state: str = "running"): - """ Executes an operation on a server - - @param operation_id: The operation to modify - @param state: The state to set this operation into - """ - - # TODO: Change state of an operation: curl -X POST -H "KEY:ADMIN123" http://localhost:8888/api/rest -d '{"index":"operation", "op_id":123, "state":"finished"}' - # curl -X POST -H "KEY:ADMIN123" http://localhost:8888/api/rest -d '{"index":"operation", "op_id":123, "state":"finished"}' - - if state not in ["running", "finished", "paused", "run_one_link", "cleanup"]: - raise ValueError - - payload = {"index": "operation", - "op_id": operation_id, - "state": state} - return self.__contact_server__(payload) - # ######### Delete - # curl -X DELETE http://localhost:8888/api/rest -d '{"index":"operations","id":"$operation_id"}' - def delete_operation(self, opid: str): - """ Delete operation by id - - @param opid: Operation id - """ - payload = {"index": "operations", - "id": opid} - return self.__contact_server__(payload, method="delete") - - def delete_adversary(self, adid: str): - """ Delete adversary by id - - @param adid: Adversary id - """ - payload = {"index": "adversaries", - "adversary_id": [{"adversary_id": adid}]} - return self.__contact_server__(payload, method="delete") - - def delete_agent(self, paw: str): - """ Delete a specific agent from the kali db. implant may still be running and reconnect - - @param paw: The Id of the agent to delete - """ - payload = {"index": "adversaries", - "paw": paw} - return self.__contact_server__(payload, method="delete") - - def kill_agent(self, paw: str): - """ Send a message to an agent to kill itself - - @param paw: The Id of the agent to delete - """ - - payload = {"index": "agents", - "paw": paw, - "watchdog": 1, - "sleep_min": 3, - "sleep_max": 3} - - return self.__contact_server__(payload, method="put") - def delete_all_agents(self): """ Delete all agents from kali db """ @@ -562,12 +244,14 @@ class CalderaControl(): # Plus: 0 as "finished" # + # TODO: Maybe try to get the report and continue until we have it. Could be done in addition. + operation = self.get_operation_by_id(opid) if debug: print(f"Operation data {operation}") try: # print(operation[0]["state"]) - if operation[0]["state"] == "finished": + if operation[0].get("state") == "finished": return True except KeyError as exception: raise CalderaError from exception @@ -641,15 +325,15 @@ class CalderaControl(): return False self.add_adversary(adversary_name, ability_id) - adid = self.get_adversary(adversary_name)["adversary_id"] + adid = self.get_adversary(adversary_name).get("adversary_id") logid = self.attack_logger.start_caldera_attack(source=self.url, paw=paw, group=group, ability_id=ability_id, - ttp=self.get_ability(ability_id)[0]["technique_id"], - name=self.get_ability(ability_id)[0]["name"], - description=self.get_ability(ability_id)[0]["description"], + ttp=self.get_ability(ability_id)[0].get("technique_id"), + name=self.get_ability(ability_id)[0].get("name"), + description=self.get_ability(ability_id)[0].get("description"), obfuscator=obfuscator, jitter=jitter, **kwargs @@ -658,8 +342,8 @@ class CalderaControl(): # ##### Create / Run Operation self.attack_logger.vprint(f"New adversary generated. ID: {adid}, ability: {ability_id} group: {group}", 2) - res = self.add_operation(operation_name, - advid=adid, + res = self.add_operation(name=operation_name, + adversary_id=adid, group=group, obfuscator=obfuscator, jitter=jitter, @@ -667,14 +351,14 @@ class CalderaControl(): ) self.attack_logger.vprint(pformat(res), 3) - opid = self.get_operation(operation_name)["id"] + opid = self.get_operation(operation_name).get("id") self.attack_logger.vprint("New operation created. OpID: " + str(opid), 3) - self.execute_operation(opid) + self.set_operation_state(opid) self.attack_logger.vprint("Execute operation", 3) retries = 30 - ability_name = self.get_ability(ability_id)[0]["name"] - ability_description = self.get_ability(ability_id)[0]["description"] + ability_name = self.get_ability(ability_id)[0].get("name") + ability_description = self.get_ability(ability_id)[0].get("description") self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Executed attack operation{CommandlineColors.ENDC}", 1) self.attack_logger.vprint(f"{CommandlineColors.BACKGROUND_BLUE} PAW: {paw} Group: {group} Ability: {ability_id} {CommandlineColors.ENDC}", 1) self.attack_logger.vprint(f"{CommandlineColors.BACKGROUND_BLUE} {ability_name}: {ability_description} {CommandlineColors.ENDC}", 1) @@ -713,16 +397,16 @@ class CalderaControl(): self.attack_logger.vprint(self.list_facts_for_name("source_" + operation_name), 2) # ######## Cleanup - self.execute_operation(opid, "cleanup") + self.set_operation_state(opid, "cleanup") self.delete_adversary(adid) self.delete_operation(opid) self.attack_logger.stop_caldera_attack(source=self.url, paw=paw, group=group, ability_id=ability_id, - ttp=self.get_ability(ability_id)[0]["technique_id"], - name=self.get_ability(ability_id)[0]["name"], - description=self.get_ability(ability_id)[0]["description"], + ttp=self.get_ability(ability_id)[0].get("technique_id"), + name=self.get_ability(ability_id)[0].get("name"), + description=self.get_ability(ability_id)[0].get("description"), obfuscator=obfuscator, jitter=jitter, logid=logid, diff --git a/app/experimentcontrol.py b/app/experimentcontrol.py index b2fd8b3..704e4f3 100644 --- a/app/experimentcontrol.py +++ b/app/experimentcontrol.py @@ -16,8 +16,8 @@ from app.interface_sfx import CommandlineColors from app.exceptions import ServerError from app.pluginmanager import PluginManager from app.doc_generator import DocGenerator -from caldera_control import CalderaControl -from machine_control import Machine +from app.calderacontrol import CalderaControl +from app.machinecontrol import Machine from plugins.base.attack import AttackPlugin diff --git a/app/machinecontrol.py b/app/machinecontrol.py index 2ba938b..b6a865e 100644 --- a/app/machinecontrol.py +++ b/app/machinecontrol.py @@ -390,7 +390,8 @@ class Machine(): # TODO: Caldera implant # TODO: Metasploit implant - def install_caldera_server(self, cleanup=False, version="2.8.1"): + # options for version: 4.0.0-alpha.2 2.8.1 + def install_caldera_server(self, cleanup=False, version="4.0.0-alpha.2"): """ Installs the caldera server on the VM @param cleanup: Remove the old caldera version. Slow but reduces side effects diff --git a/caldera_control.py b/caldera_control.py old mode 100644 new mode 100755 index fd9474a..ba70683 --- a/caldera_control.py +++ b/caldera_control.py @@ -3,25 +3,40 @@ """ A command line tool to control a caldera server """ import argparse +from pprint import pprint + +# from app.calderacontrol import CalderaControl +from app.calderaapi_4 import CalderaAPI + -from app.calderacontrol import CalderaControl from app.attack_log import AttackLog +class CmdlineArgumentException(Exception): + """ An error in the user supplied command line """ + # https://caldera.readthedocs.io/en/latest/The-REST-API.html # TODO: Check if attack is finished # TODO: Get results of a specific attack + # Arpgparse handling -def list_agents(calcontrol, arguments): # pylint: disable=unused-argument - """ Call list agents in caldera control +def agents(calcontrol, arguments): # pylint: disable=unused-argument + """ Agents in caldera control @param calcontrol: Connection to the caldera server @param arguments: Parser command line arguments """ - print(f"Running agents: {calcontrol.list_agents()}") + + if arguments.list: + print(calcontrol.list_agents()) + print([i["paw"] for i in calcontrol.list_agents()]) + if arguments.delete: + print(calcontrol.delete_agent(arguments.paw)) + if arguments.kill: + print(calcontrol.kill_agent(arguments.paw)) def list_facts(calcontrol, arguments): # pylint: disable=unused-argument @@ -53,38 +68,136 @@ def add_facts(calcontrol, arguments): # pylint: disable=unused-argument print(f'Created fact: {calcontrol.add_sources(name, data)}') -def delete_agents(calcontrol, arguments): # pylint: disable=unused-argument - """ Call list agents in caldera control +def list_abilities(calcontrol, arguments): + """ Call list abilities in caldera control @param calcontrol: Connection to the caldera server @param arguments: Parser command line arguments """ - print(calcontrol.list_paws_of_running_agents()) - if arguments.paw: - print(calcontrol.kill_agent(paw=arguments.paw)) - print(calcontrol.delete_agent(paw=arguments.paw)) + if arguments.list: + abilities = calcontrol.list_abilities() + abi_ids = [aid.ability_id for aid in abilities] + print(abi_ids) - else: - print(calcontrol.kill_all_agents()) - print(calcontrol.delete_all_agents()) + for abi in abilities: + for executor in abi.executors: + for a_parser in executor.parsers: + pprint(a_parser.relationships) -def list_abilities(calcontrol, arguments): - """ Call list abilities in caldera control +def obfuscators(calcontrol, arguments): + """ Manage obfuscators caldera control + + @param calcontrol: Connection to the caldera server + @param arguments: Parser command line arguments + """ + + if arguments.list: + obfs = calcontrol.list_obfuscators() + # ob_ids = [aid.ability_id for aid in obfuscators] + # print(ob_ids) + + for obfuscator in obfs: + print(obfuscator) + + +def objectives(calcontrol, arguments): + """ Manage objectives caldera control @param calcontrol: Connection to the caldera server @param arguments: Parser command line arguments """ - abilities = arguments.ability_ids + if arguments.list: + for objective in calcontrol.list_objectives(): + print(objective) + + +def adversaries(calcontrol, arguments): + """ Manage adversaries caldera control + + @param calcontrol: Connection to the caldera server + @param arguments: Parser command line arguments + """ - if arguments.all: - abilities = [aid["ability_id"] for aid in calcontrol.list_abilities()] + if arguments.list: + for adversary in calcontrol.list_adversaries(): + print(adversary) + if arguments.add: + if arguments.ability_id is None: + raise CmdlineArgumentException("Creating an adversary requires an ability id") + if arguments.name is None: + raise CmdlineArgumentException("Creating an adversary requires an adversary name") + calcontrol.add_adversary(arguments.name, arguments.ability_id) + if arguments.delete: + if arguments.adversary_id is None: + raise CmdlineArgumentException("Deleting an adversary requires an adversary id") + calcontrol.delete_adversary(arguments.adversary_id) - for aid in abilities: - for ability in calcontrol.get_ability(aid): - calcontrol.pretty_print_ability(ability) + +def sources(calcontrol, arguments): + """ Manage sources caldera control + + @param calcontrol: Connection to the caldera server + @param arguments: Parser command line arguments + """ + + if arguments.list: + for a_source in calcontrol.list_sources(): + print(a_source) + + +def planners(calcontrol, arguments): + """ Manage planners caldera control + + @param calcontrol: Connection to the caldera server + @param arguments: Parser command line arguments + """ + + if arguments.list: + for a_planner in calcontrol.list_planners(): + print(a_planner) + + +def operations(calcontrol, arguments): + """ Manage operations caldera control + + @param calcontrol: Connection to the caldera server + @param arguments: Parser command line arguments + """ + + if arguments.list: + for an_operation in calcontrol.list_operations(): + print(an_operation) + + if arguments.add: + if arguments.adversary_id is None: + raise CmdlineArgumentException("Adding an operation requires an adversary id") + if arguments.name is None: + raise CmdlineArgumentException("Adding an operation requires a name for it") + + ops = calcontrol.add_operation(name=arguments.name, + adversary_id=arguments.adversary_id, + source_id=arguments.source_id, + planner_id=arguments.planner_id, + group=arguments.group, + state=arguments.state, + obfuscator=arguments.obfuscator, + jitter=arguments.jitter) + print(ops) + + if arguments.delete: + if arguments.id is None: + raise CmdlineArgumentException("Deleting an operation requires its id") + ops = calcontrol.delete_operation(arguments.id) + print(ops) + + if arguments.view_report: + if arguments.id is None: + raise CmdlineArgumentException("Viewing an operation report requires an operation id") + report = calcontrol.view_operation_report(arguments.id) + print(report) def attack(calcontrol, arguments): @@ -122,15 +235,15 @@ def create_parser(): parser_abilities.set_defaults(func=list_abilities) parser_abilities.add_argument("--ability_ids", default=[], nargs="+", help="The abilities to look up. One or more ids") - parser_abilities.add_argument("--all", default=False, action="store_true", + parser_abilities.add_argument("--list", default=False, action="store_true", help="List all abilities") parser_agents = subparsers.add_parser("agents", help="agents") - parser_agents.set_defaults(func=list_agents) - - parser_delete_agents = subparsers.add_parser("delete_agents", help="agents") - parser_delete_agents.add_argument("--paw", default=None, help="PAW to delete. if not set it will delete all agents") - parser_delete_agents.set_defaults(func=delete_agents) + parser_agents.set_defaults(func=agents) + parser_agents.add_argument("--list", default=False, action="store_true", help="List all agents") + parser_agents.add_argument("--delete", default=False, action="store_true", help="Delete agent") + parser_agents.add_argument("--kill", default=False, action="store_true", help="Delete agent") + parser_agents.add_argument("--paw", default=None, help="PAW to delete. if not set it will delete all agents") parser_facts = subparsers.add_parser("facts", help="facts") parser_facts.set_defaults(func=list_facts) @@ -139,8 +252,66 @@ def create_parser(): parser_facts = subparsers.add_parser("add_facts", help="facts") parser_facts.set_defaults(func=add_facts) + # Sub parser for obfuscators + parser_obfuscators = subparsers.add_parser("obfuscators", help="obfuscators") + parser_obfuscators.set_defaults(func=obfuscators) + parser_obfuscators.add_argument("--list", default=False, action="store_true", + help="List all obfuscators") + + # Sub parser for objectives + parser_objectives = subparsers.add_parser("objectives", help="objectives") + parser_objectives.set_defaults(func=objectives) + parser_objectives.add_argument("--list", default=False, action="store_true", + help="List all objectives") + + # Sub parser for adversaries + parser_adversaries = subparsers.add_parser("adversaries", help="adversaries") + parser_adversaries.set_defaults(func=adversaries) + parser_adversaries.add_argument("--list", default=False, action="store_true", + help="List all adversaries") + parser_adversaries.add_argument("--add", default=False, action="store_true", + help="Add a new adversary") + parser_adversaries.add_argument("--ability_id", "--abid", default=None, help="Ability ID") + parser_adversaries.add_argument("--ability_name", default=None, help="Adversary name") + parser_adversaries.add_argument("--delete", default=False, action="store_true", + help="Delete adversary") + parser_adversaries.add_argument("--adversary_id", "--advid", default=None, help="Adversary ID") + + # Sub parser for operations + parser_operations = subparsers.add_parser("operations", help="operations") + parser_operations.set_defaults(func=operations) + parser_operations.add_argument("--list", default=False, action="store_true", + help="List all operations") + parser_operations.add_argument("--add", default=False, action="store_true", + help="Add a new operations") + parser_operations.add_argument("--delete", default=False, action="store_true", + help="Delete an operation") + parser_operations.add_argument("--view_report", default=False, action="store_true", + help="View the report of a finished operation") + parser_operations.add_argument("--name", default=None, help="Name of the operation") + parser_operations.add_argument("--adversary_id", "--advid", default=None, help="Adversary ID") + parser_operations.add_argument("--source_id", "--sourceid", default="basic", help="'Source' ID") + parser_operations.add_argument("--planner_id", "--planid", default="atomic", help="Planner ID") + parser_operations.add_argument("--group", default="", help="Caldera group to run the operation on (we are targeting groups, not PAWs)") + parser_operations.add_argument("--state", default="running", help="State to start the operation in") + parser_operations.add_argument("--obfuscator", default="plain-text", help="Obfuscator to use for this attack") + parser_operations.add_argument("--jitter", default="4/8", help="Jitter to use") + parser_operations.add_argument("--id", default=None, help="ID of operation to delete") + + # Sub parser for sources + parser_sources = subparsers.add_parser("sources", help="sources") + parser_sources.set_defaults(func=sources) + parser_sources.add_argument("--list", default=False, action="store_true", + help="List all sources") + + # Sub parser for planners + parser_sources = subparsers.add_parser("planners", help="planners") + parser_sources.set_defaults(func=planners) + parser_sources.add_argument("--list", default=False, action="store_true", + help="List all planners") + # For all parsers - main_parser.add_argument("--caldera_url", help="caldera url, including port", default="http://192.168.178.125:8888/") + main_parser.add_argument("--caldera_url", help="caldera url, including port", default="http://localhost:8888/") main_parser.add_argument("--apikey", help="caldera api key", default="ADMIN123") return main_parser @@ -153,7 +324,10 @@ if __name__ == "__main__": print(args.caldera_url) attack_logger = AttackLog(args.verbose) - caldera_control = CalderaControl(args.caldera_url, attack_logger, config=None, apikey=args.apikey) + caldera_control = CalderaAPI(args.caldera_url, attack_logger, config=None, apikey=args.apikey) print("Caldera Control ready") - - str(args.func(caldera_control, args)) + try: + str(args.func(caldera_control, args)) + except CmdlineArgumentException as ex: + parser.print_help() + print(f"\nCommandline error: {ex}") diff --git a/tests/test_calderacontrol.py b/tests/test_calderacontrol.py index 7c515ae..91dd59d 100644 --- a/tests/test_calderacontrol.py +++ b/tests/test_calderacontrol.py @@ -4,6 +4,7 @@ from app.calderacontrol import CalderaControl from simplejson.errors import JSONDecodeError from app.exceptions import CalderaError from app.attack_log import AttackLog +import pydantic # https://docs.python.org/3/library/unittest.html @@ -17,35 +18,14 @@ class TestExample(unittest.TestCase): def tearDown(self) -> None: pass - # List links sends the right commands and post - def test_list_links(self): - with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.list_links("asd") - mock_method.assert_called_once_with({"index": "link", "op_id": "asd"}) - - # List links gets an Exception and does not handle it (as expected) - def test_list_links_with_exception(self): - with self.assertRaises(JSONDecodeError): - with patch.object(self.cc, "__contact_server__", side_effect=JSONDecodeError("foo", "bar", 2)): - self.cc.list_links("asd") - - # list results sends the right commands and post - def test_list_results(self): - with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.list_results("asd") - mock_method.assert_called_once_with({"index": "result", "link_id": "asd"}) - - # List results gets an Exception and does not handle it (as expected) - def test_list_results_with_exception(self): - with self.assertRaises(JSONDecodeError): - with patch.object(self.cc, "__contact_server__", side_effect=JSONDecodeError("foo", "bar", 2)): - self.cc.list_results("asd") - # list_operations def test_list_operations(self): with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.list_operations() - mock_method.assert_called_once_with({"index": "operations"}) + try: + self.cc.list_operations() + except pydantic.error_wrappers.ValidationError: + pass + mock_method.assert_called_once_with(None, method='get', rest_path='api/v2/operations') # list operations gets the expected exception def test_list_operations_with_exception(self): @@ -56,8 +36,11 @@ class TestExample(unittest.TestCase): # list_abilities def test_list_abilities(self): with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.list_abilities() - mock_method.assert_called_once_with({"index": "abilities"}) + try: + self.cc.list_abilities() + except pydantic.error_wrappers.ValidationError: + pass + mock_method.assert_called_once_with(None, method='get', rest_path='api/v2/abilities') # list abilities gets the expected exception def test_list_abilities_with_exception(self): @@ -68,8 +51,11 @@ class TestExample(unittest.TestCase): # list_agents def test_list_agents(self): with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.list_agents() - mock_method.assert_called_once_with({"index": "agents"}) + try: + self.cc.list_agents() + except pydantic.error_wrappers.ValidationError: + pass + mock_method.assert_called_once_with(None, method='get', rest_path='api/v2/agents') # list agents gets the expected exception def test_list_agents_with_exception(self): @@ -80,8 +66,11 @@ class TestExample(unittest.TestCase): # list_adversaries def test_list_adversaries(self): with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.list_adversaries() - mock_method.assert_called_once_with({"index": "adversaries"}) + try: + self.cc.list_adversaries() + except pydantic.error_wrappers.ValidationError: + pass + mock_method.assert_called_once_with(None, method='get', rest_path='api/v2/adversaries') # list adversaries gets the expected exception def test_list_adversaries_with_exception(self): @@ -92,8 +81,11 @@ class TestExample(unittest.TestCase): # list_objectives def test_list_objectives(self): with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.list_objectives() - mock_method.assert_called_once_with({"index": "objectives"}) + try: + self.cc.list_objectives() + except pydantic.error_wrappers.ValidationError: + pass + mock_method.assert_called_once_with(None, method='get', rest_path='api/v2/objectives') # list objectives gets the expected exception def test_list_objectives_with_exception(self): @@ -161,27 +153,11 @@ class TestExample(unittest.TestCase): def test_get_operation_by_id(self): opid = "FooBar" with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.get_operation_by_id(opid) - mock_method.assert_called_once_with({"index": "operations", "id": opid}) - - # get_operation_by_id gets the expected exception - def test_get_operation_by_id_with_exception(self): - with self.assertRaises(JSONDecodeError): - with patch.object(self.cc, "__contact_server__", side_effect=JSONDecodeError("foo", "bar", 2)): - self.cc.get_result_by_id("FooBar") - - # get_result_by_id - def test_get_result_by_id(self): - opid = "FooBar" - with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.get_result_by_id(opid) - mock_method.assert_called_once_with({"index": "result", "link_id": opid}) - - # get_result_by_id gets the expected exception - def test_get_result_by_id_with_exception(self): - with self.assertRaises(JSONDecodeError): - with patch.object(self.cc, "__contact_server__", side_effect=JSONDecodeError("foo", "bar", 2)): - self.cc.get_result_by_id("FooBar") + try: + self.cc.get_operation_by_id(opid) + except pydantic.error_wrappers.ValidationError: + pass + mock_method.assert_called_once_with(None, method='get', rest_path='api/v2/operations') # get_linkid def test_get_linkid(self): @@ -218,7 +194,7 @@ class TestExample(unittest.TestCase): opid = "FooBar" with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: self.cc.view_operation_report(opid) - mock_method.assert_called_once_with({"index": "operation_report", "op_id": opid, "agent_output": 1}) + mock_method.assert_called_once_with({"enable_agent_output": True}, method="post", rest_path="api/v2/operations/FooBar/report") # get_result_by_id gets the expected exception def test_view_operation_report_with_exception(self): @@ -263,57 +239,32 @@ class TestExample(unittest.TestCase): group = "test_group" advid = "test_id" - exp1 = {"index": "sources", - "name": "source_test_name", - "rules": [], - "relationships": [], - "facts": [] - } - exp3 = {"index": "operations", - "name": name, - "state": state, - "autonomous": 1, - 'obfuscator': 'plain-text', - 'auto_close': '1', - 'jitter': '4/8', - 'source': 'source_test_name', - 'visibility': '50', - "group": group, - "planner": "atomic", - "adversary_id": advid, - } + exp1 = {'name': 'test_name', 'group': 'test_group', 'adversary': {'adversary_id': None}, 'auto_close': False, 'state': 'test_state', 'autonomous': 1, 'planner': {'id': 'atomic'}, 'source': {'id': 'basic'}, 'use_learning_parsers': True, 'obfuscator': 'plain-text', 'jitter': '4/8', 'visibility': '51'} + with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.add_operation(name, advid, group, state) - # mock_method.assert_called_once_with(exp, method="put") - mock_method.assert_has_calls([call(exp1, method="put"), call(exp3, method="put")]) + try: + self.cc.add_operation(name=name, + advid=advid, + group=group, + state=state) + except pydantic.error_wrappers.ValidationError: + pass + mock_method.assert_has_calls([call(exp1, method='post', rest_path='api/v2/operations')]) # add_operation defaults def test_add_operation_defaults(self): name = "test_name" advid = "test_id" - exp1 = {"index": "sources", - "name": "source_test_name", - "rules": [], - "relationships": [], - "facts": [] - } - exp3 = {"index": "operations", - "name": name, - "state": "running", # default - "autonomous": 1, - 'obfuscator': 'plain-text', - 'auto_close': '1', - 'jitter': '4/8', - 'source': 'source_test_name', - 'visibility': '50', - "group": "red", # default - "planner": "atomic", - "adversary_id": advid, - } + exp1 = {'name': 'test_name', 'group': '', 'adversary': {'adversary_id': None}, 'auto_close': False, 'state': 'running', 'autonomous': 1, 'planner': {'id': 'atomic'}, 'source': {'id': 'basic'}, 'use_learning_parsers': True, 'obfuscator': 'plain-text', 'jitter': '4/8', 'visibility': '51'} + with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.add_operation(name, advid) - mock_method.assert_has_calls([call(exp1, method="put"), call(exp3, method="put")]) + try: + self.cc.add_operation(name=name, + advid=advid) + except pydantic.error_wrappers.ValidationError: + pass + mock_method.assert_has_calls([call(exp1, method='post', rest_path='api/v2/operations')]) # add_adversary def test_add_adversary(self): @@ -321,109 +272,74 @@ class TestExample(unittest.TestCase): ability = "test_ability" description = "test_descritption" - exp = {"index": "adversaries", - "name": name, - "description": description, - "atomic_ordering": [{"id": ability}], - # - "objective": '495a9828-cab1-44dd-a0ca-66e58177d8cc' # default objective - } + # Caldera 4 + exp_4 = { + "name": name, + "description": description, + "atomic_ordering": ["test_ability"], + # + "objective": '495a9828-cab1-44dd-a0ca-66e58177d8cc' # default objective + } with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: self.cc.add_adversary(name, ability, description) - mock_method.assert_called_once_with(exp, method="put") + mock_method.assert_called_once_with(exp_4, method="post", rest_path="api/v2/adversaries") def test_add_adversary_default(self): name = "test_name" ability = "test_ability" - exp = {"index": "adversaries", - "name": name, - "description": "created automatically", - "atomic_ordering": [{"id": ability}], - # - "objective": '495a9828-cab1-44dd-a0ca-66e58177d8cc' # default objective - } + # Caldera 4 + exp_4 = { + "name": name, + "description": "created automatically", + "atomic_ordering": ["test_ability"], + # + "objective": '495a9828-cab1-44dd-a0ca-66e58177d8cc' # default objective + } with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: self.cc.add_adversary(name, ability) - mock_method.assert_called_once_with(exp, method="put") - - # execute_ability - def test_execute_ability(self): - paw = "test_paw" - ability_id = "test_ability" - obfuscator = "plain-text" - - exp = {"paw": paw, - "ability_id": ability_id, - "obfuscator": obfuscator, - "facts": []} - with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.execute_ability(paw, ability_id, obfuscator) - mock_method.assert_called_once_with(exp, rest_path="plugin/access/exploit_ex") + mock_method.assert_called_once_with(exp_4, method="post", rest_path="api/v2/adversaries") - def test_execute_ability_default(self): - paw = "test_paw" - ability_id = "test_ability" - - exp = {"paw": paw, - "ability_id": ability_id, - "obfuscator": "plain-text", - "facts": []} - with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.execute_ability(paw, ability_id) - mock_method.assert_called_once_with(exp, rest_path="plugin/access/exploit_ex") - - # execute_operation - def test_execute_operation(self): + # set_operation_state + def test_set_operation_state(self): operation_id = "test_opid" state = "paused" - exp = {"index": "operation", - "op_id": operation_id, - "state": state} with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.execute_operation(operation_id, state) - mock_method.assert_called_once_with(exp) + self.cc.set_operation_state(operation_id, state) + mock_method.assert_called_once_with({'state': 'paused'}, method='patch', rest_path='api/v2/operations/test_opid') # not supported state - def test_execute_operation_not_supported(self): + def test_set_operation_state_not_supported(self): operation_id = "test_opid" state = "not supported" with self.assertRaises(ValueError): with patch.object(self.cc, "__contact_server__", return_value=None): - self.cc.execute_operation(operation_id, state) + self.cc.set_operation_state(operation_id, state) - def test_execute_operation_default(self): + def test_set_operation_state_default(self): operation_id = "test_opid" - exp = {"index": "operation", - "op_id": operation_id, - "state": "running" # default - } with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: - self.cc.execute_operation(operation_id) - mock_method.assert_called_once_with(exp) + self.cc.set_operation_state(operation_id) + mock_method.assert_called_once_with({'state': 'running'}, method='patch', rest_path='api/v2/operations/test_opid') # delete_operation def test_delete_operation(self): opid = "test_opid" - exp = {"index": "operations", - "id": opid} with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: self.cc.delete_operation(opid) - mock_method.assert_called_once_with(exp, method="delete") + mock_method.assert_called_once_with({}, method="delete", rest_path="api/v2/operations/test_opid") # delete_adversary def test_delete_adversary(self): adid = "test_adid" - exp = {"index": "adversaries", - "adversary_id": [{"adversary_id": adid}]} with patch.object(self.cc, "__contact_server__", return_value=None) as mock_method: self.cc.delete_adversary(adid) - mock_method.assert_called_once_with(exp, method="delete") + mock_method.assert_called_once_with(None, method="delete", rest_path="api/v2/adversaries/test_adid") # is_operation_finished def test_is_operation_finished_true(self): @@ -442,14 +358,6 @@ class TestExample(unittest.TestCase): res = self.cc.is_operation_finished(opid) self.assertEqual(res, False) - def test_is_operation_finished_exception(self): - opdata = [{"chain": [{"statusa": 1}]}] - opid = "does not matter" - - with self.assertRaises(CalderaError): - with patch.object(self.cc, "get_operation_by_id", return_value=opdata): - self.cc.is_operation_finished(opid) - def test_is_operation_finished_exception2(self): opdata = [] opid = "does not matter"