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

Take 2: Splunkbase download updates courtext res260 #327

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c246fa7
Fix a bug where App_Base.getSplunkbasePath() was broken and did not w…
Res260 Oct 24, 2024
f7a0963
Fix a bug where the jinja template for `analyticsories_detection` cra…
Res260 Oct 24, 2024
ea34bb1
Do not serialize TestApp.hardcoded_path if it has value None. Fixes #…
Res260 Oct 24, 2024
d32a52d
Merge pull request #1 from Res260/issue319
Res260 Oct 24, 2024
083adfc
Merge pull request #2 from Res260/issue313
Res260 Oct 24, 2024
62f81f1
Merged PR 112820: add azure-pipelines.yml
Oct 24, 2024
792f3cc
Merged PR 112823: fix
Oct 24, 2024
8bf35a8
Merged PR 112825: fix azure-pipelines.yml
Oct 24, 2024
438d00f
Merged PR 112827: fix build
Oct 24, 2024
8bb94c6
Merged PR 112831: fix build
Oct 24, 2024
801930d
Merged PR 112907: fix build
Oct 25, 2024
b6eadbf
Merged PR 112936: fix build :)
Oct 25, 2024
f74ae37
Merged PR 114299: Allow test.splunk_api_username and test.splunk_api_…
Nov 4, 2024
0772aa0
Merge pull request #320 from Res260/issue319
pyth0n1c Nov 4, 2024
855a761
Merge pull request #317 from Res260/issue295
pyth0n1c Nov 4, 2024
e330115
Manually port changed raised by
pyth0n1c Nov 4, 2024
5bcfb6c
Merged PR 114606: Fix du setup initial + téléchargement depuis splunk…
Nov 6, 2024
e658e7d
Merged PR 114699: fix typo
Nov 6, 2024
ccd62b6
Merged PR 114746: fix tests maybe
Nov 6, 2024
929a2e3
Merged PR 114823: Add support for dynamically creating custom_index f…
Nov 6, 2024
9b3dcc8
Merged PR 114830: fix
Nov 6, 2024
344f771
Merged PR 114831: fix
Nov 6, 2024
a789726
Merged PR 114975: fix
Nov 7, 2024
8207247
Merged PR 115051: Fix validation for apps
Nov 7, 2024
967a1f1
- Bugfix: bad stack trace when a test search fails
Res260 Nov 14, 2024
fc0e4a6
merge with origin/main
Res260 Nov 14, 2024
78b77c1
merge with main
Res260 Nov 14, 2024
02685ee
Merge branch 'main' into splunkbase_download_updates_courtext_res260
Res260 Nov 21, 2024
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
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import time
import uuid
import abc
Expand All @@ -17,12 +18,13 @@
import requests # type: ignore
import splunklib.client as client # type: ignore
from splunklib.binding import HTTPError # type: ignore
from splunklib.client import Service
from splunklib.results import JSONResultsReader, Message # type: ignore
import splunklib.results
from urllib3 import disable_warnings
import urllib.parse

from contentctl.objects.config import test_common, Infrastructure
from contentctl.objects.config import test_common, Infrastructure, ENTERPRISE_SECURITY_UID
from contentctl.objects.enums import PostTestBehavior, AnalyticsType
from contentctl.objects.detection import Detection
from contentctl.objects.base_test import BaseTest
Expand All @@ -42,6 +44,8 @@
TestingStates
)

LOG = Utils.get_logger()


class SetupTestGroupResults(BaseModel):
exception: Union[Exception, None] = None
Expand Down Expand Up @@ -107,6 +111,7 @@ class DetectionTestingInfrastructure(BaseModel, abc.ABC):

def __init__(self, **data):
super().__init__(**data)
self._conn: None | Service = None

