Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Environment and Context authentication support, default example profiles and global example settings #222

Merged
merged 7 commits into from
Nov 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions caracara/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class FalconFilter(FQLGenerator):
This subclass allows pre-existing code to continue referencing FalconFilter.
"""

def __init__( # pylint: disable=R0913,R0914,R0915
def __init__( # pylint: disable=R0913,R0914,R0915,R0917
self,
client_id: str = None,
client_secret: str = None,
Expand All @@ -68,12 +68,6 @@ def __init__( # pylint: disable=R0913,R0914,R0915
"""Configure a Caracara Falcon API Client object."""
self.logger = logging.getLogger(__name__)

if client_id is None and client_secret is None and falconpy_authobject is None:
raise ValueError(
"You must provide either a Client ID and Client Secret, "
"or a pre-created FalconPy OAuth2 object"
)

if client_id is not None and client_secret is None:
raise ValueError("You cannot provide a Client ID without a Client Secret")

Expand All @@ -85,7 +79,13 @@ def __init__( # pylint: disable=R0913,R0914,R0915

self.logger.info("Setting up the Caracara client and configuring authentication")

if client_id:
if falconpy_authobject:
self.logger.info(
"Using pre-created FalconPy OAuth2 object. All other options will be ignored"
)
self.api_authentication = falconpy_authobject

else:
# Load all parameters for the FalconPy authentication object into a dictionary
# and handle environment variable interpolation
interpolator = VariableInterpolator()
Expand Down Expand Up @@ -141,13 +141,6 @@ def __init__( # pylint: disable=R0913,R0914,R0915

self.logger.debug("Configuring api_authentication object as an OAuth2 object")
self.api_authentication = OAuth2(**auth_keys)
elif falconpy_authobject:
self.logger.info(
"Using pre-created FalconPy OAuth2 object. All other options will be ignored"
)
self.api_authentication = falconpy_authobject
else:
raise TypeError("Impossible authentication scenario")

self.logger.info("Requesting API token")
self.api_authentication.token() # Need to force the authentication to resolve the base_url
Expand Down
8 changes: 1 addition & 7 deletions caracara/modules/rtr/get_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,8 @@ def download(
mode="r",
password="infected",
) as archive:
inner_filename = archive.getnames()[0]
target_dir = os.path.dirname(full_output_path_7z)

archive.extract(target_dir, inner_filename)
os.rename(
os.path.join(target_dir, inner_filename),
full_output_path,
)
archive.extract(path=target_dir)

if not preserve_7z:
os.unlink(full_output_path_7z)
Expand Down
74 changes: 44 additions & 30 deletions examples/common/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from caracara import Client
from caracara.common.csdialog import csradiolist_dialog
from caracara.common.interpolation import VariableInterpolator

_config_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
Expand All @@ -28,13 +29,10 @@ def _select_profile(config: dict) -> str:
if falcon is None:
print(f"{profile_name} does not have a falcon stanza; skipping")
continue

client_id = falcon.get("client_id")
if client_id is None:
print(f"The falcon stanza in {profile_name} does not contain a client ID; skipping")
continue

client_id = str(client_id)
cln = VariableInterpolator()
client_id = cln.interpolate(
str(profiles[profile_name]["falcon"].get("client_id", "ENVIRONMENT"))
)
profile_text = f"{profile_name} (Client ID: {client_id[0:7]}{'x'*24})"
profile_pairs.append((profile_name, profile_text))

Expand Down Expand Up @@ -64,17 +62,34 @@ def _get_profile() -> Dict:
raise KeyError("You must create a profiles stanza in the configuration YAML file")

profile_names = list(config["profiles"].keys())
global_config = None
if "globals" in config:
global_config = config["globals"]
# Check for default profile
default_name = None
for prof in profile_names:
if config["profiles"][prof].get("default", False):
default_name = prof
# First one we encounter is the default
break

# Check to see if the user provided us with a profile name as the first argument
profile_name = None
if len(sys.argv) > 1:
profile_name = sys.argv[1]
if profile_name not in profile_names:
raise KeyError(f"The profile named {profile_name} does not exist in config.yml")
elif len(profile_names) == 1:
# There's only one, treat it as the default
profile_name = profile_names[0]
elif default_name:
# Default profile key has been set
profile_name = default_name
else:
profile_name = _select_profile(config)

profile = config["profiles"][profile_name]
return profile
return profile, global_config


def _configure_logging(profile: Dict) -> None:
Expand All @@ -97,11 +112,8 @@ def _configure_logging(profile: Dict) -> None:
logging.basicConfig(format=log_format, level=log_level)


def _get_example_settings(profile: Dict, example_abs_path: str) -> Dict:
def _get_example_settings(profile: Dict, example_abs_path: str, globalsettings: Dict) -> Dict:
"""Load example-specific settings from config.yml based on the filename."""
if "examples" not in profile:
return None

# Get the base path of the examples module by obtaining the common
# path between this file and the example in question
common_path = os.path.commonpath([__file__, example_abs_path])
Expand All @@ -116,32 +128,36 @@ def _get_example_settings(profile: Dict, example_abs_path: str) -> Dict:
# Remove the file extension so that we get just the example name, without the .py
example_rel_path, _ = os.path.splitext(example_rel_path)

# Split the resultant path into a list of sectiions
# Split the resultant path into a list of sections
example_dict_path = example_rel_path.split(os.path.sep)

"""
Iterate over every part of the path to ensure that the example-specific data
exists within the config.yml. We would rather return None here than throw an
exception. It is up to each individual module to check whether the settings are
complete.
"""
example_settings = profile["examples"]
for path_section in example_dict_path:
if path_section not in example_settings:
return None
# Set our example module and name
example_module = example_dict_path[0]
example_name = example_dict_path[1]

# Get global settings for this example
global_settings = globalsettings.get("examples", {})
global_module_settings = global_settings.get(example_module, {})
global_example_settings = global_module_settings.get(example_name, {})

example_settings = example_settings[path_section]
# Get profile settings for this example
profile_module_settings = profile.get("examples", {}).get(example_module, {})
profile_example_settings = profile_module_settings.get(example_name, {})

# Returns the settings relative to this particular example
return example_settings
# Overlay global example settings with profile-specific example settings
# The dicts are expanded in order, so the profile-specific settings will overlay the global ones
merged_example_settings = {**global_example_settings, **profile_example_settings}

# Return back the merged settings dictionary
return merged_example_settings


def caracara_example(example_func):
"""Caracara Example Decorator."""

@wraps(example_func)
def wrapped(*args, **kwargs):
profile = _get_profile()
profile, global_config = _get_profile()
if not profile:
raise TypeError("No profile chosen; aborting")

Expand All @@ -153,16 +169,14 @@ def wrapped(*args, **kwargs):

falcon_config: Dict = profile["falcon"]

if "client_id" not in falcon_config or "client_secret" not in falcon_config:
raise KeyError("You must include, at minimum, a client_id and client_secret")

_configure_logging(profile)

client = Client(**falcon_config)

example_settings = _get_example_settings(
profile,
example_func.__globals__["__file__"],
global_config,
)

# Pass data back to the example via keyword arguments
Expand Down
26 changes: 26 additions & 0 deletions examples/config.example.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
profiles:
TestProfile1Auto:
falcon:
# Not required if FALCON_CLIENT_ID and FALCON_CLIENT_SECRET
# are present within the current running environment
client_id: clientid123456
client_secret: clientsecret123456
# Not required for US-1, US-2 or EU-1 users
cloud_name: auto
# Enable FalconPy native debugging by setting to True
debug: False
Expand All @@ -13,6 +16,7 @@ profiles:
logging:
level: debug
examples:
# Profile specific example settings
rtr:
download_event_log:
# How long to wait between attempts for the file to upload to Falcon
Expand Down Expand Up @@ -43,3 +47,25 @@ profiles:
client_id: ${CLIENT_ID_EU_ENV_VARIABLE}
client_secret: ${CLIENT_SECRET_EU_ENV_VARIABLE}
cloud_name: auto

globals:
examples:
# Global example settings
rtr:
download_event_log:
# How long to wait between attempts for the file to upload to Falcon
attempt_delay: 30
# Maximum number of attempts to retrieve files from Falcon
attempt_limit: 10
# Log filename (must exist in C:\Windows\System32\winevt\Logs\)
filename: System.evtx
# Which machines to upload logs from
filters:
- OS: Windows
# Output folder on disk to download the logs to
output_folder: /tmp/logs
queue_command:
filters:
- OS: Linux
- Hostname: example-host-2
command: ls
2 changes: 1 addition & 1 deletion examples/rtr/download_event_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def download_event_log(**kwargs): # pylint: disable=too-many-locals

logger.info("Downloading the event log %s", filename)

filters = client.FalconFilter(dialect="rtr")
filters = client.FalconFilter(dialect="hosts")
filter_list: List[Dict] = settings.get("filters")

# This is a custom generic function to load filters from the config file. You can
Expand Down
6 changes: 4 additions & 2 deletions examples/rtr/queue_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def queue_command(**kwargs):
if not settings or "command" not in settings:
error_message = "".join(
[
"You must configure the 'cmd' argument within your "
"You must configure the 'command' argument within your "
"YAML file to proceed with this example."
]
)
Expand All @@ -49,7 +49,7 @@ def queue_command(**kwargs):
cmd: str = settings["command"]
logger.info("Running the command: %s", cmd)

filters = client.FalconFilter(dialect="rtr")
filters = client.FalconFilter(dialect="hosts")
filter_list: List[Dict] = settings.get("filters")
parse_filter_list(filter_list, filters)

Expand All @@ -76,6 +76,8 @@ def queue_command(**kwargs):

for device_id, device_result in batch_session.run_generic_command(cmd).items():
logger.info("[%s] Task queued: %s", device_id, device_result["task_id"])
if device_result.get("complete"):
print(device_result.get("stdout", device_result.get("stderr", "No output received")))


if __name__ in ["__main__", "examples.rtr.queue_command"]:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ disable = [
# (from Falcon) by definition. Therefore, we have switched this rule off globally.
"too-many-public-methods",
]
max-positional-arguments=10


[tool.poetry.scripts]
Expand Down