diff --git a/.github/workflows/testEndToEnd.yml b/.github/workflows/testEndToEnd.yml index 6c780d12..2c32984e 100644 --- a/.github/workflows/testEndToEnd.yml +++ b/.github/workflows/testEndToEnd.yml @@ -36,12 +36,6 @@ jobs: with: python-version: ${{ matrix.python_version }} architecture: "x64" - - - name: Install Dependencies to Resolve Poetry Bug - run: - # Uses a fixed version of virtualenv due to the issue documented here: - # https://github.com/python-poetry/poetry/issues/7611#issuecomment-1640599902 - sudo apt install g++ musl musl-dev linux-musl-dev python3-dev - name: Install Poetry run: diff --git a/contentctl/actions/detection_testing/DetectionTestingManager.py b/contentctl/actions/detection_testing/DetectionTestingManager.py index e935379c..f4614068 100644 --- a/contentctl/actions/detection_testing/DetectionTestingManager.py +++ b/contentctl/actions/detection_testing/DetectionTestingManager.py @@ -89,11 +89,11 @@ def sigint_handler(signum, frame): signal.signal(signal.SIGINT, sigint_handler) with concurrent.futures.ThreadPoolExecutor( - max_workers=self.input_dto.config.num_containers, + max_workers=len(self.input_dto.config.infrastructure_config.infrastructures), ) as instance_pool, concurrent.futures.ThreadPoolExecutor( max_workers=len(self.input_dto.views) ) as view_runner, concurrent.futures.ThreadPoolExecutor( - max_workers=self.input_dto.config.num_containers, + max_workers=len(self.input_dto.config.infrastructure_config.infrastructures), ) as view_shutdowner: # Start all the views @@ -151,39 +151,37 @@ def sigint_handler(signum, frame): def create_DetectionTestingInfrastructureObjects(self): import sys - for index in range(self.input_dto.config.num_containers): - instanceConfig = deepcopy(self.input_dto.config) - instanceConfig.api_port += index * 2 - instanceConfig.hec_port += index * 2 - instanceConfig.web_ui_port += index - - instanceConfig.container_name = instanceConfig.container_name % (index,) + for infrastructure in self.input_dto.config.infrastructure_config.infrastructures: + # instanceConfig = deepcopy(self.input_dto.config) + # instanceConfig.api_port += index * 2 + # instanceConfig.hec_port += index * 2 + # instanceConfig.web_ui_port += index if ( - self.input_dto.config.target_infrastructure + self.input_dto.config.infrastructure_config.infrastructure_type == DetectionTestingTargetInfrastructure.container ): self.detectionTestingInfrastructureObjects.append( DetectionTestingInfrastructureContainer( - config=instanceConfig, sync_obj=self.output_dto + global_config=self.input_dto.config, infrastructure=infrastructure, sync_obj=self.output_dto ) ) elif ( - self.input_dto.config.target_infrastructure + self.input_dto.config.infrastructure_config.infrastructure_type == DetectionTestingTargetInfrastructure.server ): self.detectionTestingInfrastructureObjects.append( DetectionTestingInfrastructureServer( - config=instanceConfig, sync_obj=self.output_dto + global_config=self.input_dto.config, infrastructure=infrastructure, sync_obj=self.output_dto ) ) else: print( - f"Unsupported target infrastructure '{self.input_dto.config.target_infrastructure}'" + f"Unsupported target infrastructure '{self.input_dto.config.infrastructure_config.infrastructure_type}'" ) sys.exit(1) diff --git a/contentctl/actions/detection_testing/GitHubService.py b/contentctl/actions/detection_testing/GitHubService.py index 886a3e05..f503c927 100644 --- a/contentctl/actions/detection_testing/GitHubService.py +++ b/contentctl/actions/detection_testing/GitHubService.py @@ -137,15 +137,63 @@ def get_detections_changed(self, director: DirectorOutputDto) -> list[Detection] f"Error: self.repo must be initialized before getting changed detections." ) ) - raise (Exception("not implemented")) - return [] + + differences = self.repo.git.diff("--name-status", self.config.version_control_config.main_branch).split("\n") + new_content = [] + modified_content = [] + deleted_content = [] + for difference in differences: + mode, filename = difference.split("\t") + if mode == "A": + new_content.append(filename) + elif mode == "M": + modified_content.append(filename) + elif mode == "D": + deleted_content.append(filename) + else: + raise Exception(f"Unknown mode in determining differences: {difference}") + + #Changes to detections, macros, and lookups should trigger a re-test for anything which uses them + changed_lookups_list = list(filter(lambda x: x.startswith("lookups"), new_content+modified_content)) + changed_lookups = set() + + #We must account for changes to the lookup yml AND for the underlying csv + for lookup in changed_lookups_list: + if lookup.endswith(".csv"): + lookup = lookup.replace(".csv", ".yml") + changed_lookups.add(lookup) + + # At some point we should account for macros which contain other macros... + changed_macros = set(filter(lambda x: x.startswith("macros"), new_content+modified_content)) + changed_macros_and_lookups = set([str(pathlib.Path(filename).absolute()) for filename in changed_lookups.union(changed_macros)]) + + changed_detections = set(filter(lambda x: x.startswith("detections"), new_content+modified_content)) + + #Check and see if content that has been modified uses any of the changed macros or lookups + for detection in director.detections: + deps = set([content.file_path for content in detection.get_content_dependencies()]) + if not deps.isdisjoint(changed_macros_and_lookups): + changed_detections.add(detection.file_path) + + return Detection.get_detections_from_filenames(changed_detections, director.detections) def __init__(self, config: TestConfig): - self.repo = None + self.requested_detections: list[pathlib.Path] = [] self.config = config - - if config.mode == DetectionTestingMode.selected: + if config.version_control_config is not None: + self.repo = git.Repo(config.version_control_config.repo_path) + else: + self.repo = None + + + if config.mode == DetectionTestingMode.changes: + if self.repo is None: + raise Exception("You are using detection mode 'changes', but the app does not have a version_control_config in contentctl_test.yml.") + return + elif config.mode == DetectionTestingMode.all: + return + elif config.mode == DetectionTestingMode.selected: if config.detections_list is None or len(config.detections_list) < 1: raise ( Exception( @@ -171,63 +219,12 @@ def __init__(self, config: TestConfig): pathlib.Path(detection_file_name) for detection_file_name in config.detections_list ] - return - - elif config.mode == DetectionTestingMode.changes: - # Changes is ONLY possible if the app is version controlled - # in a github repo. Ensure that this is the case and, if not - # raise an exception - raise (Exception("Mode [changes] is not yet supported.")) - try: - repo = git.Repo(config.repo_path) - except Exception as e: - raise ( - Exception( - f"Error: detection mode [{config.mode}] REQUIRES that [{config.repo_path}] is a git repository, but it is not." - ) - ) - if config.main_branch == config.test_branch: - raise ( - Exception( - f"Error: test_branch [{config.test_branch}] is the same as the main_branch [{config.main_branch}]. When using mode [{config.mode}], these two branches MUST be different." - ) - ) - - # Ensure that the test branch is checked out - if self.repo.active_branch.name != config.test_branch: - raise ( - Exception( - f"Error: detection mode [{config.mode}] REQUIRES that the test_branch [{config.test_branch}] be checked out at the beginning of the test, but it is not." - ) - ) - - # Ensure that the base branch exists - - if Utils.validate_git_branch_name( - config.repo_path, "NO_URL", config.main_branch - ): - return - - elif config.mode == DetectionTestingMode.all: - return + else: - raise ( - Exception( - f"Unsupported detection testing mode [{config.mode}]. Supported detection testing modes are [{DetectionTestingMode._member_names_}]" - ) - ) - - def __init2__(self, config: TestConfig): - - self.repo = git.Repo(config.repo_path) - - if self.repo.active_branch.name != config.test_branch: - print( - f"Error - test_branch is '{config.test_branch}', but the current active branch in '{config.repo_path}' is '{self.repo.active_branch}'. Checking out the branch you specified..." - ) - self.repo.git.checkout(config.test_branch) - - self.config = config + raise Exception(f"Unsupported detection testing mode [{config.mode}]. "\ + "Supported detection testing modes are [{DetectionTestingMode._member_names_}]") + return + def clone_project(self, url, project, branch): LOGGER.info(f"Clone Security Content Project") @@ -352,29 +349,29 @@ def get_all_modified_content( # Because we have not passed -all as a kwarg, we will have a MAX of one commit returned: # https://gitpython.readthedocs.io/en/stable/reference.html?highlight=merge_base#git.repo.base.Repo.merge_base base_commits = self.repo.merge_base( - self.config.main_branch, self.config.test_branch + self.config.version_control_config.main_branch, self.config.version_control_config.test_branch ) if len(base_commits) == 0: raise ( Exception( - f"Error, main branch '{self.config.main_branch}' and test branch '{self.config.test_branch}' do not share a common ancestor" + f"Error, main branch '{self.config.version_control_config.main_branch}' and test branch '{self.config.version_control_config.test_branch}' do not share a common ancestor" ) ) base_commit = base_commits[0] if base_commit is None: raise ( Exception( - f"Error, main branch '{self.config.main_branch}' and test branch '{self.config.test_branch}' common ancestor commit was 'None'" + f"Error, main branch '{self.config.version_control_config.main_branch}' and test branch '{self.config.version_control_config.test_branch}' common ancestor commit was 'None'" ) ) all_changes = base_commit.diff( - self.config.test_branch, paths=[str(path) for path in paths] + self.config.version_control_config.test_branch, paths=[str(path) for path in paths] ) # distill changed files down to the paths of added or modified files all_changes_paths = [ - os.path.join(self.config.repo_path, change.b_path) + os.path.join(self.config.version_control_config.repo_path, change.b_path) for change in all_changes if change.change_type in ["M", "A"] ] diff --git a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py index e3c60ae6..bb85f3a4 100644 --- a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +++ b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py @@ -8,9 +8,10 @@ from contentctl.objects.unit_test_test import UnitTestTest from contentctl.objects.unit_test_attack_data import UnitTestAttackData from contentctl.objects.unit_test_result import UnitTestResult -from contentctl.objects.test_config import TestConfig +from contentctl.objects.test_config import TestConfig, Infrastructure from shutil import copyfile from splunklib.binding import HTTPError +from splunklib.results import JSONResultsReader, Message import os.path import configparser from ssl import SSLEOFError, SSLZeroReturnError @@ -33,6 +34,7 @@ import tqdm + MAX_TEST_NAME_LENGTH = 70 TESTING_STATES = [ "Downloading Data", @@ -64,7 +66,8 @@ class DetectionTestingManagerOutputDto: class DetectionTestingInfrastructure(BaseModel, abc.ABC): # thread: threading.Thread = threading.Thread() - config: TestConfig + global_config: TestConfig + infrastructure: Infrastructure sync_obj: DetectionTestingManagerOutputDto hec_token: str = "" hec_channel: str = "" @@ -185,10 +188,10 @@ def connect_to_api(self, sleep_seconds: int = 5): try: conn = client.connect( - host=self.config.test_instance_address, - port=self.config.api_port, - username=self.config.splunk_app_username, - password=self.config.splunk_app_password, + host=self.infrastructure.instance_address, + port=self.infrastructure.api_port, + username=self.infrastructure.splunk_app_username, + password=self.infrastructure.splunk_app_password, ) if conn.restart_required: @@ -249,7 +252,7 @@ def configure_imported_roles( indexes.append(self.sync_obj.replay_index) indexes_encoded = ";".join(indexes) self.get_conn().roles.post( - self.config.splunk_app_username, + self.infrastructure.splunk_app_username, imported_roles=imported_roles, srchIndexesAllowed=indexes_encoded, srchIndexesDefault=self.sync_obj.replay_index, @@ -407,7 +410,7 @@ def execute_test( test.result = UnitTestResult() test.result.set_job_content( - e, self.config, duration=time.time() - start_time + None, self.infrastructure, exception=e, duration=time.time() - start_time ) self.pbar.write( self.format_pbar_string( @@ -439,13 +442,13 @@ def execute_test( except Exception as e: test.result = UnitTestResult() test.result.set_job_content( - e, self.config, duration=time.time() - start_time + None, self.infrastructure, exception=e, duration=time.time() - start_time ) if ( - self.config.post_test_behavior == PostTestBehavior.always_pause + self.global_config.post_test_behavior == PostTestBehavior.always_pause or ( - self.config.post_test_behavior == PostTestBehavior.pause_on_failure + self.global_config.post_test_behavior == PostTestBehavior.pause_on_failure and (test.result is None or test.result.success == False) ) ) and not self.sync_obj.terminate: @@ -533,30 +536,85 @@ def retry_search_until_timeout( job = self.get_conn().search(query=search, **kwargs) - # the following raises an error if there is an exception in the search - _ = job.results(output_mode="json") - + results = JSONResultsReader(job.results(output_mode="json")) + + observable_fields_set = set([o.name for o in detection.tags.observable]) + if int(job.content.get("resultCount", "0")) > 0: test.result = UnitTestResult() + empty_fields = set() + for result in results: + if isinstance(result, Message): + continue + + #otherwise it is a dict and we will process is + results_fields_set = set(result.keys()) + + missing_fields = observable_fields_set - results_fields_set + + + if len(missing_fields) > 0: + e = Exception(f"The observable field(s) {missing_fields} are missing in the detection results") + test.result.set_job_content( + job.content, + self.infrastructure, + exception=e, + success=False, + duration=time.time() - search_start_time, + ) + + + return + + + + + # If we find one or more fields that contain the string "null" then they were + # not populated and we should throw an error. This can happen if there is a typo + # on a field. In this case, the field will appear but will not contain any values + current_empty_fields = set() + for field in observable_fields_set: + if result.get(field,'null') == 'null': + current_empty_fields.add(field) + + + if len(current_empty_fields) == 0: + test.result.set_job_content( + job.content, + self.infrastructure, + success=True, + duration=time.time() - search_start_time, + ) + return + + else: + empty_fields = empty_fields.union(current_empty_fields) + + + e = Exception(f"One or more required observable fields {empty_fields} contained 'null' values. Is the data being " + "parsed correctly or is there an error in the naming of a field?") test.result.set_job_content( job.content, - self.config, - success=True, + self.infrastructure, + exception=e, + success=False, duration=time.time() - search_start_time, ) - + return + else: test.result = UnitTestResult() test.result.set_job_content( job.content, - self.config, + self.infrastructure, success=False, duration=time.time() - search_start_time, ) - - tick += 1 - + tick += 1 + + + return def delete_attack_data(self, attack_data_files: list[UnitTestAttackData]): @@ -678,21 +736,21 @@ def hec_raw_replay( "host": attack_data_file.host or self.sync_obj.replay_host, } - if self.config.test_instance_address.strip().lower().startswith("https://"): - address_with_scheme = self.config.test_instance_address.strip().lower() - elif self.config.test_instance_address.strip().lower().startswith("http://"): + if self.infrastructure.instance_address.strip().lower().startswith("https://"): + address_with_scheme = self.infrastructure.instance_address.strip().lower() + elif self.infrastructure.instance_address.strip().lower().startswith("http://"): address_with_scheme = ( - self.config.test_instance_address.strip() + self.infrastructure.instance_address.strip() .lower() .replace("http://", "https://") ) else: - address_with_scheme = f"https://{self.config.test_instance_address}" + address_with_scheme = f"https://{self.infrastructure.instance_address}" # Generate the full URL, including the host, the path, and the params. # We can be a lot smarter about this (and pulling the port from the url, checking # for trailing /, etc, but we leave that for the future) - url_with_port = f"{address_with_scheme}:{self.config.hec_port}" + url_with_port = f"{address_with_scheme}:{self.infrastructure.hec_port}" url_with_hec_path = urllib.parse.urljoin( url_with_port, "services/collector/raw" ) diff --git a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py index c4ee664b..5ab14699 100644 --- a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +++ b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py @@ -28,7 +28,7 @@ def finish(self): super().finish() def get_name(self) -> str: - return self.config.container_name + return self.infrastructure.instance_name def get_docker_client(self): try: @@ -59,9 +59,9 @@ def make_container(self) -> docker.models.resource.Model: self.removeContainer() ports_dict = { - "8000/tcp": self.config.web_ui_port, - "8088/tcp": self.config.hec_port, - "8089/tcp": self.config.api_port, + "8000/tcp": self.infrastructure.web_ui_port, + "8088/tcp": self.infrastructure.hec_port, + "8089/tcp": self.infrastructure.api_port, } mounts = [ @@ -75,19 +75,19 @@ def make_container(self) -> docker.models.resource.Model: environment = {} environment["SPLUNK_START_ARGS"] = "--accept-license" - environment["SPLUNK_PASSWORD"] = self.config.splunk_app_password + environment["SPLUNK_PASSWORD"] = self.infrastructure.splunk_app_password environment["SPLUNK_APPS_URL"] = ",".join( - p.environment_path for p in self.config.apps + p.environment_path for p in self.global_config.apps ) if ( - self.config.splunkbase_password is not None - and self.config.splunkbase_username is not None + self.global_config.splunkbase_password is not None + and self.global_config.splunkbase_username is not None ): - environment["SPLUNKBASE_USERNAME"] = self.config.splunkbase_username - environment["SPLUNKBASE_PASSWORD"] = self.config.splunkbase_password + environment["SPLUNKBASE_USERNAME"] = self.global_config.splunkbase_username + environment["SPLUNKBASE_PASSWORD"] = self.global_config.splunkbase_password container = self.get_docker_client().containers.create( - self.config.full_image_path, + self.global_config.infrastructure_config.full_image_path, ports=ports_dict, environment=environment, name=self.get_name(), @@ -99,7 +99,6 @@ def make_container(self) -> docker.models.resource.Model: return container def removeContainer(self, removeVolumes: bool = True, forceRemove: bool = True): - try: container: docker.models.containers.Container = ( self.get_docker_client().containers.get(self.get_name()) @@ -118,6 +117,6 @@ def removeContainer(self, removeVolumes: bool = True, forceRemove: bool = True): except Exception as e: raise ( Exception( - f"Could not remove Docker Container [{self.config.container_name}]: {str(e)}" + f"Could not remove Docker Container [{self.get_name()}]: {str(e)}" ) ) diff --git a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureServer.py b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureServer.py index 34dde092..e7b8c2e8 100644 --- a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureServer.py +++ b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureServer.py @@ -11,4 +11,4 @@ def finish(self): super().finish() def get_name(self): - return self.config.container_name + return self.infrastructure.instance_name diff --git a/contentctl/actions/detection_testing/views/DetectionTestingView.py b/contentctl/actions/detection_testing/views/DetectionTestingView.py index 35158c60..8e188376 100644 --- a/contentctl/actions/detection_testing/views/DetectionTestingView.py +++ b/contentctl/actions/detection_testing/views/DetectionTestingView.py @@ -69,7 +69,7 @@ def getETA(self) -> datetime.timedelta: def getSummaryObject( self, - test_model_fields: list[str] = ["success", "message"], + test_model_fields: list[str] = ["success", "message", "exception"], test_job_fields: list[str] = ["resultCount", "runDuration"], ) -> dict: total_untested = len(self.sync_obj.inputQueue) diff --git a/contentctl/actions/detection_testing/views/DetectionTestingViewFile.py b/contentctl/actions/detection_testing/views/DetectionTestingViewFile.py index ac9f2af8..79e3f705 100644 --- a/contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +++ b/contentctl/actions/detection_testing/views/DetectionTestingViewFile.py @@ -21,8 +21,8 @@ class DetectionTestingViewFile(DetectionTestingView): output_filename: str = OUTPUT_FILENAME def getOutputFilePath(self) -> pathlib.Path: - - folder_path = pathlib.Path(self.config.repo_path) / self.output_folder + + folder_path = pathlib.Path('.') / self.output_folder output_file = folder_path / self.output_filename return output_file @@ -31,13 +31,14 @@ def setup(self): pass def stop(self): - folder_path = pathlib.Path(self.config.repo_path) / OUTPUT_FOLDER + folder_path = pathlib.Path('.') / self.output_folder output_file = self.getOutputFilePath() folder_path.mkdir(parents=True, exist_ok=True) - + + result_dict = self.getSummaryObject() - + # use the yaml writer class with open(output_file, "w") as res: res.write(yaml.safe_dump(result_dict)) diff --git a/contentctl/actions/initialize.py b/contentctl/actions/initialize.py index 33dce84b..96bbc661 100644 --- a/contentctl/actions/initialize.py +++ b/contentctl/actions/initialize.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from contentctl.objects.config import Config, TestConfig, PASSWORD from contentctl.output.yml_writer import YmlWriter +import json @dataclass(frozen=True) class InitializeInputDto: @@ -18,8 +19,7 @@ def execute(self, input_dto: InitializeInputDto) -> None: c = Config() - t = TestConfig.construct(splunk_app_username="admin", - splunk_app_password= PASSWORD) #Disable validation for default object + t = TestConfig.construct() #Disable validation for default object config_as_dict = c.dict() config_as_dict.pop("test") @@ -29,7 +29,7 @@ def execute(self, input_dto: InitializeInputDto) -> None: # This field serialization hack is required to get # enums declared in Pydantic Models serialized properly # without emitting tags that make them hard to read in yml - import json + j = json.dumps(t.dict(),sort_keys=False) obj=json.loads(j) YmlWriter.writeYmlFile(os.path.join(input_dto.path, 'contentctl_test.yml'), dict(obj)) diff --git a/contentctl/contentctl.py b/contentctl/contentctl.py index 183c27ff..84dbbc61 100644 --- a/contentctl/contentctl.py +++ b/contentctl/contentctl.py @@ -25,6 +25,7 @@ SecurityContentProduct, DetectionTestingMode, PostTestBehavior, + DetectionTestingTargetInfrastructure ) from contentctl.input.new_content_generator import NewContentGeneratorInputDto from contentctl.helper.config_handler import ConfigHandler @@ -32,14 +33,14 @@ from contentctl.objects.config import Config from contentctl.objects.app import App -from contentctl.objects.test_config import TestConfig +from contentctl.objects.test_config import TestConfig, Infrastructure from contentctl.actions.test import Test, TestInputDto, TestOutputDto import tqdm import functools from typing import Union - +SERVER_ARGS_ENV_VARIABLE = "CONTENTCTL_TEST_INFRASTRUCTURES" def configure_unattended(args: argparse.Namespace) -> argparse.Namespace: # disable all calls to tqdm - this is so that CI/CD contexts don't @@ -128,19 +129,63 @@ def acs_deploy(args) -> None: def test(args: argparse.Namespace): args = configure_unattended(args) + config = start(args, read_test_file=True) + if config.test is None: + raise Exception("Error parsing test configuration. Test Object was None.") # set some arguments that are not # yet exposed/written properly in # the config file - config.test.mode=DetectionTestingMode(args.mode) - config.test.num_containers=1 - config.test.post_test_behavior=PostTestBehavior(args.behavior) - config.test.detections_list=args.detections_list - - + if args.infrastructure is not None: + config.test.infrastructure_config.infrastructure_type = DetectionTestingTargetInfrastructure(args.infrastructure) + if args.mode is not None: + config.test.mode=DetectionTestingMode(args.mode) + if args.behavior is not None: + config.test.post_test_behavior=PostTestBehavior(args.behavior) + if args.detections_list is not None: + config.test.detections_list=args.detections_list + + + if config.test.infrastructure_config.infrastructure_type == DetectionTestingTargetInfrastructure.container: + if args.num_containers is None: + raise Exception("Error - trying to start a test using container infrastructure but no value for --num_containers was found") + config.test.infrastructure_config.infrastructures = Infrastructure.get_infrastructure_containers(args.num_containers) + elif config.test.infrastructure_config.infrastructure_type == DetectionTestingTargetInfrastructure.server: + if args.server_info is None and os.environ.get(SERVER_ARGS_ENV_VARIABLE) is None: + if len(config.test.infrastructure_config.infrastructures) == 0: + raise Exception("Error - trying to start a test using server infrastructure, but server information was not stored " + "in contentctl_test.yml or passed on the command line. Please see the documentation for --server_info " + "at the command line or 'infrastructures' in contentctl.yml.") + else: + print("Using server configuration from: [contentctl_test.yml infrastructures section]") + + else: + if args.server_info is not None: + print("Using server configuration from: [command line]") + pass + elif os.environ.get(SERVER_ARGS_ENV_VARIABLE) is not None: + args.server_info = os.environ.get(SERVER_ARGS_ENV_VARIABLE,"").split(';') + print(f"Using server configuration from: [{SERVER_ARGS_ENV_VARIABLE} environment variable]") + else: + raise Exception(f"Server infrastructure information not passed in contentctl_test.yml file, using --server_info switch on the command line, or in the {SERVER_ARGS_ENV_VARIABLE} environment variable") + # if server info was provided on the command line, us that. Otherwise use the env + + + + config.test.infrastructure_config.infrastructures = [] + + for server in args.server_info: + address,username,password,web_ui_port,hec_port,api_port = server.split(",") + config.test.infrastructure_config.infrastructures.append(Infrastructure(splunk_app_username=username, + splunk_app_password=password, + instance_address=address, + hec_port=int(hec_port), + web_ui_port=int(web_ui_port), + api_port=int(api_port))) + # We do this before generating the app to save some time if options are incorrect. # For example, if the detection(s) we are trying to test do not exist githubService = GithubService(config.test) @@ -180,21 +225,19 @@ def test(args: argparse.Namespace): test = Test() - try: + - result = test.execute(test_input_dto) - # This return code is important. Even if testing - # fully completes, if everything does not pass then - # we want to return a nonzero status code - if result: - sys.exit(0) - else: - sys.exit(1) - - except Exception as e: - print(f"Error running contentctl test: {str(e)}") + result = test.execute(test_input_dto) + # This return code is important. Even if testing + # fully completes, if everything does not pass then + # we want to return a nonzero status code + if result: + sys.exit(0) + else: sys.exit(1) + + def validate(args) -> None: config = start(args) @@ -337,7 +380,7 @@ def main(): test_parser.add_argument( "--mode", required=False, - default=DetectionTestingMode.all.name, + default=None, type=str, choices=DetectionTestingMode._member_names_, help="Controls which detections to test. 'all' will test all detections in the repo." @@ -347,7 +390,7 @@ def main(): test_parser.add_argument( "--behavior", required=False, - default=PostTestBehavior.pause_on_failure.name, + default=None, type=str, choices=PostTestBehavior._member_names_, help="Controls what to do when a test completes. 'always_pause' means that the state of " @@ -364,13 +407,27 @@ def main(): "--detections_list", required=False, nargs="+", + default=None, type=str, help="An explicit list " "of detections to test. Their paths should be relative to the app path.", ) + test_parser.add_argument("--unattended", action=argparse.BooleanOptionalAction) + + test_parser.add_argument("--infrastructure", required=False, type=str, + choices=DetectionTestingTargetInfrastructure._member_names_, default=None, + help="Determines what infrastructure to use for testing. The options are " + "container and server. Container will set up Splunk Container(s) at runtime, " + "install all relevant apps, and perform configurations. Server will use " + "preconfigured server(s) either specified on the command line or in " + "contentctl_test.yml.") + test_parser.add_argument("--num_containers", required=False, default=1, type=int) + test_parser.add_argument("--server_info", required=False, default=None, type=str, nargs='+') + + test_parser.set_defaults(func=test) # parse them @@ -383,5 +440,6 @@ def main(): print(f"Error during contentctl:\n{str(e)}") import traceback traceback.print_exc() + traceback.print_stack() sys.exit(1) diff --git a/contentctl/helper/utils.py b/contentctl/helper/utils.py index 0c8901c6..ddf0e407 100644 --- a/contentctl/helper/utils.py +++ b/contentctl/helper/utils.py @@ -179,19 +179,19 @@ def validate_git_pull_request(repo_path: str, pr_number: int) -> str: return hash - @staticmethod - def check_required_fields( - thisField: str, definedFields: dict, requiredFields: list[str] - ): - missing_fields = [ - field for field in requiredFields if field not in definedFields - ] - if len(missing_fields) > 0: - raise ( - ValueError( - f"Could not validate - please resolve other errors resulting in missing fields {missing_fields}" - ) - ) + # @staticmethod + # def check_required_fields( + # thisField: str, definedFields: dict, requiredFields: list[str] + # ): + # missing_fields = [ + # field for field in requiredFields if field not in definedFields + # ] + # if len(missing_fields) > 0: + # raise ( + # ValueError( + # f"Could not validate - please resolve other errors resulting in missing fields {missing_fields}" + # ) + # ) @staticmethod def verify_file_exists( @@ -207,15 +207,16 @@ def verify_file_exists( # Try to make a head request to verify existence of the file try: + req = requests.head( file_path, timeout=timeout_seconds, verify=True, allow_redirects=True ) if req.status_code > 400: - raise (Exception(f"Return code {req.status_code}")) + raise (Exception(f"Return code={req.status_code}")) except Exception as e: raise ( Exception( - f"Cannot confirm the existence of '{file_path}' - are you sure it exists: {str(e)}" + f"HTTP Resolution Failed: {str(e)}" ) ) diff --git a/contentctl/input/detection_builder.py b/contentctl/input/detection_builder.py index bbd8d684..d98697e7 100644 --- a/contentctl/input/detection_builder.py +++ b/contentctl/input/detection_builder.py @@ -8,6 +8,7 @@ from contentctl.objects.detection import Detection from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.macro import Macro +from contentctl.objects.lookup import Lookup from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment from contentctl.enrichments.cve_enrichment import CveEnrichment from contentctl.enrichments.splunk_app_enrichment import SplunkAppEnrichment @@ -41,25 +42,25 @@ def addRBA(self) -> None: for entity in self.security_content_obj.tags.observable: risk_object = dict() - if entity['type'].lower() in risk_object_user_types: + if entity.type.lower() in risk_object_user_types: risk_object['risk_object_type'] = 'user' - risk_object['risk_object_field'] = entity['name'] + risk_object['risk_object_field'] = entity.name risk_object['risk_score'] = self.security_content_obj.tags.risk_score risk_objects.append(risk_object) - elif entity['type'].lower() in risk_object_system_types: + elif entity.type.lower() in risk_object_system_types: risk_object['risk_object_type'] = 'system' - risk_object['risk_object_field'] = entity['name'] + risk_object['risk_object_field'] = entity.name risk_object['risk_score'] = self.security_content_obj.tags.risk_score risk_objects.append(risk_object) - elif 'role' in entity and 'Attacker' in entity['role']: - risk_object['threat_object_field'] = entity['name'] - risk_object['threat_object_type'] = entity['type'].lower() + elif 'Attacker' in entity.role: + risk_object['threat_object_field'] = entity.name + risk_object['threat_object_type'] = entity.type.lower() risk_objects.append(risk_object) else: risk_object['risk_object_type'] = 'other' - risk_object['risk_object_field'] = entity['name'] + risk_object['risk_object_field'] = entity.name risk_object['risk_score'] = self.security_content_obj.tags.risk_score risk_objects.append(risk_object) continue @@ -181,37 +182,23 @@ def addMitreAttackEnrichment(self, attack_enrichment: dict) -> None: def addMacros(self, macros: list) -> None: if self.security_content_obj: - macros_found = re.findall(r'`([^\s]+)`', self.security_content_obj.search) - macros_filtered = set() - self.security_content_obj.macros = [] - - for macro in macros_found: - if not '_filter' in macro and not 'drop_dm_object_name' in macro: - start = macro.find('(') - if start != -1: - macros_filtered.add(macro[:start]) - else: - macros_filtered.add(macro) - - for macro_name in macros_filtered: - for macro in macros: - if macro_name == macro.name: - self.security_content_obj.macros.append(macro) - + found_macros, missing_macros = Macro.get_macros(self.security_content_obj.search, macros) name = self.security_content_obj.name.replace(' ', '_').replace('-', '_').replace('.', '_').replace('/', '_').lower() + '_filter' macro = Macro(name=name, definition='search *', description='Update this macro to limit the output results to filter out false positives.') + found_macros.append(macro) + self.security_content_obj.macros = found_macros + if len(missing_macros) > 0: + raise Exception(f"{self.security_content_obj.name} is missing the following macros: {missing_macros}") - self.security_content_obj.macros.append(macro) def addLookups(self, lookups: list) -> None: if self.security_content_obj: - lookups_found = re.findall(r'lookup (?:update=true)?(?:append=t)?\s*([^\s]*)', self.security_content_obj.search) - self.security_content_obj.lookups = [] - for lookup_name in lookups_found: - for lookup in lookups: - if lookup.name == lookup_name: - self.security_content_obj.lookups.append(lookup) + found_lookups, missing_lookups = Lookup.get_lookups(self.security_content_obj.search, lookups) + self.security_content_obj.lookups = found_lookups + if len(missing_lookups) > 0: + raise Exception(f"{self.security_content_obj.name} is missing the following lookups: {missing_lookups}") + def addCve(self) -> None: diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index 4b0059ad..32498606 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import uuid import string import requests import time import sys - +import re +import pathlib from pydantic import BaseModel, validator, root_validator, Extra from dataclasses import dataclass from typing import Union @@ -30,12 +33,13 @@ class Detection_Abstract(SecurityContentObject): type: str status: DetectionStatus data_source: list[str] + tags: DetectionTags search: Union[str, dict] how_to_implement: str known_false_positives: str check_references: bool = False references: list - tags: DetectionTags + tests: list[UnitTest] = [] # enrichments @@ -45,11 +49,11 @@ class Detection_Abstract(SecurityContentObject): deployment: ConfigDetectionConfiguration = None annotations: dict = None risk: list = None - playbooks: list[Playbook] = None - baselines: list[Baseline] = None + playbooks: list[Playbook] = [] + baselines: list[Baseline] = [] mappings: dict = None - macros: list[Macro] = None - lookups: list[Lookup] = None + macros: list[Macro] = [] + lookups: list[Lookup] = [] cve_enrichment: list = None splunk_app_enrichment: list = None file_path: str = None @@ -61,6 +65,21 @@ class Detection_Abstract(SecurityContentObject): class Config: use_enum_values = True + + def get_content_dependencies(self)->list[SecurityContentObject]: + return self.playbooks + self.baselines + self.macros + self.lookups + + @staticmethod + def get_detections_from_filenames(detection_filenames:set[str], all_detections:list[Detection_Abstract])->list[Detection_Abstract]: + detection_filenames = set(str(pathlib.Path(filename).absolute()) for filename in detection_filenames) + detection_dict = SecurityContentObject.create_filename_to_content_dict(all_detections) + + try: + return [detection_dict[detection_filename] for detection_filename in detection_filenames] + except Exception as e: + raise Exception(f"Failed to find detection object for modified detection: {str(e)}") + + @validator("type") def type_valid(cls, v, values): if v.lower() not in [el.name.lower() for el in AnalyticsType]: @@ -89,8 +108,34 @@ def encode_error(cls, v, values, field): @validator("search") - def search_validate(cls, v, values): - # write search validator + def search_obsersables_exist_validate(cls, v, values): + tags:DetectionTags = values.get("tags") + if tags == None: + raise ValueError("Unable to parse Detection Tags. Please resolve Detection Tags errors") + + observable_fields = [ob.name.lower() for ob in tags.observable] + + #All $field$ fields from the message must appear in the search + field_match_regex = r"\$([^\s.]*)\$" + + message_fields = [match.replace("$", "").lower() for match in re.findall(field_match_regex, tags.message.lower())] + missing_fields = set([field for field in observable_fields if field not in v.lower()]) + + error_messages = [] + if len(missing_fields) > 0: + error_messages.append(f"The following fields are declared as observables, but do not exist in the search: {missing_fields}") + + + missing_fields = set([field for field in message_fields if field not in v.lower()]) + if len(missing_fields) > 0: + error_messages.append(f"The following fields are used as fields in the message, but do not exist in the search: {missing_fields}") + + if len(error_messages) > 0: + msg = "\n\t".join(error_messages) + print("Errors found in notable validation - skipping for now") + #raise(ValueError(msg)) + + # Found everything return v @validator("tests") @@ -131,7 +176,7 @@ def all_tests_successful(self) -> bool: def get_summary( self, detection_fields: list[str] = ["name", "search"], - test_model_fields: list[str] = ["success", "message"], + test_model_fields: list[str] = ["success", "message", "exception"], test_job_fields: list[str] = ["resultCount", "runDuration"], ) -> dict: summary_dict = {} diff --git a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py index 4ae242cf..666cd6d1 100644 --- a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py @@ -1,24 +1,29 @@ +from __future__ import annotations + import abc import string import uuid from datetime import datetime -from pydantic import BaseModel, validator, ValidationError +from pydantic import BaseModel, validator, ValidationError, Field from contentctl.objects.enums import SecurityContentType +from typing import Tuple +import uuid +import pathlib - +NO_FILE_BUILT_AT_RUNTIME = "NO_FILE_BUILT_AT_RUNTIME" class SecurityContentObject_Abstract(BaseModel, abc.ABC): contentType: SecurityContentType name: str author: str = "UNKNOWN_AUTHOR" date: str = "1990-01-01" - version: int = 99999 - id: str = None + version: int = 1 + id: uuid.UUID = Field(default_factory=uuid.uuid4) #we set a default here until all content has a uuid description: str = "UNKNOWN_DESCRIPTION" + file_path: str = "NO_FILE_BUILT_AT_RUNTIME" @validator('name') def name_max_length(cls, v): if len(v) > 67: - print("LENGTH ERROR!") raise ValueError('name is longer then 67 chars: ' + v) return v @@ -29,16 +34,6 @@ def name_invalid_chars(cls, v): raise ValueError('invalid chars used in name: ' + v) return v - @validator('id',always=True) - def id_check(cls, v, values): - try: - uuid.UUID(str(v)) - except: - #print(f"Generating missing uuid for {values['name']}") - return str(uuid.uuid4()) - raise ValueError('uuid is not valid: ' + values["name"]) - return v - @validator('date') def date_valid(cls, v, values): try: @@ -58,3 +53,21 @@ def free_text_field_valid(input_cls, v, values, field): @validator('description') def description_valid(cls, v, values, field): return SecurityContentObject_Abstract.free_text_field_valid(cls,v,values,field) + + + @staticmethod + def get_objects_by_name(names_to_find:set[str], objects_to_search:list[SecurityContentObject_Abstract])->Tuple[list[SecurityContentObject_Abstract], set[str]]: + found_objects = list(filter(lambda obj: obj.name in names_to_find, objects_to_search)) + found_names = set([obj.name for obj in found_objects]) + missing_names = names_to_find - found_names + return found_objects,missing_names + + @staticmethod + def create_filename_to_content_dict(all_objects:list[SecurityContentObject_Abstract])->dict[str,SecurityContentObject_Abstract]: + name_dict:dict[str,SecurityContentObject_Abstract] = dict() + + for object in all_objects: + name_dict[str(pathlib.Path(object.file_path))] = object + + return name_dict + \ No newline at end of file diff --git a/contentctl/objects/app.py b/contentctl/objects/app.py index db7f7194..dc2a0210 100644 --- a/contentctl/objects/app.py +++ b/contentctl/objects/app.py @@ -102,32 +102,32 @@ def validate_string_alphanumeric_with_underscores(input: str) -> bool: ) return True - @validator("uid", always=True) + @validator("uid") def validate_uid(cls, v): return v - @validator("appid", always=True) + @validator("appid") def validate_appid(cls, v): # Called function raises exception on failure, so we don't need to raise it here cls.validate_string_alphanumeric_with_underscores(v) return v - @validator("title", always=True) + @validator("title") def validate_title(cls, v): # Basically, a title can be any string return v - @validator("description", always=True) + @validator("description") def validate_description(cls, v): # description can be anything return v - @validator("release", always=True) + @validator("release") def validate_release(cls, v): # release can be any string return v - @validator("local_path", always=True) + @validator("local_path") def validate_local_path(cls, v): if v is not None: p = pathlib.Path(v) @@ -139,7 +139,7 @@ def validate_local_path(cls, v): # release can be any string return v - @validator("http_path", always=True) + @validator("http_path") def validate_http_path(cls, v, values): if v is not None: try: @@ -149,11 +149,8 @@ def validate_http_path(cls, v, values): raise (ValueError(f"Error validating the http_path: {str(e)}")) return v - @validator("splunkbase_path", always=True) + @validator("splunkbase_path") def validate_splunkbase_path(cls, v, values): - Utils.check_required_fields( - "splunkbase_path", values, ["local_path", "http_path", "uid", "title"] - ) if v is not None: try: diff --git a/contentctl/objects/detection_tags.py b/contentctl/objects/detection_tags.py index 18fd08d9..7ba5c87e 100644 --- a/contentctl/objects/detection_tags.py +++ b/contentctl/objects/detection_tags.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, validator, ValidationError, root_validator from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment from contentctl.objects.constants import * +from contentctl.objects.observable import Observable class DetectionTags(BaseModel): # detection spec @@ -14,10 +15,10 @@ class DetectionTags(BaseModel): confidence: str impact: int kill_chain_phases: list = None - message: str mitre_attack_id: list = None nist: list = None - observable: list + observable: list[Observable] = [] + message: str product: list required_fields: list risk_score: int @@ -128,22 +129,32 @@ def tags_calculate_risk_score(cls, v, values): raise ValueError(f"Risk Score must be calculated as round(confidence * impact / 100)" f"\n Expected risk_score={calculated_risk_score}, found risk_score={int(v)}: {values['name']}") return v + + # The following validator is temporarily disabled pending further discussions + # @validator('message') + # def validate_message(cls,v,values): + + # observables:list[Observable] = values.get("observable",[]) + # observable_names = set([o.name for o in observables]) + # #find all of the observables used in the message by name + # name_match_regex = r"\$([^\s.]*)\$" + + # message_observables = set() + + # #Make sure that all observable names in + # for match in re.findall(name_match_regex, v): + # #Remove + # match_without_dollars = match.replace("$", "") + # message_observables.add(match_without_dollars) + - @root_validator - def tags_observable(cls, values): - valid_roles = SES_OBSERVABLE_ROLE_MAPPING.keys() - valid_types = SES_OBSERVABLE_TYPE_MAPPING.keys() + # missing_observables = message_observables - observable_names + # unused_observables = observable_names - message_observables + # if len(missing_observables) > 0: + # raise ValueError(f"The following observables are referenced in the message, but were not declared as observables: {missing_observables}") - for value in values["observable"]: - if value['type'] in valid_types: - if 'Splunk Behavioral Analytics' in values["product"]: - continue - - if 'role' not in value: - raise ValueError('Observable role is missing for ' + values["name"]) - for role in value['role']: - if role not in valid_roles: - raise ValueError('Observable role ' + role + ' not valid for ' + values["name"] + '. valid options are ' + str(valid_roles)) - else: - raise ValueError('Observable type ' + value['type'] + ' not valid for ' + values["name"] + '. valid options are ' + str(valid_types)) - return values \ No newline at end of file + # if len(unused_observables) > 0: + # raise ValueError(f"The following observables were declared, but are not referenced in the message: {unused_observables}") + # return v + + \ No newline at end of file diff --git a/contentctl/objects/lookup.py b/contentctl/objects/lookup.py index a82c45a2..20c5db5b 100644 --- a/contentctl/objects/lookup.py +++ b/contentctl/objects/lookup.py @@ -1,8 +1,22 @@ -from pydantic import BaseModel, validator, ValidationError +from __future__ import annotations +from pydantic import BaseModel, validator, ValidationError +from typing import Tuple +import re from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.enums import SecurityContentType +LOOKUPS_TO_IGNORE = set(["outputlookup"]) +LOOKUPS_TO_IGNORE.add("ut_shannon_lookup") #In the URL toolbox app which is recommended for ESCU +LOOKUPS_TO_IGNORE.add("identity_lookup_expanded") #Shipped with the Asset and Identity Framework +LOOKUPS_TO_IGNORE.add("cim_corporate_web_domain_lookup") #Shipped with the Asset and Identity Framework +LOOKUPS_TO_IGNORE.add("alexa_lookup_by_str") #Shipped with the Asset and Identity Framework +LOOKUPS_TO_IGNORE.add("interesting_ports_lookup") #Shipped with the Asset and Identity Framework + +#Special case for the Detection "Exploit Public Facing Application via Apache Commons Text" +LOOKUPS_TO_IGNORE.add("=") +LOOKUPS_TO_IGNORE.add("other_lookups") + class Lookup(SecurityContentObject): contentType: SecurityContentType = SecurityContentType.lookups @@ -29,4 +43,13 @@ def name_invalid_chars(cls, v): def name_max_length(cls, v): #if len(v) > 67: # raise ValueError('name is longer then 67 chars: ' + v) - return v \ No newline at end of file + return v + + @staticmethod + def get_lookups(text_field: str, all_lookups: list[Lookup], ignore_lookups:set[str]=LOOKUPS_TO_IGNORE)->Tuple[list[Lookup], set[str]]: + lookups_to_get = set(re.findall(r'[^output]lookup (?:update=true)?(?:append=t)?\s*([^\s]*)', text_field)) + lookups_to_ignore = set([lookup for lookup in lookups_to_get if any(to_ignore in lookups_to_get for to_ignore in ignore_lookups)]) + lookups_to_get -= lookups_to_ignore + found_lookups, missing_lookups = SecurityContentObject.get_objects_by_name(lookups_to_get, all_lookups) + return found_lookups, missing_lookups + \ No newline at end of file diff --git a/contentctl/objects/macro.py b/contentctl/objects/macro.py index 7fedf982..19ac9356 100644 --- a/contentctl/objects/macro.py +++ b/contentctl/objects/macro.py @@ -1,9 +1,20 @@ - - +# Used so that we can have a staticmethod that takes the class +# type Macro as an argument +from __future__ import annotations +import re from pydantic import BaseModel, validator, ValidationError from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.enums import SecurityContentType +from typing import Tuple + + +MACROS_TO_IGNORE = set(["_filter", "drop_dm_object_name"]) +#Should all of the following be included as well? +MACROS_TO_IGNORE.add("get_asset" ) +MACROS_TO_IGNORE.add("get_risk_severity") +MACROS_TO_IGNORE.add("cim_corporate_web_domain_search") +MACROS_TO_IGNORE.add("prohibited_processes") class Macro(SecurityContentObject): @@ -26,4 +37,37 @@ def name_invalid_chars(cls, v): def name_max_length(cls, v): #if len(v) > 67: # raise ValueError('name is longer then 67 chars: ' + v) - return v \ No newline at end of file + return v + + + @staticmethod + def get_macros(text_field:str, all_macros: list[Macro], ignore_macros:set[str]=MACROS_TO_IGNORE)->Tuple[list[Macro], set[str]]: + + macros_to_get = re.findall(r'`([^\s]+)`', text_field) + #If macros take arguments, stop at the first argument. We just want the name of the macro + macros_to_get = set([macro[:macro.find('(')] if macro.find('(') != -1 else macro for macro in macros_to_get]) + + macros_to_ignore = set([macro for macro in macros_to_get if any(to_ignore in macro for to_ignore in ignore_macros)]) + #remove the ones that we will ignore + macros_to_get -= macros_to_ignore + found_macros, missing_macros = SecurityContentObject.get_objects_by_name(macros_to_get, all_macros) + return found_macros, missing_macros + + # found_macros = [macro for macro in all_macros if macro.name in macros_to_get] + + # missing_macros = macros_to_get - set([macro.name for macro in found_macros]) + # missing_macros_after_ignored_macros = set() + # for macro in missing_macros: + # found = False + # for ignore in ignore_macros: + # if ignore in macro: + # found=True + # break + # if found is False: + # missing_macros_after_ignored_macros.add(macro) + + #return found_macros, missing_macros_after_ignored_macros + + + + diff --git a/contentctl/objects/observable.py b/contentctl/objects/observable.py new file mode 100644 index 00000000..7b40b145 --- /dev/null +++ b/contentctl/objects/observable.py @@ -0,0 +1,45 @@ +import abc +import string +import uuid +from typing import Literal +from datetime import datetime +from pydantic import BaseModel, validator, ValidationError +from contentctl.objects.enums import SecurityContentType +from contentctl.objects.constants import * + + + +class Observable(BaseModel): + name: str + type: str + role: list[str] + + + + @validator('name') + def check_name(cls, v, values): + if v == "": + raise ValueError("No name provided for observable") + return v + + @validator('type') + def check_type(cls, v, values): + if v not in SES_OBSERVABLE_TYPE_MAPPING.keys(): + raise ValueError(f"Invalid type '{v}' provided for observable. Valid observable types are {SES_OBSERVABLE_TYPE_MAPPING.keys()}") + return v + + + @validator('role', each_item=False) + def check_roles_not_empty(cls, v, values): + if len(v) == 0: + raise ValueError("At least one role must be defined for observable") + return v + + @validator('role', each_item=True) + def check_roles(cls, v, values): + if v not in SES_OBSERVABLE_ROLE_MAPPING.keys(): + raise ValueError(f"Invalid role '{v}' provided for observable. Valid observable types are {SES_OBSERVABLE_ROLE_MAPPING.keys()}") + return v + + + \ No newline at end of file diff --git a/contentctl/objects/repo_config.py b/contentctl/objects/repo_config.py index 9cfd0c58..bf0904f5 100644 --- a/contentctl/objects/repo_config.py +++ b/contentctl/objects/repo_config.py @@ -124,9 +124,9 @@ def validate_repo_path(cls,v): return v - @validator('repo_url', always=True) + @validator('repo_url') def validate_repo_url(cls, v, values): - Utils.check_required_fields('repo_url', values, ['repo_path']) + #First try to get the value from the repo try: @@ -152,9 +152,9 @@ def validate_repo_url(cls, v, values): return v - @validator('main_branch', always=True) + @validator('main_branch') def valid_main_branch(cls, v, values): - Utils.check_required_fields('main_branch', values, ['repo_path', 'repo_url']) + try: Utils.validate_git_branch_name(values['repo_path'],values['repo_url'], v) diff --git a/contentctl/objects/test_config.py b/contentctl/objects/test_config.py index dca3f2d6..7d85e9f8 100644 --- a/contentctl/objects/test_config.py +++ b/contentctl/objects/test_config.py @@ -1,7 +1,7 @@ # Needed for a staticmethod to be able to return an instance of the class it belongs to from __future__ import annotations - +import git import validators import pathlib import yaml @@ -9,6 +9,7 @@ from pydantic import BaseModel, validator, root_validator, Extra, Field from dataclasses import dataclass from typing import Union +import re import docker import docker.errors @@ -39,70 +40,96 @@ def getTestConfigFromYMLFile(path: pathlib.Path): except Exception as e: print(f"Error loading test configuration file '{path}': {str(e)}") - -class TestConfig(BaseModel, extra=Extra.forbid, validate_assignment=True): - repo_path: str = Field(default=".", title="Path to the root of your app") - repo_url: Union[str, None] = Field( - default=None, - title="HTTP(s) path to the repo for repo_path. If this field is blank, it will be inferred from the repo", - ) - # main_branch: Union[str,None] = Field(default=None, title="Main branch of the repo, if applicable.") - # test_branch: Union[str,None] = Field(default=None, title="Branch of the repo to be tested, if applicable.") - # commit_hash: Union[str,None] = Field(default=None, title="Commit hash of the repo state to be tested, if applicable") - target_infrastructure: DetectionTestingTargetInfrastructure = Field( - default=DetectionTestingTargetInfrastructure.container, - title=f"Control where testing should be launched. Choose one of {DetectionTestingTargetInfrastructure._member_names_}", - ) - full_image_path: str = Field( - default="registry.hub.docker.com/splunk/splunk:latest", - title="Full path to the container image to be used", - ) - container_name: str = Field( - default="splunk_contentctl_%d", - title="Template to be used for naming the Splunk Test Containers which will be created", - ) - post_test_behavior: PostTestBehavior = Field( - default=PostTestBehavior.pause_on_failure, - title=f"What to do after a test has completed. Choose one of {PostTestBehavior._member_names_}", - ) - mode: DetectionTestingMode = Field( - default=DetectionTestingMode.all, - title=f"Control which detections should be tested. Choose one of {DetectionTestingMode._member_names_}", - ) - detections_list: Union[list[str], None] = Field( - default=None, title="List of paths to detections which should be tested" - ) - num_containers: int = Field( - default=1, title="Number of testing containers to start in parallel." - ) - # pr_number: Union[int,None] = Field(default=None, title="The number of the PR to test") +class Infrastructure(BaseModel, extra=Extra.forbid, validate_assignment=True): splunk_app_username: Union[str, None] = Field( default="admin", title="The name of the user for testing" ) splunk_app_password: Union[str, None] = Field( default="password", title="Password for logging into Splunk Server" ) - splunkbase_username: Union[str, None] = Field( - default=None, - title="The username for logging into Splunkbase in case apps must be downloaded", - ) - splunkbase_password: Union[str, None] = Field( - default=None, - title="The password for logging into Splunkbase in case apps must be downloaded", - ) - apps: list[App] = Field( - default=App.get_default_apps(), - title="A list of all the apps to be installed on each container", - ) - test_instance_address: str = Field( + instance_address: str = Field( default="127.0.0.1", title="Domain name of IP address of Splunk server to be used for testing. Do NOT use a protocol, like http(s):// or 'localhost'", ) - + + instance_name: str = Field( + default="Splunk_Server_Name", + title="Template to be used for naming the Splunk Test Containers or referring to Test Servers.", + ) + hec_port: int = Field(default=8088, title="HTTP Event Collector Port") web_ui_port: int = Field(default=8000, title="Web UI Port") api_port: int = Field(default=8089, title="REST API Port") + @staticmethod + def get_infrastructure_containers(num_containers:int=1, splunk_app_username:str="admin", splunk_app_password:str="password", instance_name_template="splunk_contentctl_{index}")->list[Infrastructure]: + containers:list[Infrastructure] = [] + if num_containers < 0: + raise ValueError(f"Error - you must specifiy 1 or more containers, not {num_containers}.") + + #Get the starting ports + i = Infrastructure() #Instantiate to get the base port numbers + + for index in range(0, num_containers): + containers.append(Infrastructure(splunk_app_username=splunk_app_username, + splunk_app_password=splunk_app_password, + instance_name=instance_name_template.format(index=index), + hec_port=i.hec_port+(index*2), + web_ui_port=i.web_ui_port+index, + api_port=i.api_port+(index*2))) + + + return containers + + @validator("instance_name") + def validate_instance_name(cls,v,values): + if not re.fullmatch("[a-zA-Z0-9][a-zA-Z0-9_.-]*", v): + raise ValueError(f"The instance_name '{v}' is not valid. Please use an instance name which matches the regular expression '[a-zA-Z0-9][a-zA-Z0-9_.-]*'") + else: + return v + + @validator("instance_address") + def validate_instance_address(cls, v, values): + try: + if v.startswith("http"): + raise (Exception("should not begin with http")) + is_ipv4 = validators.ipv4(v) + if bool(is_ipv4): + return v + is_domain_name = validators.domain(v) + if bool(is_domain_name): + import socket + + try: + socket.gethostbyname(v) + return v + except Exception as e: + pass + raise (Exception("DNS Lookup failed")) + raise (Exception(f"not an IPV4 address or a domain name")) + except Exception as e: + raise ( + Exception( + f"Error, failed to validate instance_address '{v}': {str(e)}" + ) + ) + + + + @validator("splunk_app_password") + def validate_splunk_app_password(cls, v): + if v == None: + # No app password was provided, so generate one + v = Utils.get_random_password() + else: + MIN_PASSWORD_LENGTH = 6 + if len(v) < MIN_PASSWORD_LENGTH: + raise ( + ValueError( + f"Password is less than {MIN_PASSWORD_LENGTH} characters long. This password is extremely weak, please change it." + ) + ) + return v @validator("hec_port", "web_ui_port", "api_port", each_item=True) def validate_ports_range(cls, v): @@ -119,130 +146,55 @@ def validate_ports_range(cls, v): ) ) return v - + @validator("hec_port", "web_ui_port", "api_port", each_item=False) def validate_ports_overlap(cls, v): - global PREVIOUSLY_ALLOCATED_PORTS + if type(v) is not list: # Otherwise this throws error when we update a single field return v if len(set(v)) != len(v): raise (ValueError(f"Duplicate ports detected: [{v}]")) - if PREVIOUSLY_ALLOCATED_PORTS.isdisjoint(v): - PREVIOUSLY_ALLOCATED_PORTS = PREVIOUSLY_ALLOCATED_PORTS.union() - else: - raise ( - ValueError( - f"Duplicate ports detected: [{PREVIOUSLY_ALLOCATED_PORTS.intersection(v)}]" - ) - ) + return v + +class InfrastructureConfig(BaseModel, extra=Extra.forbid, validate_assignment=True): + infrastructure_type: DetectionTestingTargetInfrastructure = Field( + default=DetectionTestingTargetInfrastructure.container, + title=f"Control where testing should be launched. Choose one of {DetectionTestingTargetInfrastructure._member_names_}", + ) + full_image_path: str = Field( + default="registry.hub.docker.com/splunk/splunk:latest", + title="Full path to the container image to be used", + ) + infrastructures: list[Infrastructure] = [] + + @validator("infrastructure_type") + def validate_infrastructure_type(cls, v, values): + if v == DetectionTestingTargetInfrastructure.server: + # No need to validate that the docker client is available + return v + elif v == DetectionTestingTargetInfrastructure.container: + # we need to make sure we can actually get the docker client from the environment + try: + docker.client.from_env() + except Exception as e: + raise ( + Exception( + f"Error, failed to get docker client. Is Docker Installed and running " + f"and are docker environment variables set properly? Error:\n\t{str(e)}" + ) + ) return v - # Ensure that at least 1 of test_branch, commit_hash, and/or pr_number were passed. - # Otherwise, what are we testing?? - # @root_validator(pre=False) - # def ensure_there_is_something_to_test(cls, values): - # if 'test_branch' not in values and 'commit_hash' not in values and'pr_number' not in values: - # if 'mode' in values and values['mode'] == DetectionTestingMode.changes: - # raise(ValueError(f"Under mode [{DetectionTestingMode.changes}], 'test_branch', 'commit_hash', and/or 'pr_number' must be defined so that we know what to test.")) - - # return values - - # @validator('repo_path', always=True) - # def validate_repo_path(cls,v): - - # try: - # path = pathlib.Path(v) - # except Exception as e: - # raise(ValueError(f"Error, the provided path is is not a valid path: '{v}'")) - - # try: - # r = git.Repo(path) - # except Exception as e: - # raise(ValueError(f"Error, the provided path is not a valid git repo: '{path}'")) - - # try: - - # if ALWAYS_PULL_REPO: - # r.remotes.origin.pull() - # except Exception as e: - # raise ValueError(f"Error pulling git repository {v}: {str(e)}") - - # return v - - # @validator('repo_url', always=True) - # def validate_repo_url(cls, v, values): - # Utils.check_required_fields('repo_url', values, ['repo_path']) - - # #First try to get the value from the repo - # try: - # remote_url_from_repo = git.Repo(values['repo_path']).remotes.origin.url - # except Exception as e: - # raise(ValueError(f"Error reading remote_url from the repo located at {values['repo_path']}")) - - # if v is not None and remote_url_from_repo != v: - # raise(ValueError(f"The url of the remote repo supplied in the config file {v} does not "\ - # f"match the value read from the repository at {values['repo_path']}, {remote_url_from_repo}")) - - # if v is None: - # v = remote_url_from_repo - - # #Ensure that the url is the proper format - # try: - # if bool(validators.url(v)) == False: - # raise(Exception) - # except: - # raise(ValueError(f"Error validating the repo_url. The url is not valid: {v}")) - - # return v - - # @validator('main_branch', always=True) - # def valid_main_branch(cls, v, values): - # Utils.check_required_fields('main_branch', values, ['repo_path', 'repo_url']) - - # if v is None: - # print(f"main_branch is not supplied. Inferring from '{values['repo_path']}'...",end='') - - # main_branch = Utils.get_default_branch_name(values['repo_path'], values['repo_url']) - # print(f"main_branch name '{main_branch}' inferred'") - # #continue with the validation - # v = main_branch - - # try: - # Utils.validate_git_branch_name(values['repo_path'],values['repo_url'], v) - # except Exception as e: - # raise ValueError(f"Error validating main_branch: {str(e)}") - # return v - - # @validator('test_branch', always=True) - # def validate_test_branch(cls, v, values): - # Utils.check_required_fields('test_branch', values, ['repo_path', 'repo_url', 'main_branch']) - # if v is None: - # print(f"No test_branch provided, so we will default to using the main_branch '{values['main_branch']}'") - # return values['main_branch'] - # try: - # Utils.validate_git_branch_name(values['repo_path'],values['repo_url'], v) - # except Exception as e: - # raise ValueError(f"Error validating test_branch: {str(e)}") - # return v - - # @validator('commit_hash', always=True) - # def validate_commit_hash(cls, v, values): - # Utils.check_required_fields('commit_hash', values, ['repo_path', 'repo_url', 'test_branch']) - - # try: - # #We can a hash with this function too - # Utils.validate_git_hash(values['repo_path'],values['repo_url'], v, values['test_branch']) - # except Exception as e: - # raise ValueError(f"Error validating commit_hash '{v}': {str(e)}") - # return v - - @validator("full_image_path", always=True) + + + + @validator("full_image_path") def validate_full_image_path(cls, v, values): if ( - values.get("target_infrastructure", None) + values.get("infrastructure_type", None) == DetectionTestingTargetInfrastructure.server.value ): print( @@ -330,13 +282,235 @@ def validate_full_image_path(cls, v, values): return v + @validator("infrastructures", always=True) + def validate_infrastructures(cls, v, values): + MAX_RECOMMENDED_CONTAINERS_BEFORE_WARNING = 2 + if values.get("infrastructure_type",None) == DetectionTestingTargetInfrastructure.container and len(v) == 0: + v = [Infrastructure()] + + if len(v) < 1: + print("Fix number of infrastructure validation later") + return v + raise ( + ValueError( + f"Error validating infrastructures. Test must be run with AT LEAST 1 infrastructure, not {len(v)}" + ) + ) + if (values.get("infrastructure_type", None) == DetectionTestingTargetInfrastructure.container.value) and len(v) > MAX_RECOMMENDED_CONTAINERS_BEFORE_WARNING: + print( + f"You requested to run with [{v}] containers which may use a very large amount of resources " + "as they all run in parallel. The maximum suggested number of parallel containers is " + f"[{MAX_RECOMMENDED_CONTAINERS_BEFORE_WARNING}]. We will do what you asked, but be warned!" + ) + return v + + + @validator("infrastructures", each_item=False) + def validate_ports_overlap(cls, v, values): + ports = set() + if values.get("infrastructure_type", None) == DetectionTestingTargetInfrastructure.server.value: + #ports are allowed to overlap, they are on different servers + return v + + if len(v) == 0: + raise ValueError("Error, there must be at least one test infrastructure defined in infrastructures.") + for infrastructure in v: + for k in ["hec_port", "web_ui_port", "api_port"]: + if getattr(infrastructure, k) in ports: + raise ValueError(f"Port {getattr(infrastructure, k)} used more than once in container infrastructure ports") + ports.add(getattr(infrastructure, k)) + return v + +class VersionControlConfig(BaseModel, extra=Extra.forbid, validate_assignment=True): + repo_path: str = Field(default=".", title="Path to the root of your app") + repo_url: str = Field( + default="https://github.com/your_organization/your_repo", + title="HTTP(s) path to the repo for repo_path. If this field is blank, it will be inferred from the repo", + ) + main_branch: str = Field(default="main", title="Main branch of the repo, if applicable.") + test_branch: str = Field(default="main", title="Branch of the repo to be tested, if applicable.") + commit_hash: Union[str,None] = Field(default=None, title="Commit hash of the repo state to be tested, if applicable") + pr_number: Union[int,None] = Field(default=None, title="The number of the PR to test") + + @validator('repo_path') + def validate_repo_path(cls,v): + print(f"checking repo path '{v}'") + try: + path = pathlib.Path(v) + except Exception as e: + + raise(ValueError(f"Error, the provided path is is not a valid path: '{v}'")) + + try: + r = git.Repo(path) + except Exception as e: + + raise(ValueError(f"Error, the provided path is not a valid git repo: '{path}'")) + + try: + + if ALWAYS_PULL_REPO: + r.remotes.origin.pull() + except Exception as e: + raise ValueError(f"Error pulling git repository {v}: {str(e)}") + print("repo path looks good") + return v + + @validator('repo_url') + def validate_repo_url(cls, v, values): + #First try to get the value from the repo + try: + remotes = git.Repo(values['repo_path']).remotes + except Exception as e: + raise ValueError(f"Error - repo at {values['repo_path']} has no remotes. Repo must be tracked in a remote git repo.") + + try: + remote_url_from_repo = remotes.origin.url + except Exception as e: + raise(ValueError(f"Error reading remote_url from the repo located at '{values['repo_path']}'")) + + if v is not None and remote_url_from_repo != v: + raise(ValueError(f"The url of the remote repo supplied in the config file {v} does not "\ + f"match the value read from the repository at {values['repo_path']}, {remote_url_from_repo}")) + + if v is None: + v = remote_url_from_repo + + #Ensure that the url is the proper format + try: + if bool(validators.url(v)) == False: + raise(Exception) + except: + raise(ValueError(f"Error validating the repo_url. The url is not valid: {v}")) + + return v + @validator('main_branch') + def valid_main_branch(cls, v, values): + if v is None: + print(f"main_branch is not supplied. Inferring from '{values['repo_path']}'...",end='') + + main_branch = Utils.get_default_branch_name(values['repo_path'], values['repo_url']) + print(f"main_branch name '{main_branch}' inferred'") + #continue with the validation + v = main_branch + + try: + Utils.validate_git_branch_name(values['repo_path'],values['repo_url'], v) + except Exception as e: + raise ValueError(f"Error validating main_branch: {str(e)}") + return v + + @validator('test_branch') + def validate_test_branch(cls, v, values): + if v is None: + print(f"No test_branch provided, so we will default to using the main_branch '{values['main_branch']}'") + v = values['main_branch'] + try: + Utils.validate_git_branch_name(values['repo_path'],values['repo_url'], v) + except Exception as e: + raise ValueError(f"Error validating test_branch: {str(e)}") + + r = git.Repo(values.get("repo_path")) + try: + if r.active_branch != v: + print(f"We are trying to test {v} but the current active branch is {r.active_branch}") + print(f"Checking out {v}") + r.git.checkout(v) + except Exception as e: + raise ValueError(f"Error checking out test_branch '{v}': {str(e)}") + return v + + @validator('commit_hash') + def validate_commit_hash(cls, v, values): + try: + #We can a hash with this function too + Utils.validate_git_hash(values['repo_path'],values['repo_url'], v, values['test_branch']) + except Exception as e: + raise ValueError(f"Error validating commit_hash '{v}': {str(e)}") + return v + + @validator('pr_number') + def validate_pr_number(cls, v, values): + if v == None: + return v + + hash = Utils.validate_git_pull_request(values['repo_path'], v) + + #Ensure that the hash is equal to the one in the config file, if it exists. + if values['commit_hash'] is None: + values['commit_hash'] = hash + else: + if values['commit_hash'] != hash: + raise(ValueError(f"commit_hash specified in configuration was {values['commit_hash']}, but commit_hash"\ + f" from pr_number {v} was {hash}. These must match. If you're testing"\ + " a PR, you probably do NOT want to provide the commit_hash in the configuration file "\ + "and always want to test the head of the PR. This will be done automatically if you do "\ + "not provide the commit_hash.")) + + return v + + +class TestConfig(BaseModel, extra=Extra.forbid, validate_assignment=True): + + version_control_config: Union[VersionControlConfig,None] = VersionControlConfig() + + infrastructure_config: InfrastructureConfig = Field( + default=InfrastructureConfig(), + title=f"The infrastructure for testing to be run on", + ) + + + post_test_behavior: PostTestBehavior = Field( + default=PostTestBehavior.pause_on_failure, + title=f"What to do after a test has completed. Choose one of {PostTestBehavior._member_names_}", + ) + mode: DetectionTestingMode = Field( + default=DetectionTestingMode.all, + title=f"Control which detections should be tested. Choose one of {DetectionTestingMode._member_names_}", + ) + detections_list: Union[list[str], None] = Field( + default=None, title="List of paths to detections which should be tested" + ) + + + splunkbase_username: Union[str, None] = Field( + default=None, + title="The username for logging into Splunkbase in case apps must be downloaded", + ) + splunkbase_password: Union[str, None] = Field( + default=None, + title="The password for logging into Splunkbase in case apps must be downloaded", + ) + apps: list[App] = Field( + default=App.get_default_apps(), + title="A list of all the apps to be installed on each container", + ) + + + + + + + + + + # Ensure that at least 1 of test_branch, commit_hash, and/or pr_number were passed. + # Otherwise, what are we testing?? + # @root_validator(pre=False) + def ensure_there_is_something_to_test(cls, values): + if 'test_branch' not in values and 'commit_hash' not in values and'pr_number' not in values: + if 'mode' in values and values['mode'] == DetectionTestingMode.changes: + raise(ValueError(f"Under mode [{DetectionTestingMode.changes}], 'test_branch', 'commit_hash', and/or 'pr_number' must be defined so that we know what to test.")) + + return values + + + # presumably the post test behavior is validated by the enum? # presumably the mode is validated by the enum? @validator("detections_list", always=True) def validate_detections_list(cls, v, values): - - Utils.check_required_fields("detections_list", values, ["mode", "repo_path"]) # A detections list can only be provided if the mode is selected # otherwise, we must throw an error @@ -380,67 +554,18 @@ def validate_detections_list(cls, v, values): return v - @validator("num_containers", always=True) - def validate_num_containers(cls, v): - MAX_RECOMMENDED_CONTAINERS_BEFORE_WARNING = 2 - if v < 1: - raise ( - ValueError( - f"Error validating num_containers. Test must be run with at least 1 container, not {v}" - ) - ) - if v > MAX_RECOMMENDED_CONTAINERS_BEFORE_WARNING: - print( - f"You requested to run with [{v}] containers which may use a very large amount of resources " - "as they all run in parallel. The maximum suggested number of parallel containers is " - f"[{MAX_RECOMMENDED_CONTAINERS_BEFORE_WARNING}]. We will do what you asked, but be warned!" - ) - return v - - # @validator('pr_number', always=True) - # def validate_pr_number(cls, v, values): - # Utils.check_required_fields('pr_number', values, ['repo_path', 'commit_hash']) - - # if v == None: - # return v - # hash = Utils.validate_git_pull_request(values['repo_path'], v) - # #Ensure that the hash is equal to the one in the config file, if it exists. - # if values['commit_hash'] is None: - # values['commit_hash'] = hash - # else: - # if values['commit_hash'] != hash: - # raise(ValueError(f"commit_hash specified in configuration was {values['commit_hash']}, but commit_hash"\ - # f" from pr_number {v} was {hash}. These must match. If you're testing"\ - # " a PR, you probably do NOT want to provide the commit_hash in the configuration file "\ - # "and always want to test the head of the PR. This will be done automatically if you do "\ - # "not provide the commit_hash.")) + - # return v + - @validator("splunk_app_password", always=True) - def validate_splunk_app_password(cls, v): - if v == None: - # No app password was provided, so generate one - v = Utils.get_random_password() - else: - MIN_PASSWORD_LENGTH = 6 - if len(v) < MIN_PASSWORD_LENGTH: - raise ( - ValueError( - f"Password is less than {MIN_PASSWORD_LENGTH} characters long. This password is extremely weak, please change it." - ) - ) - return v - - @validator("splunkbase_username", always=True) + @validator("splunkbase_username") def validate_splunkbase_username(cls, v): return v - @validator("splunkbase_password", always=True) + @validator("splunkbase_password") def validate_splunkbase_password(cls, v, values): - Utils.check_required_fields("repo_url", values, ["splunkbase_username"]) if values["splunkbase_username"] == None: return v elif (v == None and values["splunkbase_username"] != None) or ( @@ -460,16 +585,14 @@ def validate_splunkbase_password(cls, v, values): @validator("apps",) def validate_apps(cls, v, values): - Utils.check_required_fields( - "repo_url", values, ["splunkbase_username", "splunkbase_password"] - ) + app_errors = [] # ensure that the splunkbase username and password are provided username = values["splunkbase_username"] password = values["splunkbase_password"] - app_directory = pathlib.Path(values["repo_path"]) / LOCAL_APP_DIR + app_directory = LOCAL_APP_DIR try: os.makedirs(LOCAL_APP_DIR, exist_ok=True) except Exception as e: @@ -496,46 +619,4 @@ def validate_apps(cls, v, values): return v - @validator("target_infrastructure", always=True) - def validate_target_infrastructure(cls, v, values): - if v == DetectionTestingTargetInfrastructure.server: - # No need to validate that the docker client is available - return v - elif v == DetectionTestingTargetInfrastructure.container: - # we need to make sure we can actually get the docker client from the environment - try: - docker.client.from_env() - except Exception as e: - raise ( - Exception( - f"Error, failed to get docker client. Is Docker Installed and running " - f"and are docker environment variables set properly? Error:\n\t{str(e)}" - ) - ) - return v - - @validator("test_instance_address", always=True) - def validate_test_instance_address(cls, v, values): - try: - if v.startswith("http"): - raise (Exception("should not begin with http")) - is_ipv4 = validators.ipv4(v) - if bool(is_ipv4): - return v - is_domain_name = validators.domain(v) - if bool(is_domain_name): - import socket - - try: - socket.gethostbyname(v) - return v - except Exception as e: - pass - raise (Exception("DNS Lookup failed")) - raise (Exception(f"not an IPV4 address or a domain name")) - except Exception as e: - raise ( - Exception( - f"Error, failed to validate test_instance_address '{v}': {str(e)}" - ) - ) + \ No newline at end of file diff --git a/contentctl/objects/unit_test_result.py b/contentctl/objects/unit_test_result.py index 01baf5ed..b6054cac 100644 --- a/contentctl/objects/unit_test_result.py +++ b/contentctl/objects/unit_test_result.py @@ -4,11 +4,12 @@ from typing import Union from datetime import timedelta from splunklib.data import Record -from contentctl.objects.test_config import TestConfig +from contentctl.objects.test_config import Infrastructure from contentctl.helper.utils import Utils FORCE_TEST_FAILURE_FOR_MISSING_OBSERVABLE = False +NO_SID = "Testing Failed, NO Search ID" SID_TEMPLATE = "{server}:{web_port}/en-US/app/search/search?sid={sid}" @@ -17,12 +18,13 @@ class UnitTestResult(BaseModel): missing_observables: list[str] = [] sid_link: Union[None, str] = None message: Union[None, str] = None - exception: bool = False + exception: Union[Exception,None] = None success: bool = False duration: float = 0 class Config: validate_assignment = True + arbitrary_types_allowed = True def get_summary_dict( self, @@ -31,8 +33,12 @@ def get_summary_dict( ) -> dict: results_dict = {} for field in model_fields: - value = getattr(self, field) - results_dict[field] = getattr(self, field) + if getattr(self, field) is not None: + if isinstance(getattr(self, field), Exception): + #Exception cannot be serialized, so convert to str + results_dict[field] = str(getattr(self, field)) + else: + results_dict[field] = getattr(self, field) for field in job_fields: if self.job_content is not None: @@ -50,170 +56,41 @@ def get_summary_dict( def set_job_content( self, - content: Union[Record, None, Exception], - config: TestConfig, + content: Union[Record, None], + config: Infrastructure, + exception: Union[Exception, None] = None, success: bool = False, duration: float = 0, ): self.duration = round(duration, 2) - if isinstance(content, Record): + self.exception = exception + self.success = success + + if content is not None: self.job_content = content - self.success = success + if success: self.message = "TEST PASSED" else: self.message = "TEST FAILED" - self.exception = False + - if not config.test_instance_address.startswith("http://"): + if not config.instance_address.startswith("http://"): sid_template = f"http://{SID_TEMPLATE}" else: sid_template = SID_TEMPLATE self.sid_link = sid_template.format( - server=config.test_instance_address, + server=config.instance_address, web_port=config.web_ui_port, sid=content.get("sid", None), ) - elif isinstance(content, Exception): - self.job_content = None - self.success = False - self.exception = True - self.message = f"Error during test: {str(content)}" - elif content is None: self.job_content = None self.success = False - self.exception = True - self.message = f"Error during test: unable to run test" - - else: - msg = f"Error: Unknown type for content in UnitTestResult: {type(content)}" - print(msg) - self.job_content = None - self.success = False - self.exception = True - self.message = f"Error during test - unable to run test {msg}" - return self.success - - """ - def get_summary(self, test_name: str, verbose=False) -> str: - lines: list[str] = [] - lines.append(f"SEARCH NAME : '{test_name}'") - if verbose or self.determine_success() == False: - lines.append(f"SEARCH : {self.get_search()}") - lines.append(f"SUCCESS : {self.determine_success()}") - if self.exception is True: - lines.append(f"EXCEPTION : {self.exception}") - if self.message is not None: - lines.append(f"MESSAGE : {self.message}") - else: - lines.append(f"SUCCESS : {self.determine_success()}") - if len(self.missing_observables) > 0: - lines.append(f"MISSING OBSERVABLES: {self.missing_observables}") - - return "\n\t".join(lines) - - def get_search(self) -> str: - if self.job_content is not None: - return self.job_content.get( - "search", "NO SEARCH FOUND - JOB MISSING SEARCH FIELD" - ) - return "NO SEARCH FOUND - JOB IS EMPTY" - - def add_message(self, message: str): - if self.message is None: - self.message = message - else: - self.message += f"\n{message}" - - @root_validator(pre=False) - def update_success(cls, values): - if values["job_content"] is None: - values["exception"] = True - values["success"] = False - if values["message"] is None: - # If the message has not been overridden, then put in a default - values["message"] = "Job Content was None - unknown failure reason" - # Otherwise, a message has been passed so don't overwrite it - return values - - if "messages" in values["job_content"]: - fatal_or_error = False - all_messages = values["job_content"]["messages"] - unique_messages = set() - for level, level_messages in all_messages.items(): - if level in ["info"]: - # we will skip any info messages - continue - elif level in ["fatal", "error"]: - for msg in level_messages: - # These error indicate a failure - the search was - # not successful. They are important for debugging, - # so we will pass them to the user. - # They also represent a an error during the test - values["logic"] = False - values["success"] = False - values["exception"] = True - unique_messages.add(msg) - fatal_or_error = True - else: - unknown_messages_as_single_string = "\n".join(level_messages) - unique_messages.add(unknown_messages_as_single_string) - - if len(unique_messages) == 0: - values["message"] = None # No messages + self.message = f"Error during test: {str(content)}" + self.sid_link = NO_SID - else: - # Merge all those messages together - values["message"] = "\n".join(unique_messages) - - if fatal_or_error: - return values - - # Can there still be a success even if there was an error/fatal message above? Probably not? - if ( - "resultCount" in values["job_content"] - and int(values["job_content"]["resultCount"]) == 1 - ): - # in the future we probably want other metrics, about noise or others, here - values["logic"] = True - values["success"] = True - - elif ( - "resultCount" in values["job_content"] - and int(values["job_content"]["resultCount"]) != 1 - ): - values["logic"] = False - values["success"] = False - - else: - raise (Exception("Result created with indeterminate success.")) - - return values - - def update_missing_observables(self, missing_observables: set[str]): - self.missing_observables = list(missing_observables) - self.success = self.determine_success() - - def determine_success(self) -> bool: - # values_dict = self.update_success(self.__dict__) - # self.exception = values_dict['exception'] - # self.success = values_dict['success'] return self.success - def get_job_field(self, fieldName: str): - if self.job_content is None: - # return f"FIELD NAME {fieldName} does not exist in Job Content because Job Content is NONE" - return None - return self.job_content.get(fieldName, None) - - def get_time(self) -> timedelta: - if self.job_content is None: - return timedelta(0) - elif "runDuration" in self.job_content: - duration = str(self.job_content["runDuration"]) - return timedelta(float(duration)) - else: - raise (Exception("runDuration missing from job.")) - """ + \ No newline at end of file diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index aaac72d3..a33a1cec 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -110,14 +110,30 @@ def writeObjects(self, objects: list, type: SecurityContentType = None) -> None: 'transforms.j2', self.config, objects) - + #import code + #code.interact(local=locals()) if self.input_path is None: raise(Exception(f"input_path is required for lookups, but received [{self.input_path}]")) files = glob.iglob(os.path.join(self.input_path, 'lookups', '*.csv')) - for file in files: - if os.path.isfile(file): - shutil.copy(file, os.path.join(self.output_path, 'lookups')) + lookup_folder = self.output_path/"lookups" + if lookup_folder.exists(): + # Remove it since we want to remove any previous lookups that are not + # currently part of the app + if lookup_folder.is_dir(): + shutil.rmtree(lookup_folder) + else: + lookup_folder.unlink() + + # Make the new folder for the lookups + lookup_folder.mkdir() + + #Copy each lookup into the folder + for lookup_name in files: + lookup_path = pathlib.Path(lookup_name) + if lookup_path.is_file(): + lookup_target_path = self.output_path/"lookups"/lookup_path.name + shutil.copy(lookup_path, lookup_target_path) elif type == SecurityContentType.macros: ConfWriter.writeConfFile(self.output_path/'default/macros.conf', @@ -243,5 +259,29 @@ def inspectApp(self)-> None: #back as we read logfile.seek(0) json.dump(j, logfile, indent=3, ) + bad_stuff = ["error", "failure", "manual_check", "warning"] + reports = j.get("reports", []) + if len(reports) != 1: + raise Exception("Expected to find one appinspect report but found 0") + verbose_errors = [] + + for group in reports[0].get("groups", []): + for check in group.get("checks",[]): + if check.get("result","") in bad_stuff: + verbose_errors.append(f"Result: {check.get('result','')} - [{group.get('name','NONAME')}: {check.get('name', 'NONAME')}]") + verbose_errors.sort() + + summary = j.get("summary", None) + if summary is None: + raise Exception("Missing summary from appinspect report") + msgs = [] + for key in bad_stuff: + if summary.get(key,0)>0: + msgs.append(f"{summary.get(key,0)} {key}s") + if len(msgs)>0 or len(verbose_errors): + summary = '\n - '.join(msgs) + details = '\n - '.join(verbose_errors) + raise Exception(f"AppInspect found issue(s) that may prevent automated vetting:\nSummary:\n{summary}\nDetails:\n{details}") + except Exception as e: print(f"Failed to format {appinspect_output}: {str(e)}") \ No newline at end of file diff --git a/contentctl/templates/detections/anomalous_usage_of_7zip.yml b/contentctl/templates/detections/anomalous_usage_of_7zip.yml index 82380985..5b464f3e 100644 --- a/contentctl/templates/detections/anomalous_usage_of_7zip.yml +++ b/contentctl/templates/detections/anomalous_usage_of_7zip.yml @@ -18,7 +18,7 @@ search: '| tstats `security_content_summariesonly` count min(_time) as firstTime as lastTime from datamodel=Endpoint.Processes where Processes.parent_process_name IN ("rundll32.exe", "dllhost.exe") Processes.process_name=*7z* by Processes.dest Processes.user Processes.parent_process Processes.process_name Processes.process - Processes.process_id Processes.parent_process_id | `drop_dm_object_name(Processes)` + Processes.process_id Processes.parent_process_id Processes.parent_process_name | `drop_dm_object_name(Processes)` | `security_content_ctime(firstTime)` | `security_content_ctime(lastTime)`| `anomalous_usage_of_7zip_filter`' how_to_implement: To successfully implement this search you need to be ingesting information on process that include the name of the process responsible for the changes from diff --git a/poetry.lock b/poetry.lock index 564422d4..ffb03566 100644 --- a/poetry.lock +++ b/poetry.lock @@ -25,24 +25,6 @@ files = [ stix2 = "*" taxii2-client = "*" -[[package]] -name = "beautifulsoup4" -version = "4.12.2" -description = "Screen-scraping library" -optional = false -python-versions = ">=3.6.0" -files = [ - {file = "beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a"}, - {file = "beautifulsoup4-4.12.2.tar.gz", hash = "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da"}, -] - -[package.dependencies] -soupsieve = ">1.2" - -[package.extras] -html5lib = ["html5lib"] -lxml = ["lxml"] - [[package]] name = "bottle" version = "0.12.25" @@ -65,17 +47,6 @@ files = [ {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] -[[package]] -name = "chardet" -version = "3.0.4" -description = "Universal encoding detector for Python 2 and 3" -optional = false -python-versions = "*" -files = [ - {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, - {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, -] - [[package]] name = "charset-normalizer" version = "3.2.0" @@ -160,17 +131,6 @@ files = [ {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, ] -[[package]] -name = "click" -version = "7.1.2" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, - {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, -] - [[package]] name = "colorama" version = "0.4.6" @@ -182,20 +142,6 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "croniter" -version = "1.4.1" -description = "croniter provides iteration for datetime object with cron like format" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "croniter-1.4.1-py2.py3-none-any.whl", hash = "sha256:9595da48af37ea06ec3a9f899738f1b2c1c13da3c38cea606ef7cd03ea421128"}, - {file = "croniter-1.4.1.tar.gz", hash = "sha256:1a6df60eacec3b7a0aa52a8f2ef251ae3dd2a7c7c8b9874e73e791636d55a361"}, -] - -[package.dependencies] -python-dateutil = "*" - [[package]] name = "decorator" version = "5.1.1" @@ -228,18 +174,6 @@ websocket-client = ">=0.32.0" [package.extras] ssh = ["paramiko (>=2.4.3)"] -[[package]] -name = "enum34" -version = "1.1.10" -description = "Python 3.4 Enum backported to 3.3, 3.2, 3.1, 2.7, 2.6, 2.5, and 2.4" -optional = false -python-versions = "*" -files = [ - {file = "enum34-1.1.10-py2-none-any.whl", hash = "sha256:a98a201d6de3f2ab3db284e70a33b0f896fbf35f8086594e8c9e74b909058d53"}, - {file = "enum34-1.1.10-py3-none-any.whl", hash = "sha256:c3858660960c984d6ab0ebad691265180da2b43f07e061c0f8dca9ef3cffd328"}, - {file = "enum34-1.1.10.tar.gz", hash = "sha256:cce6a7477ed816bd2542d03d53db9f0db935dd013b70f336a95c73979289f248"}, -] - [[package]] name = "future" version = "0.18.3" @@ -250,30 +184,6 @@ files = [ {file = "future-0.18.3.tar.gz", hash = "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307"}, ] -[[package]] -name = "futures" -version = "3.0.5" -description = "Backport of the concurrent.futures package from Python 3.2" -optional = false -python-versions = "*" -files = [ - {file = "futures-3.0.5-py2-none-any.whl", hash = "sha256:f7f16b6bf9653a918a03f1f2c2d62aac0cd64b1bc088e93ea279517f6b61120b"}, - {file = "futures-3.0.5.tar.gz", hash = "sha256:0542525145d5afc984c88f914a0c85c77527f65946617edb5274f72406f981df"}, -] - -[[package]] -name = "futures-then" -version = "0.1.1" -description = "Python Futures made then-able" -optional = false -python-versions = "*" -files = [ - {file = "futures_then-0.1.1.tar.gz", hash = "sha256:976f684e5b336a1a13c8c2f342e28352519febf6591175aeb3bbc5ce60dde04a"}, -] - -[package.dependencies] -futures = "*" - [[package]] name = "gitdb" version = "4.0.10" @@ -313,36 +223,6 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] -[[package]] -name = "importlib-metadata" -version = "6.8.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, - {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] - -[[package]] -name = "ipaddress" -version = "1.0.23" -description = "IPv4/IPv6 manipulation library" -optional = false -python-versions = "*" -files = [ - {file = "ipaddress-1.0.23-py2.py3-none-any.whl", hash = "sha256:6e0f4a39e66cb5bb9a137b00276a2eff74f93b71dcbdad6f10ff7df9d3557fcc"}, - {file = "ipaddress-1.0.23.tar.gz", hash = "sha256:b7f8e0369580bb4a24d5ba1d7cc29660a4a6987763faf1d8a8046830e020e7e2"}, -] - [[package]] name = "jinja2" version = "3.1.2" @@ -360,167 +240,6 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "jsoncomment" -version = "0.3.3" -description = "A wrapper to JSON parsers allowing comments, multiline strings and trailing commas" -optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*" -files = [ - {file = "jsoncomment-0.3.3-py3-none-any.whl", hash = "sha256:ec5e16b609724f60b33b86509cad56aa1dd4ccdf30ebe936d2f0d0daa8c43151"}, - {file = "jsoncomment-0.3.3.tar.gz", hash = "sha256:98093db601c735804b8da6d999f117727fa4cd31c0fa18cfde3cc993d27e5a1e"}, -] - -[package.extras] -ujson = ["ujson (>=1.30)"] - -[[package]] -name = "langdetect" -version = "1.0.9" -description = "Language detection library ported from Google's language-detection." -optional = false -python-versions = "*" -files = [ - {file = "langdetect-1.0.9-py2-none-any.whl", hash = "sha256:7cbc0746252f19e76f77c0b1690aadf01963be835ef0cd4b56dddf2a8f1dfc2a"}, - {file = "langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0"}, -] - -[package.dependencies] -six = "*" - -[[package]] -name = "lxml" -version = "4.9.3" -description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" -files = [ - {file = "lxml-4.9.3-cp27-cp27m-macosx_11_0_x86_64.whl", hash = "sha256:b0a545b46b526d418eb91754565ba5b63b1c0b12f9bd2f808c852d9b4b2f9b5c"}, - {file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:075b731ddd9e7f68ad24c635374211376aa05a281673ede86cbe1d1b3455279d"}, - {file = "lxml-4.9.3-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1e224d5755dba2f4a9498e150c43792392ac9b5380aa1b845f98a1618c94eeef"}, - {file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0781a98ff5e6586926293e59480b64ddd46282953203c76ae15dbbbf302e8bb"}, - {file = "lxml-4.9.3-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:cef2502e7e8a96fe5ad686d60b49e1ab03e438bd9123987994528febd569868e"}, - {file = "lxml-4.9.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:b86164d2cff4d3aaa1f04a14685cbc072efd0b4f99ca5708b2ad1b9b5988a991"}, - {file = "lxml-4.9.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:42871176e7896d5d45138f6d28751053c711ed4d48d8e30b498da155af39aebd"}, - {file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:411007c0d88188d9f621b11d252cce90c4a2d1a49db6c068e3c16422f306eab8"}, - {file = "lxml-4.9.3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:cd47b4a0d41d2afa3e58e5bf1f62069255aa2fd6ff5ee41604418ca925911d76"}, - {file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0e2cb47860da1f7e9a5256254b74ae331687b9672dfa780eed355c4c9c3dbd23"}, - {file = "lxml-4.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1247694b26342a7bf47c02e513d32225ededd18045264d40758abeb3c838a51f"}, - {file = "lxml-4.9.3-cp310-cp310-win32.whl", hash = "sha256:cdb650fc86227eba20de1a29d4b2c1bfe139dc75a0669270033cb2ea3d391b85"}, - {file = "lxml-4.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:97047f0d25cd4bcae81f9ec9dc290ca3e15927c192df17331b53bebe0e3ff96d"}, - {file = "lxml-4.9.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:1f447ea5429b54f9582d4b955f5f1985f278ce5cf169f72eea8afd9502973dd5"}, - {file = "lxml-4.9.3-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:57d6ba0ca2b0c462f339640d22882acc711de224d769edf29962b09f77129cbf"}, - {file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:71c52db65e4b56b8ddc5bb89fb2e66c558ed9d1a74a45ceb7dcb20c191c3df2f"}, - {file = "lxml-4.9.3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d73d8ecf8ecf10a3bd007f2192725a34bd62898e8da27eb9d32a58084f93962b"}, - {file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0a3d3487f07c1d7f150894c238299934a2a074ef590b583103a45002035be120"}, - {file = "lxml-4.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e28c51fa0ce5674be9f560c6761c1b441631901993f76700b1b30ca6c8378d6"}, - {file = "lxml-4.9.3-cp311-cp311-win32.whl", hash = "sha256:0bfd0767c5c1de2551a120673b72e5d4b628737cb05414f03c3277bf9bed3305"}, - {file = "lxml-4.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:25f32acefac14ef7bd53e4218fe93b804ef6f6b92ffdb4322bb6d49d94cad2bc"}, - {file = "lxml-4.9.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d3ff32724f98fbbbfa9f49d82852b159e9784d6094983d9a8b7f2ddaebb063d4"}, - {file = "lxml-4.9.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48d6ed886b343d11493129e019da91d4039826794a3e3027321c56d9e71505be"}, - {file = "lxml-4.9.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9a92d3faef50658dd2c5470af249985782bf754c4e18e15afb67d3ab06233f13"}, - {file = "lxml-4.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b4e4bc18382088514ebde9328da057775055940a1f2e18f6ad2d78aa0f3ec5b9"}, - {file = "lxml-4.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fc9b106a1bf918db68619fdcd6d5ad4f972fdd19c01d19bdb6bf63f3589a9ec5"}, - {file = "lxml-4.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:d37017287a7adb6ab77e1c5bee9bcf9660f90ff445042b790402a654d2ad81d8"}, - {file = "lxml-4.9.3-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56dc1f1ebccc656d1b3ed288f11e27172a01503fc016bcabdcbc0978b19352b7"}, - {file = "lxml-4.9.3-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:578695735c5a3f51569810dfebd05dd6f888147a34f0f98d4bb27e92b76e05c2"}, - {file = "lxml-4.9.3-cp35-cp35m-win32.whl", hash = "sha256:704f61ba8c1283c71b16135caf697557f5ecf3e74d9e453233e4771d68a1f42d"}, - {file = "lxml-4.9.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c41bfca0bd3532d53d16fd34d20806d5c2b1ace22a2f2e4c0008570bf2c58833"}, - {file = "lxml-4.9.3-cp36-cp36m-macosx_11_0_x86_64.whl", hash = "sha256:64f479d719dc9f4c813ad9bb6b28f8390360660b73b2e4beb4cb0ae7104f1c12"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:dd708cf4ee4408cf46a48b108fb9427bfa00b9b85812a9262b5c668af2533ea5"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c31c7462abdf8f2ac0577d9f05279727e698f97ecbb02f17939ea99ae8daa98"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:4930be26af26ac545c3dffb662521d4e6268352866956672231887d18f0eaab2"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4aec80cde9197340bc353d2768e2a75f5f60bacda2bab72ab1dc499589b3878c"}, - {file = "lxml-4.9.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14e019fd83b831b2e61baed40cab76222139926b1fb5ed0e79225bc0cae14584"}, - {file = "lxml-4.9.3-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0c0850c8b02c298d3c7006b23e98249515ac57430e16a166873fc47a5d549287"}, - {file = "lxml-4.9.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aca086dc5f9ef98c512bac8efea4483eb84abbf926eaeedf7b91479feb092458"}, - {file = "lxml-4.9.3-cp36-cp36m-win32.whl", hash = "sha256:50baa9c1c47efcaef189f31e3d00d697c6d4afda5c3cde0302d063492ff9b477"}, - {file = "lxml-4.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bef4e656f7d98aaa3486d2627e7d2df1157d7e88e7efd43a65aa5dd4714916cf"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:46f409a2d60f634fe550f7133ed30ad5321ae2e6630f13657fb9479506b00601"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4c28a9144688aef80d6ea666c809b4b0e50010a2aca784c97f5e6bf143d9f129"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:53ace1c1fd5a74ef662f844a0413446c0629d151055340e9893da958a374f70d"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17a753023436a18e27dd7769e798ce302963c236bc4114ceee5b25c18c52c693"}, - {file = "lxml-4.9.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7d298a1bd60c067ea75d9f684f5f3992c9d6766fadbc0bcedd39750bf344c2f4"}, - {file = "lxml-4.9.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:081d32421db5df44c41b7f08a334a090a545c54ba977e47fd7cc2deece78809a"}, - {file = "lxml-4.9.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:23eed6d7b1a3336ad92d8e39d4bfe09073c31bfe502f20ca5116b2a334f8ec02"}, - {file = "lxml-4.9.3-cp37-cp37m-win32.whl", hash = "sha256:1509dd12b773c02acd154582088820893109f6ca27ef7291b003d0e81666109f"}, - {file = "lxml-4.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:120fa9349a24c7043854c53cae8cec227e1f79195a7493e09e0c12e29f918e52"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4d2d1edbca80b510443f51afd8496be95529db04a509bc8faee49c7b0fb6d2cc"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d7e43bd40f65f7d97ad8ef5c9b1778943d02f04febef12def25f7583d19baac"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:6fc3c450eaa0b56f815c7b62f2b7fba7266c4779adcf1cece9e6deb1de7305ce"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65299ea57d82fb91c7f019300d24050c4ddeb7c5a190e076b5f48a2b43d19c42"}, - {file = "lxml-4.9.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:eadfbbbfb41b44034a4c757fd5d70baccd43296fb894dba0295606a7cf3124aa"}, - {file = "lxml-4.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3e9bdd30efde2b9ccfa9cb5768ba04fe71b018a25ea093379c857c9dad262c40"}, - {file = "lxml-4.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fcdd00edfd0a3001e0181eab3e63bd5c74ad3e67152c84f93f13769a40e073a7"}, - {file = "lxml-4.9.3-cp38-cp38-win32.whl", hash = "sha256:57aba1bbdf450b726d58b2aea5fe47c7875f5afb2c4a23784ed78f19a0462574"}, - {file = "lxml-4.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:92af161ecbdb2883c4593d5ed4815ea71b31fafd7fd05789b23100d081ecac96"}, - {file = "lxml-4.9.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:9bb6ad405121241e99a86efff22d3ef469024ce22875a7ae045896ad23ba2340"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8ed74706b26ad100433da4b9d807eae371efaa266ffc3e9191ea436087a9d6a7"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:303bf1edce6ced16bf67a18a1cf8339d0db79577eec5d9a6d4a80f0fb10aa2da"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:5515edd2a6d1a5a70bfcdee23b42ec33425e405c5b351478ab7dc9347228f96e"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:690dafd0b187ed38583a648076865d8c229661ed20e48f2335d68e2cf7dc829d"}, - {file = "lxml-4.9.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b6420a005548ad52154c8ceab4a1290ff78d757f9e5cbc68f8c77089acd3c432"}, - {file = "lxml-4.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bb3bb49c7a6ad9d981d734ef7c7193bc349ac338776a0360cc671eaee89bcf69"}, - {file = "lxml-4.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d27be7405547d1f958b60837dc4c1007da90b8b23f54ba1f8b728c78fdb19d50"}, - {file = "lxml-4.9.3-cp39-cp39-win32.whl", hash = "sha256:8df133a2ea5e74eef5e8fc6f19b9e085f758768a16e9877a60aec455ed2609b2"}, - {file = "lxml-4.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:4dd9a263e845a72eacb60d12401e37c616438ea2e5442885f65082c276dfb2b2"}, - {file = "lxml-4.9.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6689a3d7fd13dc687e9102a27e98ef33730ac4fe37795d5036d18b4d527abd35"}, - {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f6bdac493b949141b733c5345b6ba8f87a226029cbabc7e9e121a413e49441e0"}, - {file = "lxml-4.9.3-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2006f5c8d28dee289f7020f721354362fa304acbaaf9745751ac4006650254b"}, - {file = "lxml-4.9.3-pp38-pypy38_pp73-macosx_11_0_x86_64.whl", hash = "sha256:5c245b783db29c4e4fbbbfc9c5a78be496c9fea25517f90606aa1f6b2b3d5f7b"}, - {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4fb960a632a49f2f089d522f70496640fdf1218f1243889da3822e0a9f5f3ba7"}, - {file = "lxml-4.9.3-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9719fe17307a9e814580af1f5c6e05ca593b12fb7e44fe62450a5384dbf61b4b"}, - {file = "lxml-4.9.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:3331bece23c9ee066e0fb3f96c61322b9e0f54d775fccefff4c38ca488de283a"}, - {file = "lxml-4.9.3-pp39-pypy39_pp73-macosx_11_0_x86_64.whl", hash = "sha256:ed667f49b11360951e201453fc3967344d0d0263aa415e1619e85ae7fd17b4e0"}, - {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8b77946fd508cbf0fccd8e400a7f71d4ac0e1595812e66025bac475a8e811694"}, - {file = "lxml-4.9.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fe4bda6bd4340caa6e5cf95e73f8fea5c4bfc55763dd42f1b50a94c1b4a2fbd4"}, - {file = "lxml-4.9.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f3df3db1d336b9356dd3112eae5f5c2b8b377f3bc826848567f10bfddfee77e9"}, - {file = "lxml-4.9.3.tar.gz", hash = "sha256:48628bd53a426c9eb9bc066a923acaa0878d1e86129fd5359aee99285f4eed9c"}, -] - -[package.extras] -cssselect = ["cssselect (>=0.7)"] -html5 = ["html5lib"] -htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=0.29.35)"] - -[[package]] -name = "mako" -version = "1.2.4" -description = "A super-fast templating language that borrows the best ideas from the existing templating languages." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818"}, - {file = "Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34"}, -] - -[package.dependencies] -MarkupSafe = ">=0.9.2" - -[package.extras] -babel = ["Babel"] -lingua = ["lingua"] -testing = ["pytest"] - -[[package]] -name = "markdown" -version = "3.4.4" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.7" -files = [ - {file = "Markdown-3.4.4-py3-none-any.whl", hash = "sha256:a4c1b65c0957b4bd9e7d86ddc7b3c9868fb9670660f6f99f6d1bca8954d5a941"}, - {file = "Markdown-3.4.4.tar.gz", hash = "sha256:225c6123522495d4119a90b3a3ba31a1e87a70369e03f14799ea9c0d7183a3d6"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.0)", "mkdocs-nature (>=0.4)"] -testing = ["coverage", "pyyaml"] - [[package]] name = "markupsafe" version = "2.1.3" @@ -582,107 +301,15 @@ files = [ [[package]] name = "packaging" -version = "21.3" +version = "23.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.6" -files = [ - {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, - {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, -] - -[package.dependencies] -pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" - -[[package]] -name = "painter" -version = "0.3.1" -description = "Your own expressive painter who colors text in your terminal." -optional = false -python-versions = "*" -files = [ - {file = "painter-0.3.1.tar.gz", hash = "sha256:3373463d584ba9bbbb23d570c37893e7930b93704fe1149df88e9d2ef906fc88"}, -] - -[[package]] -name = "pillow" -version = "9.5.0" -description = "Python Imaging Library (Fork)" -optional = false python-versions = ">=3.7" files = [ - {file = "Pillow-9.5.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:ace6ca218308447b9077c14ea4ef381ba0b67ee78d64046b3f19cf4e1139ad16"}, - {file = "Pillow-9.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d3d403753c9d5adc04d4694d35cf0391f0f3d57c8e0030aac09d7678fa8030aa"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ba1b81ee69573fe7124881762bb4cd2e4b6ed9dd28c9c60a632902fe8db8b38"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe7e1c262d3392afcf5071df9afa574544f28eac825284596ac6db56e6d11062"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f36397bf3f7d7c6a3abdea815ecf6fd14e7fcd4418ab24bae01008d8d8ca15e"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:252a03f1bdddce077eff2354c3861bf437c892fb1832f75ce813ee94347aa9b5"}, - {file = "Pillow-9.5.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:85ec677246533e27770b0de5cf0f9d6e4ec0c212a1f89dfc941b64b21226009d"}, - {file = "Pillow-9.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b416f03d37d27290cb93597335a2f85ed446731200705b22bb927405320de903"}, - {file = "Pillow-9.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1781a624c229cb35a2ac31cc4a77e28cafc8900733a864870c49bfeedacd106a"}, - {file = "Pillow-9.5.0-cp310-cp310-win32.whl", hash = "sha256:8507eda3cd0608a1f94f58c64817e83ec12fa93a9436938b191b80d9e4c0fc44"}, - {file = "Pillow-9.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:d3c6b54e304c60c4181da1c9dadf83e4a54fd266a99c70ba646a9baa626819eb"}, - {file = "Pillow-9.5.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:7ec6f6ce99dab90b52da21cf0dc519e21095e332ff3b399a357c187b1a5eee32"}, - {file = "Pillow-9.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:560737e70cb9c6255d6dcba3de6578a9e2ec4b573659943a5e7e4af13f298f5c"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96e88745a55b88a7c64fa49bceff363a1a27d9a64e04019c2281049444a571e3"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9c206c29b46cfd343ea7cdfe1232443072bbb270d6a46f59c259460db76779a"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcc2c53c06f2ccb8976fb5c71d448bdd0a07d26d8e07e321c103416444c7ad1"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a0f9bb6c80e6efcde93ffc51256d5cfb2155ff8f78292f074f60f9e70b942d99"}, - {file = "Pillow-9.5.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8d935f924bbab8f0a9a28404422da8af4904e36d5c33fc6f677e4c4485515625"}, - {file = "Pillow-9.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fed1e1cf6a42577953abbe8e6cf2fe2f566daebde7c34724ec8803c4c0cda579"}, - {file = "Pillow-9.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c1170d6b195555644f0616fd6ed929dfcf6333b8675fcca044ae5ab110ded296"}, - {file = "Pillow-9.5.0-cp311-cp311-win32.whl", hash = "sha256:54f7102ad31a3de5666827526e248c3530b3a33539dbda27c6843d19d72644ec"}, - {file = "Pillow-9.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfa4561277f677ecf651e2b22dc43e8f5368b74a25a8f7d1d4a3a243e573f2d4"}, - {file = "Pillow-9.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:965e4a05ef364e7b973dd17fc765f42233415974d773e82144c9bbaaaea5d089"}, - {file = "Pillow-9.5.0-cp312-cp312-win32.whl", hash = "sha256:22baf0c3cf0c7f26e82d6e1adf118027afb325e703922c8dfc1d5d0156bb2eeb"}, - {file = "Pillow-9.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:432b975c009cf649420615388561c0ce7cc31ce9b2e374db659ee4f7d57a1f8b"}, - {file = "Pillow-9.5.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:5d4ebf8e1db4441a55c509c4baa7a0587a0210f7cd25fcfe74dbbce7a4bd1906"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:375f6e5ee9620a271acb6820b3d1e94ffa8e741c0601db4c0c4d3cb0a9c224bf"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99eb6cafb6ba90e436684e08dad8be1637efb71c4f2180ee6b8f940739406e78"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfaaf10b6172697b9bceb9a3bd7b951819d1ca339a5ef294d1f1ac6d7f63270"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:763782b2e03e45e2c77d7779875f4432e25121ef002a41829d8868700d119392"}, - {file = "Pillow-9.5.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:35f6e77122a0c0762268216315bf239cf52b88865bba522999dc38f1c52b9b47"}, - {file = "Pillow-9.5.0-cp37-cp37m-win32.whl", hash = "sha256:aca1c196f407ec7cf04dcbb15d19a43c507a81f7ffc45b690899d6a76ac9fda7"}, - {file = "Pillow-9.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:322724c0032af6692456cd6ed554bb85f8149214d97398bb80613b04e33769f6"}, - {file = "Pillow-9.5.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:a0aa9417994d91301056f3d0038af1199eb7adc86e646a36b9e050b06f526597"}, - {file = "Pillow-9.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8286396b351785801a976b1e85ea88e937712ee2c3ac653710a4a57a8da5d9c"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c830a02caeb789633863b466b9de10c015bded434deb3ec87c768e53752ad22a"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbd359831c1657d69bb81f0db962905ee05e5e9451913b18b831febfe0519082"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8fc330c3370a81bbf3f88557097d1ea26cd8b019d6433aa59f71195f5ddebbf"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:7002d0797a3e4193c7cdee3198d7c14f92c0836d6b4a3f3046a64bd1ce8df2bf"}, - {file = "Pillow-9.5.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:229e2c79c00e85989a34b5981a2b67aa079fd08c903f0aaead522a1d68d79e51"}, - {file = "Pillow-9.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9adf58f5d64e474bed00d69bcd86ec4bcaa4123bfa70a65ce72e424bfb88ed96"}, - {file = "Pillow-9.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:662da1f3f89a302cc22faa9f14a262c2e3951f9dbc9617609a47521c69dd9f8f"}, - {file = "Pillow-9.5.0-cp38-cp38-win32.whl", hash = "sha256:6608ff3bf781eee0cd14d0901a2b9cc3d3834516532e3bd673a0a204dc8615fc"}, - {file = "Pillow-9.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:e49eb4e95ff6fd7c0c402508894b1ef0e01b99a44320ba7d8ecbabefddcc5569"}, - {file = "Pillow-9.5.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:482877592e927fd263028c105b36272398e3e1be3269efda09f6ba21fd83ec66"}, - {file = "Pillow-9.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3ded42b9ad70e5f1754fb7c2e2d6465a9c842e41d178f262e08b8c85ed8a1d8e"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c446d2245ba29820d405315083d55299a796695d747efceb5717a8b450324115"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aca1152d93dcc27dc55395604dcfc55bed5f25ef4c98716a928bacba90d33a3"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:608488bdcbdb4ba7837461442b90ea6f3079397ddc968c31265c1e056964f1ef"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:60037a8db8750e474af7ffc9faa9b5859e6c6d0a50e55c45576bf28be7419705"}, - {file = "Pillow-9.5.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:07999f5834bdc404c442146942a2ecadd1cb6292f5229f4ed3b31e0a108746b1"}, - {file = "Pillow-9.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a127ae76092974abfbfa38ca2d12cbeddcdeac0fb71f9627cc1135bedaf9d51a"}, - {file = "Pillow-9.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:489f8389261e5ed43ac8ff7b453162af39c3e8abd730af8363587ba64bb2e865"}, - {file = "Pillow-9.5.0-cp39-cp39-win32.whl", hash = "sha256:9b1af95c3a967bf1da94f253e56b6286b50af23392a886720f563c547e48e964"}, - {file = "Pillow-9.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:77165c4a5e7d5a284f10a6efaa39a0ae8ba839da344f20b111d62cc932fa4e5d"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:833b86a98e0ede388fa29363159c9b1a294b0905b5128baf01db683672f230f5"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaf305d6d40bd9632198c766fb64f0c1a83ca5b667f16c1e79e1661ab5060140"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0852ddb76d85f127c135b6dd1f0bb88dbb9ee990d2cd9aa9e28526c93e794fba"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:91ec6fe47b5eb5a9968c79ad9ed78c342b1f97a091677ba0e012701add857829"}, - {file = "Pillow-9.5.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cb841572862f629b99725ebaec3287fc6d275be9b14443ea746c1dd325053cbd"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:c380b27d041209b849ed246b111b7c166ba36d7933ec6e41175fd15ab9eb1572"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c9af5a3b406a50e313467e3565fc99929717f780164fe6fbb7704edba0cebbe"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5671583eab84af046a397d6d0ba25343c00cd50bce03787948e0fff01d4fd9b1"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:84a6f19ce086c1bf894644b43cd129702f781ba5751ca8572f08aa40ef0ab7b7"}, - {file = "Pillow-9.5.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1e7723bd90ef94eda669a3c2c19d549874dd5badaeefabefd26053304abe5799"}, - {file = "Pillow-9.5.0.tar.gz", hash = "sha256:bf548479d336726d7a0eceb6e767e179fbde37833ae42794602631a070d630f1"}, + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, ] -[package.extras] -docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] -tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] - [[package]] name = "prompt-toolkit" version = "3.0.39" @@ -763,45 +390,6 @@ typing-extensions = ">=4.2.0" dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] -[[package]] -name = "pyparsing" -version = "3.1.0" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.6.8" -files = [ - {file = "pyparsing-3.1.0-py3-none-any.whl", hash = "sha256:d554a96d1a7d3ddaf7183104485bc19fd80543ad6ac5bdb6426719d766fb06c1"}, - {file = "pyparsing-3.1.0.tar.gz", hash = "sha256:edb662d6fe322d6e990b1594b5feaeadf806803359e3d4d42f11e295e588f0ea"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-magic" -version = "0.4.24" -description = "File type identification using libmagic" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "python-magic-0.4.24.tar.gz", hash = "sha256:de800df9fb50f8ec5974761054a708af6e4246b03b4bdaee993f948947b0ebcf"}, - {file = "python_magic-0.4.24-py2.py3-none-any.whl", hash = "sha256:4fec8ee805fea30c07afccd1592c0f17977089895bdfaae5fec870a84e997626"}, -] - [[package]] name = "pytz" version = "2023.3" @@ -838,40 +426,51 @@ files = [ [[package]] name = "pyyaml" -version = "5.4.1" +version = "6.0.1" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.6" files = [ - {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, - {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, - {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, - {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, - {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, - {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, - {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, - {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, - {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] [[package]] @@ -891,89 +490,6 @@ prompt_toolkit = ">=2.0,<4.0" [package.extras] docs = ["Sphinx (>=3.3,<4.0)", "sphinx-autobuild (>=2020.9.1,<2021.0.0)", "sphinx-autodoc-typehints (>=1.11.1,<2.0.0)", "sphinx-copybutton (>=0.3.1,<0.4.0)", "sphinx-rtd-theme (>=0.5.0,<0.6.0)"] -[[package]] -name = "regex" -version = "2022.1.18" -description = "Alternative regular expression module, to replace re." -optional = false -python-versions = "*" -files = [ - {file = "regex-2022.1.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34316bf693b1d2d29c087ee7e4bb10cdfa39da5f9c50fa15b07489b4ab93a1b5"}, - {file = "regex-2022.1.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a0b9f6a1a15d494b35f25ed07abda03209fa76c33564c09c9e81d34f4b919d7"}, - {file = "regex-2022.1.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f99112aed4fb7cee00c7f77e8b964a9b10f69488cdff626ffd797d02e2e4484f"}, - {file = "regex-2022.1.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a2bf98ac92f58777c0fafc772bf0493e67fcf677302e0c0a630ee517a43b949"}, - {file = "regex-2022.1.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8618d9213a863c468a865e9d2ec50221015f7abf52221bc927152ef26c484b4c"}, - {file = "regex-2022.1.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b52cc45e71657bc4743a5606d9023459de929b2a198d545868e11898ba1c3f59"}, - {file = "regex-2022.1.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e12949e5071c20ec49ef00c75121ed2b076972132fc1913ddf5f76cae8d10b4"}, - {file = "regex-2022.1.18-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b02e3e72665cd02afafb933453b0c9f6c59ff6e3708bd28d0d8580450e7e88af"}, - {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:abfcb0ef78df0ee9df4ea81f03beea41849340ce33a4c4bd4dbb99e23ec781b6"}, - {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6213713ac743b190ecbf3f316d6e41d099e774812d470422b3a0f137ea635832"}, - {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:61ebbcd208d78658b09e19c78920f1ad38936a0aa0f9c459c46c197d11c580a0"}, - {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:b013f759cd69cb0a62de954d6d2096d648bc210034b79b1881406b07ed0a83f9"}, - {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9187500d83fd0cef4669385cbb0961e227a41c0c9bc39219044e35810793edf7"}, - {file = "regex-2022.1.18-cp310-cp310-win32.whl", hash = "sha256:94c623c331a48a5ccc7d25271399aff29729fa202c737ae3b4b28b89d2b0976d"}, - {file = "regex-2022.1.18-cp310-cp310-win_amd64.whl", hash = "sha256:1a171eaac36a08964d023eeff740b18a415f79aeb212169080c170ec42dd5184"}, - {file = "regex-2022.1.18-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:49810f907dfe6de8da5da7d2b238d343e6add62f01a15d03e2195afc180059ed"}, - {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d2f5c3f7057530afd7b739ed42eb04f1011203bc5e4663e1e1d01bb50f813e3"}, - {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85ffd6b1cb0dfb037ede50ff3bef80d9bf7fa60515d192403af6745524524f3b"}, - {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba37f11e1d020969e8a779c06b4af866ffb6b854d7229db63c5fdddfceaa917f"}, - {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:637e27ea1ebe4a561db75a880ac659ff439dec7f55588212e71700bb1ddd5af9"}, - {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:37978254d9d00cda01acc1997513f786b6b971e57b778fbe7c20e30ae81a97f3"}, - {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e54a1eb9fd38f2779e973d2f8958fd575b532fe26013405d1afb9ee2374e7ab8"}, - {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:768632fd8172ae03852e3245f11c8a425d95f65ff444ce46b3e673ae5b057b74"}, - {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:de2923886b5d3214be951bc2ce3f6b8ac0d6dfd4a0d0e2a4d2e5523d8046fdfb"}, - {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:1333b3ce73269f986b1fa4d5d395643810074dc2de5b9d262eb258daf37dc98f"}, - {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:d19a34f8a3429bd536996ad53597b805c10352a8561d8382e05830df389d2b43"}, - {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d2f355a951f60f0843f2368b39970e4667517e54e86b1508e76f92b44811a8a"}, - {file = "regex-2022.1.18-cp36-cp36m-win32.whl", hash = "sha256:2245441445099411b528379dee83e56eadf449db924648e5feb9b747473f42e3"}, - {file = "regex-2022.1.18-cp36-cp36m-win_amd64.whl", hash = "sha256:25716aa70a0d153cd844fe861d4f3315a6ccafce22b39d8aadbf7fcadff2b633"}, - {file = "regex-2022.1.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7e070d3aef50ac3856f2ef5ec7214798453da878bb5e5a16c16a61edf1817cc3"}, - {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22709d701e7037e64dae2a04855021b62efd64a66c3ceed99dfd684bfef09e38"}, - {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9099bf89078675c372339011ccfc9ec310310bf6c292b413c013eb90ffdcafc"}, - {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04611cc0f627fc4a50bc4a9a2e6178a974c6a6a4aa9c1cca921635d2c47b9c87"}, - {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:552a39987ac6655dad4bf6f17dd2b55c7b0c6e949d933b8846d2e312ee80005a"}, - {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e031899cb2bc92c0cf4d45389eff5b078d1936860a1be3aa8c94fa25fb46ed8"}, - {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2dacb3dae6b8cc579637a7b72f008bff50a94cde5e36e432352f4ca57b9e54c4"}, - {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:e5c31d70a478b0ca22a9d2d76d520ae996214019d39ed7dd93af872c7f301e52"}, - {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bb804c7d0bfbd7e3f33924ff49757de9106c44e27979e2492819c16972ec0da2"}, - {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:36b2d700a27e168fa96272b42d28c7ac3ff72030c67b32f37c05616ebd22a202"}, - {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:16f81025bb3556eccb0681d7946e2b35ff254f9f888cff7d2120e8826330315c"}, - {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:da80047524eac2acf7c04c18ac7a7da05a9136241f642dd2ed94269ef0d0a45a"}, - {file = "regex-2022.1.18-cp37-cp37m-win32.whl", hash = "sha256:6ca45359d7a21644793de0e29de497ef7f1ae7268e346c4faf87b421fea364e6"}, - {file = "regex-2022.1.18-cp37-cp37m-win_amd64.whl", hash = "sha256:38289f1690a7e27aacd049e420769b996826f3728756859420eeee21cc857118"}, - {file = "regex-2022.1.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6014038f52b4b2ac1fa41a58d439a8a00f015b5c0735a0cd4b09afe344c94899"}, - {file = "regex-2022.1.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0b5d6f9aed3153487252d00a18e53f19b7f52a1651bc1d0c4b5844bc286dfa52"}, - {file = "regex-2022.1.18-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d24b03daf7415f78abc2d25a208f234e2c585e5e6f92f0204d2ab7b9ab48e3"}, - {file = "regex-2022.1.18-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf594cc7cc9d528338d66674c10a5b25e3cde7dd75c3e96784df8f371d77a298"}, - {file = "regex-2022.1.18-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd914db437ec25bfa410f8aa0aa2f3ba87cdfc04d9919d608d02330947afaeab"}, - {file = "regex-2022.1.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90b6840b6448203228a9d8464a7a0d99aa8fa9f027ef95fe230579abaf8a6ee1"}, - {file = "regex-2022.1.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11772be1eb1748e0e197a40ffb82fb8fd0d6914cd147d841d9703e2bef24d288"}, - {file = "regex-2022.1.18-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a602bdc8607c99eb5b391592d58c92618dcd1537fdd87df1813f03fed49957a6"}, - {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7e26eac9e52e8ce86f915fd33380f1b6896a2b51994e40bb094841e5003429b4"}, - {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:519c0b3a6fbb68afaa0febf0d28f6c4b0a1074aefc484802ecb9709faf181607"}, - {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3c7ea86b9ca83e30fa4d4cd0eaf01db3ebcc7b2726a25990966627e39577d729"}, - {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:51f02ca184518702975b56affde6c573ebad4e411599005ce4468b1014b4786c"}, - {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:385ccf6d011b97768a640e9d4de25412204fbe8d6b9ae39ff115d4ff03f6fe5d"}, - {file = "regex-2022.1.18-cp38-cp38-win32.whl", hash = "sha256:1f8c0ae0a0de4e19fddaaff036f508db175f6f03db318c80bbc239a1def62d02"}, - {file = "regex-2022.1.18-cp38-cp38-win_amd64.whl", hash = "sha256:760c54ad1b8a9b81951030a7e8e7c3ec0964c1cb9fee585a03ff53d9e531bb8e"}, - {file = "regex-2022.1.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:93c20777a72cae8620203ac11c4010365706062aa13aaedd1a21bb07adbb9d5d"}, - {file = "regex-2022.1.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6aa427c55a0abec450bca10b64446331b5ca8f79b648531138f357569705bc4a"}, - {file = "regex-2022.1.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c38baee6bdb7fe1b110b6b3aaa555e6e872d322206b7245aa39572d3fc991ee4"}, - {file = "regex-2022.1.18-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:752e7ddfb743344d447367baa85bccd3629c2c3940f70506eb5f01abce98ee68"}, - {file = "regex-2022.1.18-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8acef4d8a4353f6678fd1035422a937c2170de58a2b29f7da045d5249e934101"}, - {file = "regex-2022.1.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c73d2166e4b210b73d1429c4f1ca97cea9cc090e5302df2a7a0a96ce55373f1c"}, - {file = "regex-2022.1.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24c89346734a4e4d60ecf9b27cac4c1fee3431a413f7aa00be7c4d7bbacc2c4d"}, - {file = "regex-2022.1.18-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:596f5ae2eeddb79b595583c2e0285312b2783b0ec759930c272dbf02f851ff75"}, - {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ecfe51abf7f045e0b9cdde71ca9e153d11238679ef7b5da6c82093874adf3338"}, - {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1d6301f5288e9bdca65fab3de6b7de17362c5016d6bf8ee4ba4cbe833b2eda0f"}, - {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:93cce7d422a0093cfb3606beae38a8e47a25232eea0f292c878af580a9dc7605"}, - {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cf0db26a1f76aa6b3aa314a74b8facd586b7a5457d05b64f8082a62c9c49582a"}, - {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:defa0652696ff0ba48c8aff5a1fac1eef1ca6ac9c660b047fc8e7623c4eb5093"}, - {file = "regex-2022.1.18-cp39-cp39-win32.whl", hash = "sha256:6db1b52c6f2c04fafc8da17ea506608e6be7086715dab498570c3e55e4f8fbd1"}, - {file = "regex-2022.1.18-cp39-cp39-win_amd64.whl", hash = "sha256:ebaeb93f90c0903233b11ce913a7cb8f6ee069158406e056f884854c737d2442"}, - {file = "regex-2022.1.18.tar.gz", hash = "sha256:97f32dc03a8054a4c4a5ab5d761ed4861e828b2c200febd4e46857069a483916"}, -] - [[package]] name = "requests" version = "2.31.0" @@ -1010,17 +526,6 @@ files = [ dev = ["Django (>=1.11)", "check-manifest", "colorama (<=0.4.1)", "coverage", "flake8", "nose2", "readme-renderer (<25.0)", "tox", "wheel", "zest.releaser[recommended]"] doc = ["Sphinx", "sphinx-rtd-theme"] -[[package]] -name = "semver" -version = "3.0.1" -description = "Python helper for Semantic Versioning (https://semver.org)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "semver-3.0.1-py3-none-any.whl", hash = "sha256:2a23844ba1647362c7490fe3995a86e097bb590d16f0f32dfc383008f19e4cdf"}, - {file = "semver-3.0.1.tar.gz", hash = "sha256:9ec78c5447883c67b97f98c3b6212796708191d22e4ad30f4570f840171cbce1"}, -] - [[package]] name = "simplejson" version = "3.19.1" @@ -1137,51 +642,6 @@ files = [ {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] -[[package]] -name = "soupsieve" -version = "2.4.1" -description = "A modern CSS selector implementation for Beautiful Soup." -optional = false -python-versions = ">=3.7" -files = [ - {file = "soupsieve-2.4.1-py3-none-any.whl", hash = "sha256:1c1bfee6819544a3447586c889157365a27e10d88cde3ad3da0cf0ddf646feb8"}, - {file = "soupsieve-2.4.1.tar.gz", hash = "sha256:89d12b2d5dfcd2c9e8c22326da9d9aa9cb3dfab0a83a024f05704076ee8d35ea"}, -] - -[[package]] -name = "splunk-appinspect" -version = "2.36.0" -description = "Automatic validation checks for Splunk Apps" -optional = false -python-versions = "*" -files = [ - {file = "splunk-appinspect-2.36.0.tar.gz", hash = "sha256:6173c03d27673508ed8622509502aeffca2eabb17b83e609f630f5d0c6cd222e"}, -] - -[package.dependencies] -beautifulsoup4 = ">=4.8.1,<5.dev0" -chardet = "3.0.4" -click = ">=7.0.0,<8.dev0" -croniter = ">0.3.34,<2" -enum34 = ">=1.1.6,<2.dev0" -future = ">=0.18.0,<1.dev0" -futures-then = ">=0.1.1,<1.dev0" -ipaddress = ">=1.0.22,<2.dev0" -jinja2 = ">=2.11.3,<4" -jsoncomment = "0.3.3" -langdetect = ">=1.0.7,<2.dev0" -lxml = ">=4.6.0,<5.dev0" -mako = ">=1.0.12,<2.dev0" -markdown = ">=3.1.1,<4.dev0" -packaging = "21.3" -painter = ">=0.3.1,<1.dev0" -pillow = "9.5.0" -python-magic = "0.4.24" -pyyaml = ">=5.4.0,<6.dev0" -regex = "2022.1.18" -semver = ">=2.13.0" -six = ">=1.12.0,<2.dev0" - [[package]] name = "splunk-packaging-toolkit" version = "1.0.1" @@ -1369,22 +829,7 @@ files = [ {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, ] -[[package]] -name = "zipp" -version = "3.16.2" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, - {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] - [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "f236d7f0b87977f477b70d675ba0681787e8fbe8f6f27751a95351d6f88e971f" +content-hash = "1a552c0b23de4a3391a2b8d6b190492e1e1a75a92647d0f7772735c98d8ca6e0"