# TODO: why not use @abstractmethod
def start(self):
Expand Down Expand Up @@ -138,7 +143,8 @@ def setup(self):
try:
for func, msg in [
(self.start, "Starting"),
(self.get_conn, "Waiting for App Installation"),
(self.get_conn, "Getting initial connection"),
(self.wait_for_app_installation, "Waiting for App Installation"),
(self.configure_conf_file_datamodels, "Configuring Datamodels"),
(self.create_replay_index, f"Create index '{self.sync_obj.replay_index}'"),
(self.get_all_indexes, "Getting all indexes from server"),
Expand Down Expand Up @@ -170,14 +176,15 @@ def wait_for_ui_ready(self):
def configure_hec(self):
self.hec_channel = str(uuid.uuid4())
try:
res = self.get_conn().input(
path="/servicesNS/nobody/splunk_httpinput/data/inputs/http/http:%2F%2FDETECTION_TESTING_HEC"
)
self.hec_token = str(res.token)
return
except Exception:
# HEC input does not exist. That's okay, we will create it
# Delete old HEC
self.get_conn().inputs.delete("DETECTION_TESTING_HEC", kind='http')
except (HTTPError, KeyError) as e:
# HEC input didn't exist in the first place, everything is good.
pass
except Exception as e:
LOG.error("Error when deleting input DETECTION_TESTING_HEC.")
LOG.exception(e)
raise e

try:
res = self.get_conn().inputs.create(
Expand Down Expand Up @@ -210,6 +217,31 @@ def get_all_indexes(self) -> None:
except Exception as e:
raise (Exception(f"Failure getting indexes: {str(e)}"))

def wait_for_app_installation(self):
config_apps = self.global_config.apps
installed_config_apps = []
while len(installed_config_apps) < len(config_apps):
try:
# Get apps installed in the Splunk instance
splunk_instance_apps = self.get_conn().apps.list()

# Try to find all the apps we want to be installed (config_apps)
installed_config_apps = []
for config_app in config_apps:
for splunk_instance_app in splunk_instance_apps:
if config_app.appid == splunk_instance_app.name:
# For Enterprise Security, we need to make sure the app is also configured.
if config_app.uid == ENTERPRISE_SECURITY_UID and splunk_instance_app.content.get('configured') != '1':
continue
installed_config_apps.append(config_app.appid)
LOG.debug("Apps in the Splunk instance: " + str(list(map(lambda x: x.name, splunk_instance_apps))))
LOG.debug(f"apps in contentctl package found in Splunk instance: {installed_config_apps}")
if len(installed_config_apps) >= len(config_apps):
break
except Exception as e:
LOG.exception(e)
time.sleep(5)

def get_conn(self) -> client.Service:
try:
if not self._conn:
Expand All @@ -218,8 +250,9 @@ def get_conn(self) -> client.Service:
# continue trying to re-establish a connection until after
# the server has restarted
self.connect_to_api()
except Exception:
except Exception as e:
# there was some issue getting the connection. Try again just once
LOG.exception(e)
self.connect_to_api()
return self._conn

Expand Down Expand Up @@ -295,7 +328,7 @@ def configure_imported_roles(
):
try:
# Set which roles should be configured. For Enterprise Security/Integration Testing,
# we must add some extra foles.
# we must add some extra roles.
if self.global_config.enable_integration_testing:
roles = imported_roles + enterprise_security_roles
else:
Expand Down Expand Up @@ -1100,7 +1133,7 @@ def retry_search_until_timeout(
threat_object_fields_set = set([o.name for o in detection.tags.observable if "Attacker" in o.role]) # just the "threat objects"

# Ensure the search had at least one result
if int(job.content.get("resultCount", "0")) > 0:
if int(job.content["resultCount"]) > 0:
# Initialize the test result
test.result = UnitTestResult()

Expand Down Expand Up @@ -1203,6 +1236,7 @@ def retry_search_until_timeout(
self.infrastructure,
TestResultStatus.FAIL,
duration=time.time() - search_start_time,
message=f"Search had 0 result. {job.content}"
)
tick += 1

Expand Down Expand Up @@ -1247,16 +1281,16 @@ def replay_attack_data_file(
test_group_start_time: float,
):
# Before attempting to replay the file, ensure that the index we want
# to replay into actuall exists. If not, we should throw a detailed
# exception that can easily be interpreted by the user.
# to replay into actually exists. If not, we create the index.
if attack_data_file.custom_index is not None and \
attack_data_file.custom_index not in self.all_indexes_on_server:
raise ReplayIndexDoesNotExistOnServer(
f"Unable to replay data file {attack_data_file.data} "
f"into index '{attack_data_file.custom_index}'. "
"The index does not exist on the Splunk Server. "
f"The only valid indexes on the server are {self.all_indexes_on_server}"
)
index = self.get_conn().indexes.create(name=attack_data_file.custom_index)
LOG.info(f"Created Index {attack_data_file.custom_index}: {index}")
LOG.info("Re-retup of the HEC and roles and indexes...")
self.get_all_indexes()
self.configure_imported_roles()
self.configure_delete_indexes()
self.configure_hec()

tempfile = mktemp(dir=tmp_dir)
if not (str(attack_data_file.data).startswith("http://") or
Expand Down Expand Up @@ -1358,7 +1392,7 @@ def hec_raw_replay(
url_with_hec_path = urllib.parse.urljoin(
url_with_port, "services/collector/raw"
)
with open(tempfile, "rb") as datafile:
with open(tempfile, "r") as datafile:
try:
res = requests.post(
url_with_hec_path,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import time

from contentctl.actions.detection_testing.infrastructures.DetectionTestingInfrastructure import (
DetectionTestingInfrastructure,
)
Expand Down Expand Up @@ -25,6 +27,10 @@ def start(self):

self.container = self.make_container()
self.container.start()
# There might be a small delay between the starting of the container and the binding of the ports for splunk.
# To avoid a "connection refused" error, wait a little bit before finishing the method call.
# This won't change setup time, because the container is already started.
time.sleep(20)

def finish(self):
if self.container is not None:
Expand Down
7 changes: 4 additions & 3 deletions contentctl/contentctl.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import tyro

from contentctl.actions.initialize import Initialize
from contentctl.helper.utils import Utils
from contentctl.objects.config import init, validate, build, new, deploy_acs, test, test_servers, inspect, report, test_common, release_notes
from contentctl.actions.validate import Validate
from contentctl.actions.new_content import NewContent
Expand Down Expand Up @@ -52,8 +53,7 @@
# """
# )



LOG = Utils.get_logger()

def init_func(config:test):
Initialize().execute(config)
Expand Down Expand Up @@ -157,7 +157,8 @@ def main():
config_obj = YmlReader().load_file(configFile)
t = test.model_validate(config_obj)
except Exception as e:
print(f"Error validating 'contentctl.yml':\n{str(e)}")
LOG.error(f"Error validating 'contentctl.yml':\n{str(e)}")
LOG.exception(e)
sys.exit(1)


Expand Down
12 changes: 12 additions & 0 deletions contentctl/helper/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os
import git
import shutil
Expand All @@ -19,6 +20,13 @@

TOTAL_BYTES = 0
ALWAYS_PULL = True
LOG = logging.getLogger("main")
LOG.setLevel(logging.INFO)
handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - [%(levelname)-8s] %(message)s')
handler.setFormatter(formatter)
LOG.addHandler(handler)


class Utils:
Expand Down Expand Up @@ -485,3 +493,7 @@ def getPercent(numerator: float, denominator: float, decimal_places: int) -> str
ratio = numerator / denominator
percent = ratio * 100
return Utils.getFixedWidth(percent, decimal_places) + "%"

@staticmethod
def get_logger() -> logging.Logger:
return LOG
25 changes: 19 additions & 6 deletions contentctl/objects/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
ENTERPRISE_SECURITY_UID = 263
COMMON_INFORMATION_MODEL_UID = 1621

SPLUNKBASE_URL = "https://splunkbase.splunk.com/app/{uid}/release/{version}/download"
SPLUNKBASE_BASE_URL = "https://splunkbase.splunk.com"
SPLUNKBASE_URL = SPLUNKBASE_BASE_URL + "/app/{uid}/release/{version}/download"


# TODO (#266): disable the use_enum_values configuration
Expand All @@ -47,7 +48,7 @@ class App_Base(BaseModel,ABC):


def getSplunkbasePath(self)->HttpUrl:
return HttpUrl(SPLUNKBASE_URL.format(uid=self.uid, release=self.version))
return HttpUrl(SPLUNKBASE_URL.format(uid=self.uid, version=self.version))

@abstractmethod
def getApp(self, config:test, stage_file:bool=False)->str:
Expand All @@ -65,7 +66,7 @@ class TestApp(App_Base):
hardcoded_path: Optional[Union[FilePath,HttpUrl]] = Field(default=None, description="This may be a relative or absolute link to a file OR an HTTP URL linking to your app.")


@field_serializer('hardcoded_path',when_used='always')
@field_serializer('hardcoded_path',when_used='unless-none')
def serialize_path(path: Union[AnyUrl, pathlib.Path])->str:
return str(path)

Expand Down Expand Up @@ -93,6 +94,10 @@ def getApp(self, config:test,stage_file:bool=False)->str:
destination = config.getLocalAppDir() / server_path.name
if stage_file:
Utils.download_file_from_http(file_url_string, str(destination))
# Needed for `contentctl validate` and `contentctl build` else it fails without the splunkbase creds,
# which shouldn't be mandatory for validation or building the app.
elif self.version is not None and self.uid is not None:
destination = self.getSplunkbasePath()
else:
raise Exception(f"Unknown path for app '{self.title}'")

Expand Down Expand Up @@ -831,10 +836,18 @@ class test(test_common):
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
container_settings:ContainerSettings = ContainerSettings()
test_instances: List[Container] = Field([], exclude = True, validate_default=True)
splunk_api_username: Optional[str] = Field(default=None, exclude = True,description="Splunk API username used for running appinspect or installating apps from Splunkbase")
splunk_api_password: Optional[str] = Field(default=None, exclude = True, description="Splunk API password used for running appinspect or installaing apps from Splunkbase")


splunk_api_username: None | str = Field(default=None, exclude = True,description="Splunk API username used for running appinspect or installating apps from Splunkbase. Can be replaced by the 'SPLUNKBASE_USERNAME' environment variable.")
splunk_api_password: None | str = Field(default=None, exclude = True, description="Splunk API password used for running appinspect or installaing apps from Splunkbase. Can be replaced by the 'SPLUNKBASE_PASSWORD' environment variable.")

def __init__(self, **kwargs: Any):
if "SPLUNKBASE_USERNAME" in environ:
kwargs['splunk_api_username'] = environ["SPLUNKBASE_USERNAME"]
if "SPLUNKBASE_PASSWORD" in environ:
kwargs['splunk_api_password'] = environ["SPLUNKBASE_PASSWORD"]
super().__init__(**kwargs)

def getContainerInfrastructureObjects(self)->Self:
try:
self.test_instances = self.container_settings.getContainers()
Expand Down Expand Up @@ -879,7 +892,7 @@ def getContainerEnvironmentString(self,stage_file:bool=False, include_custom_app

container_paths = []
for path in paths:
if path.startswith(SPLUNKBASE_URL):
if path.startswith(SPLUNKBASE_BASE_URL):
container_paths.append(path)
else:
container_paths.append((self.getContainerAppDir()/pathlib.Path(path).name).as_posix())
Expand Down
7 changes: 6 additions & 1 deletion contentctl/objects/unit_test_result.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from typing import Union,TYPE_CHECKING
from typing import Union, TYPE_CHECKING, Optional
from splunklib.data import Record
from contentctl.objects.base_test_result import BaseTestResult, TestResultStatus

Expand All @@ -23,6 +23,7 @@ def set_job_content(
status: TestResultStatus,
exception: Union[Exception, None] = None,
duration: float = 0,
message: Optional[str] = None
) -> bool:
"""
Sets various fields in the result, pulling some fields from the provided search job's
Expand Down Expand Up @@ -75,4 +76,8 @@ def set_job_content(
self.message = f"ERROR with no more specific message available."
self.sid_link = NO_SID

if message:
# Override the message
self.message = message

return self.success