From c9b09be506ffa8ef41b41d2bd8e5eef01acfc012 Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Tue, 10 Oct 2023 14:30:36 -0700 Subject: [PATCH 01/11] Improve local appinspect cli support. Initial stub support for appinspect api --- contentctl/actions/generate.py | 17 +++++++++-- contentctl/contentctl.py | 18 ++++++++++- contentctl/output/conf_output.py | 52 ++++++++++++++++++++------------ 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/contentctl/actions/generate.py b/contentctl/actions/generate.py index 83e98631..bbc5969f 100644 --- a/contentctl/actions/generate.py +++ b/contentctl/actions/generate.py @@ -9,11 +9,13 @@ from contentctl.output.conf_output import ConfOutput from contentctl.output.ba_yml_output import BAYmlOutput from contentctl.output.api_json_output import ApiJsonOutput - +from typing import Union @dataclass(frozen=True) class GenerateInputDto: director_input_dto: DirectorInputDto + appinspect_api_username: Union[str,None] = None + appinspect_api_password: Union[str,None] = None class Generate: @@ -24,6 +26,14 @@ def execute(self, input_dto: GenerateInputDto) -> DirectorOutputDto: director.execute(input_dto.director_input_dto) if input_dto.director_input_dto.product == SecurityContentProduct.SPLUNK_APP: + if input_dto.appinspect_api_username and input_dto.appinspect_api_password: + pass + elif input_dto.appinspect_api_username or input_dto.appinspect_api_password: + if input_dto.appinspect_api_password: + raise Exception("appinspect_api_password was provided, but appinspect_api_username was not. Please provide both or neither") + else: + raise Exception("appinspect_api_username was provided, but appinspect_api_password was not. Please provide both or neither") + conf_output = ConfOutput(input_dto.director_input_dto.input_path, input_dto.director_input_dto.config) conf_output.writeHeaders() conf_output.writeObjects(director_output_dto.detections, SecurityContentType.detections) @@ -34,8 +44,11 @@ def execute(self, input_dto: GenerateInputDto) -> DirectorOutputDto: conf_output.writeObjects(director_output_dto.macros, SecurityContentType.macros) conf_output.writeAppConf() conf_output.packageApp() - #conf_output.inspectApp() + conf_output.inspectAppCLI() + if input_dto.appinspect_api_username and input_dto.appinspect_api_password: + conf_output.inspectAppAPI(input_dto.appinspect_api_username, input_dto.appinspect_api_password) + print(f'Generate of security content successful to {conf_output.output_path}') return director_output_dto diff --git a/contentctl/contentctl.py b/contentctl/contentctl.py index ccdbe414..bf00b259 100644 --- a/contentctl/contentctl.py +++ b/contentctl/contentctl.py @@ -122,7 +122,7 @@ def build(args, config:Union[Config,None]=None) -> DirectorOutputDto: product=product_type, config=config ) - generate_input_dto = GenerateInputDto(director_input_dto) + generate_input_dto = GenerateInputDto(director_input_dto, args.appinspect_api_username, args.appinspect_api_password) generate = Generate() @@ -424,6 +424,22 @@ def main(): default="app", help="Type of package: app, ssa or api" ) + + build_parser.add_argument( + "--appinspect_api_username", + required=False, + type=str, + default=None, + help=f"Username for running AppInspect on {SecurityContentProduct.SPLUNK_APP.name} ONLY. For documentation, please review https://dev.splunk.com/enterprise/reference/appinspect/appinspectapiepref" + ) + build_parser.add_argument( + "--appinspect_api_password", + required=False, + type=str, + default=None, + help=f"Password for running AppInspect on {SecurityContentProduct.SPLUNK_APP.name} ONLY. For documentation, please review https://dev.splunk.com/enterprise/reference/appinspect/appinspectapiepref" + ) + build_parser.set_defaults(func=build) docs_parser.set_defaults(func=doc_gen) diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index 4115a5fd..9c22a854 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -19,7 +19,7 @@ class ConfOutput: input_path: str config: Config output_path: pathlib.Path - + def __init__(self, input_path: str, config: Config): self.input_path = input_path @@ -29,6 +29,11 @@ def __init__(self, input_path: str, config: Config): template_splunk_app_path = os.path.join(os.path.dirname(__file__), 'templates/splunk_app') shutil.copytree(template_splunk_app_path, self.output_path, dirs_exist_ok=True) + def getPackagePath(self, include_version:bool=False)->pathlib.Path: + if include_version: + return self.output_path / f"{self.config.build.name}-{self.config.build.version}.tar.gz" + else: + return self.output_path / f"{self.config.build.name}.tar.gz" def writeHeaders(self) -> None: ConfWriter.writeConfFileHeader(self.output_path/'default/analyticstories.conf', self.config) @@ -152,7 +157,6 @@ def packageApp(self) -> None: # if not readme_file.is_file(): # raise Exception("The README file does not exist in this directory. Cannot build app.") # shutil.copyfile(readme_file, input_app_path/readme_file.name) - output_app_expected_name = pathlib.Path(os.path.join(self.input_path, self.config.build.path_root))/f"{self.config.build.name}-{self.config.build.version}.tar.gz" # try: @@ -176,19 +180,24 @@ def packageApp(self) -> None: # except SystemExit as e: # raise Exception(f"Error building package with slim: {str(e)}") # else: - with tarfile.open(output_app_expected_name, "w:gz") as app_archive: + with tarfile.open(self.getPackagePath(include_version=True), "w:gz") as app_archive: app_archive.add(self.output_path, arcname=os.path.basename(self.output_path)) - - if not output_app_expected_name.exists(): - raise (Exception(f"The expected output app path '{output_app_expected_name}' does not exist")) - - def inspectApp(self)-> None: + if not self.output_path.exists(): + raise (Exception(f"The expected output app path '{self.getPackagePath(include_version=True)}' does not exist")) - output_app_expected_name = pathlib.Path(self.config.build.path_root)/f"{self.config.build.name}-{self.config.build.version}.tar.gz" - name_without_version = pathlib.Path(self.config.build.path_root)/f"{self.config.build.name}.tar.gz" - shutil.copy2(output_app_expected_name, name_without_version, follow_symlinks=False) + shutil.copy2(self.getPackagePath(include_version=True), + self.getPackagePath(include_version=False), + follow_symlinks=False) + + + + def inspectAppAPI(self, username:str, password:str)->None: + print("we would appinspect api now!") + return None + + def inspectAppCLI(self)-> None: try: from splunk_appinspect.main import ( @@ -197,10 +206,13 @@ def inspectApp(self)-> None: PRECERT_MODE, TEST_MODE) except Exception as e: print("******WARNING******") - if sys.version_info.major == 3 and sys.version_info.minor == 9: + if sys.version_info.major == 3 and sys.version_info.minor > 9: print("The package splunk-appinspect was not installed due to a current issue with the library on Python3.10+. " - "Please use the following commands to set up a virtualenvironment in a different folder so you may run appinspect manually:" - f"\n\tpython3.9 -m venv .venv; source .venv/bin/activate; python3 -m pip install splunk-appinspect; splunk-appinspect inspect {name_without_version} --mode precert") + "Please use the following commands to set up a virtualenvironment in a different folder so you may run appinspect manually (if desired):" + "\n\tpython3.9 -m venv .venv" + "\n\tsource .venv/bin/activate" + "\n\tpython3 -m pip install splunk-appinspect" + f"\n\tsplunk-appinspect inspect {self.getPackagePath(include_version=False).relative_to(pathlib.Path('.').absolute())} --mode precert") else: print("splunk-appinspect is only compatable with Python3.9 at this time. Please see the following open issue here: https://github.com/splunk/contentctl/issues/28") @@ -218,7 +230,7 @@ def inspectApp(self)-> None: appinspect_output = pathlib.Path(self.config.build.path_root)/"appinspect_results.json" appinspect_logging = pathlib.Path(self.config.build.path_root)/"appinspect_logging.log" try: - arguments_list = [(APP_PACKAGE_ARGUMENT, str(name_without_version))] + arguments_list = [(APP_PACKAGE_ARGUMENT, str(self.getPackagePath(include_version=False)))] options_list = [] options_list += [MODE_OPTION, TEST_MODE] options_list += [OUTPUT_FILE_OPTION, str(appinspect_output)] @@ -258,7 +270,7 @@ def inspectApp(self)-> None: j = json.load(logfile) #Seek back to the beginning of the file. We don't need to clear #it sice we will always write AT LEAST the same number of characters - #back as we read + #back as we read (due to the addition of whitespace) logfile.seek(0) json.dump(j, logfile, indent=3, ) bad_stuff = ["error", "failure", "manual_check", "warning"] @@ -270,7 +282,7 @@ def inspectApp(self)-> None: 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.append(f" - {check.get('result','')} [{group.get('name','NONAME')}: {check.get('name', 'NONAME')}]") verbose_errors.sort() summary = j.get("summary", None) @@ -279,10 +291,10 @@ def inspectApp(self)-> None: msgs = [] for key in bad_stuff: if summary.get(key,0)>0: - msgs.append(f"{summary.get(key,0)} {key}s") + 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) + 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: From d19acc44fe21174aa19a404a68b30ec9b7a839a0 Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Tue, 10 Oct 2023 17:05:08 -0700 Subject: [PATCH 02/11] Significant progress on appinspect api usgae in python. Just need to check for final results and do final error handling --- contentctl/output/conf_output.py | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index 9c22a854..335cf41c 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -195,6 +195,57 @@ def packageApp(self) -> None: def inspectAppAPI(self, username:str, password:str)->None: print("we would appinspect api now!") + from requests import Session, post, get + from requests.auth import HTTPBasicAuth + + session = Session() + session.auth = HTTPBasicAuth(username, password) + APPINSPECT_API_LOGIN = "https://api.splunk.com/2.0/rest/login/splunk" + res = session.get(APPINSPECT_API_LOGIN) + #If login failed or other failure, raise an exception + res.raise_for_status() + + appinspect_token = res.json().get("data",{}).get("token",None) + APPINSPECT_API_VALIDATION_REQUEST = "https://appinspect.splunk.com/v1/app/validate" + headers = { + "Authorization": f"bearer {appinspect_token}", + "Cache-Control": "no-cache" + } + files = { + "app_package": open(self.getPackagePath(include_version=False),"rb"), + "included_tags":(None,"cloud") + } + + res = post(APPINSPECT_API_VALIDATION_REQUEST, headers=headers, files=files) + + res.raise_for_status() + + request_id = res.json().get("request_id",None) + APPINSPECT_API_VALIDATION_STATUS = f"https://appinspect.splunk.com/v1/app/validate/status/{request_id}" + headers = headers = { + "Authorization": f"bearer {appinspect_token}" + } + while True: + import time + time.sleep(15) + res = get(APPINSPECT_API_VALIDATION_STATUS, headers=headers) + res.raise_for_status() + status = res.json().get("status",None) + if status in ["PROCESSING", "PREPARING"]: + print(f"Appinspect API is {status}...") + continue + elif status == "SUCCESS": + print("Appinspect API has finished") + break + else: + raise Exception(f"Error - Unknown Appinspect API status '{status}'") + + + + + + + return None def inspectAppCLI(self)-> None: From b8f3cf2df6f63b4107c5da5fbc7667d42b17141b Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Wed, 11 Oct 2023 16:00:06 -0700 Subject: [PATCH 03/11] Appinspect api results being written to disk, but we sstill need to fully parse them and fail on any warnings. There are also some contentctl issues we need to account for so that it does not throw failures. --- contentctl/output/conf_output.py | 58 +++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index 335cf41c..3c5da509 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -7,7 +7,9 @@ from typing import Union from pathlib import Path import pathlib - +import time +import timeit +import datetime import shutil from contentctl.output.conf_writer import ConfWriter from contentctl.objects.enums import SecurityContentType @@ -24,16 +26,17 @@ class ConfOutput: def __init__(self, input_path: str, config: Config): self.input_path = input_path self.config = config - self.output_path = pathlib.Path(os.path.join(self.input_path, self.config.build.path_root)) /self.config.build.name + self.dist = pathlib.Path(self.input_path, self.config.build.path_root) + self.output_path = self.dist/self.config.build.name self.output_path.mkdir(parents=True, exist_ok=True) template_splunk_app_path = os.path.join(os.path.dirname(__file__), 'templates/splunk_app') shutil.copytree(template_splunk_app_path, self.output_path, dirs_exist_ok=True) def getPackagePath(self, include_version:bool=False)->pathlib.Path: if include_version: - return self.output_path / f"{self.config.build.name}-{self.config.build.version}.tar.gz" + return self.dist / f"{self.config.build.name}-{self.config.build.version}.tar.gz" else: - return self.output_path / f"{self.config.build.name}.tar.gz" + return self.dist / f"{self.config.build.name}.tar.gz" def writeHeaders(self) -> None: ConfWriter.writeConfFileHeader(self.output_path/'default/analyticstories.conf', self.config) @@ -192,7 +195,9 @@ def packageApp(self) -> None: follow_symlinks=False) - + def getElapsedTime(self, startTime:float)->datetime.timedelta: + return datetime.timedelta(seconds=round(timeit.default_timer() - startTime)) + def inspectAppAPI(self, username:str, password:str)->None: print("we would appinspect api now!") from requests import Session, post, get @@ -225,26 +230,49 @@ def inspectAppAPI(self, username:str, password:str)->None: headers = headers = { "Authorization": f"bearer {appinspect_token}" } + startTime = timeit.default_timer() while True: - import time - time.sleep(15) + res = get(APPINSPECT_API_VALIDATION_STATUS, headers=headers) res.raise_for_status() status = res.json().get("status",None) if status in ["PROCESSING", "PREPARING"]: - print(f"Appinspect API is {status}...") + print(f"[{self.getElapsedTime(startTime)}] Appinspect API is {status}...") + time.sleep(15) continue elif status == "SUCCESS": - print("Appinspect API has finished") + print(f"[{self.getElapsedTime(startTime)}] Appinspect API has finished!") break else: raise Exception(f"Error - Unknown Appinspect API status '{status}'") - - + - - + #We have finished running appinspect, so get the report + APPINSPECT_API_REPORT = f"https://appinspect.splunk.com/v1/app/report/{request_id}" + #Get human-readable HTML report + headers = headers = { + "Authorization": f"bearer {appinspect_token}", + "Content-Type": "text/html" + } + res = get(APPINSPECT_API_REPORT, headers=headers) + res.raise_for_status() + report_html = res.content + + #Get JSON report for processing + headers = headers = { + "Authorization": f"bearer {appinspect_token}", + "Content-Type": "application/json" + } + res = get(APPINSPECT_API_REPORT, headers=headers) + res.raise_for_status() + report_json = res.json() + import json + + with open(self.dist/f"{self.config.build.name}-{self.config.build.version}.appinspect_api_results.html", "wb") as report: + report.write(report_html) + with open(self.dist/f"{self.config.build.name}-{self.config.build.version}.appinspect_api_results.json", "w") as report: + json.dump(report_json, report,indent=3) return None @@ -278,8 +306,8 @@ def inspectAppCLI(self)-> None: included_tags = [] excluded_tags = [] - appinspect_output = pathlib.Path(self.config.build.path_root)/"appinspect_results.json" - appinspect_logging = pathlib.Path(self.config.build.path_root)/"appinspect_logging.log" + appinspect_output = self.dist/f"{self.config.build.name}-{self.config.build.version}.appinspect_cli_results.json" + appinspect_logging = self.dist/f"{self.config.build.name}-{self.config.build.version}.appinspect_cli_logging.log" try: arguments_list = [(APP_PACKAGE_ARGUMENT, str(self.getPackagePath(include_version=False)))] options_list = [] From 6abe444dec7d46bf0a737df9e3e71c335ef02762 Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Thu, 12 Oct 2023 15:59:33 -0700 Subject: [PATCH 04/11] Improve generation of app.manifest and app.conf files to resolve appinspect issues. --- contentctl/actions/api_deploy.py | 4 +- contentctl/contentctl.py | 6 +-- contentctl/objects/config.py | 4 +- contentctl/output/conf_output.py | 24 +++++----- contentctl/output/conf_writer.py | 8 +++- contentctl/output/templates/app.conf.j2 | 4 +- contentctl/output/templates/app.manifest.j2 | 12 ++--- .../stories/splunk_app/metadata/app.conf.j2 | 45 ------------------- .../stories/splunk_app/metadata/default.meta | 6 --- 9 files changed, 32 insertions(+), 81 deletions(-) delete mode 100644 contentctl/templates/stories/splunk_app/metadata/app.conf.j2 delete mode 100644 contentctl/templates/stories/splunk_app/metadata/default.meta diff --git a/contentctl/actions/api_deploy.py b/contentctl/actions/api_deploy.py index 04d17f7b..eb466385 100644 --- a/contentctl/actions/api_deploy.py +++ b/contentctl/actions/api_deploy.py @@ -36,11 +36,11 @@ def fix_newlines_in_conf_files(self, conf_path: pathlib.Path) -> RawConfigParser def execute(self, input_dto: API_DeployInputDto) -> None: if len(input_dto.config.deployments.rest_api_deployments) == 0: raise Exception("No rest_api_deployments defined in 'contentctl.yml'") - app_path = pathlib.Path(input_dto.config.build.path_root)/input_dto.config.build.name + app_path = pathlib.Path(input_dto.config.build.path_root)/input_dto.config.build.title if not app_path.is_dir(): raise Exception(f"The unpackaged app does not exist at the path {app_path}. Please run 'contentctl build' to generate the app.") for target in input_dto.config.deployments.rest_api_deployments: - print(f"Deploying '{input_dto.config.build.name}' to target '{target.server}' [{target.description}]") + print(f"Deploying '{input_dto.config.build.title}' to target '{target.server}' [{target.description}]") splunk_args = { "host": target.server, "port": target.port, diff --git a/contentctl/contentctl.py b/contentctl/contentctl.py index bf00b259..ff832728 100644 --- a/contentctl/contentctl.py +++ b/contentctl/contentctl.py @@ -212,11 +212,11 @@ def test(args: argparse.Namespace): # be able to do it in Test().execute. For now, we will do it here app = App( uid=9999, - appid=config.build.name, - title=config.build.name, + appid=config.build.title, + title=config.build.title, release=config.build.version, http_path=None, - local_path=str(pathlib.Path(config.build.path_root)/f"{config.build.name}-{config.build.version}.tar.gz"), + local_path=str(pathlib.Path(config.build.path_root)/f"{config.build.title}-{config.build.version}.tar.gz"), description=config.build.description, splunkbase_path=None, force_local=True diff --git a/contentctl/objects/config.py b/contentctl/objects/config.py index a869de92..1bbfdbaa 100644 --- a/contentctl/objects/config.py +++ b/contentctl/objects/config.py @@ -99,7 +99,7 @@ class ConfigBuildBa(BaseModel): class ConfigBuild(BaseModel): # Fields required for app.conf based on # https://docs.splunk.com/Documentation/Splunk/9.0.4/Admin/Appconf - name: str = Field(default="ContentPack",title="Internal name used by your app. No spaces or special characters.") + title: str = Field(default="ContentPack",title="Internal name used by your app. No spaces or special characters.") path_root: str = Field(default="dist",title="The root path at which you will build your app.") prefix: str = Field(default="ContentPack",title="A short prefix to easily identify all your content.") build: int = Field(default=int(datetime.utcnow().strftime("%Y%m%d%H%M%S")), @@ -120,7 +120,7 @@ class ConfigBuild(BaseModel): # * must not be any of the following names: CON, PRN, AUX, NUL, # COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9, # LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9 - id: str = Field(default="ContentPack",title="Internal name used by your app. No spaces or special characters.") + name: str = Field(default="ContentPack",title="Internal name used by your app. No spaces or special characters.") label: str = Field(default="Custom Splunk Content Pack",title="This is the app name that shows in the launcher.") author_name: str = Field(default="author name",title="Name of the Content Pack Author.") author_email: str = Field(default="author@contactemailaddress.com",title="Contact email for the Content Pack Author") diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index 3c5da509..9bf53c3d 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -14,7 +14,8 @@ from contentctl.output.conf_writer import ConfWriter from contentctl.objects.enums import SecurityContentType from contentctl.objects.config import Config - +from requests import Session, post, get +from requests.auth import HTTPBasicAuth class ConfOutput: @@ -27,16 +28,16 @@ def __init__(self, input_path: str, config: Config): self.input_path = input_path self.config = config self.dist = pathlib.Path(self.input_path, self.config.build.path_root) - self.output_path = self.dist/self.config.build.name + self.output_path = self.dist/self.config.build.title self.output_path.mkdir(parents=True, exist_ok=True) template_splunk_app_path = os.path.join(os.path.dirname(__file__), 'templates/splunk_app') shutil.copytree(template_splunk_app_path, self.output_path, dirs_exist_ok=True) def getPackagePath(self, include_version:bool=False)->pathlib.Path: if include_version: - return self.dist / f"{self.config.build.name}-{self.config.build.version}.tar.gz" + return self.dist / f"{self.config.build.title}-{self.config.build.version}.tar.gz" else: - return self.dist / f"{self.config.build.name}.tar.gz" + return self.dist / f"{self.config.build.title}.tar.gz" def writeHeaders(self) -> None: ConfWriter.writeConfFileHeader(self.output_path/'default/analyticstories.conf', self.config) @@ -53,7 +54,8 @@ def writeHeaders(self) -> None: def writeAppConf(self): ConfWriter.writeConfFile(self.output_path/"default"/"app.conf", "app.conf.j2", self.config, [self.config.build] ) ConfWriter.writeConfFile(self.output_path/"default"/"content-version.conf", "content-version.j2", self.config, [self.config.build] ) - ConfWriter.writeConfFile(self.output_path/"app.manifest", "app.manifest.j2", self.config, [self.config.build] ) + ConfWriter.writeConfFile(self.output_path/"app.manifest", "app.manifest.j2", self.config, [self.config.build],append=False) + input(self.output_path/"app.manifest") def writeObjects(self, objects: list, type: SecurityContentType = None) -> None: if type == SecurityContentType.detections: @@ -199,10 +201,6 @@ def getElapsedTime(self, startTime:float)->datetime.timedelta: return datetime.timedelta(seconds=round(timeit.default_timer() - startTime)) def inspectAppAPI(self, username:str, password:str)->None: - print("we would appinspect api now!") - from requests import Session, post, get - from requests.auth import HTTPBasicAuth - session = Session() session.auth = HTTPBasicAuth(username, password) APPINSPECT_API_LOGIN = "https://api.splunk.com/2.0/rest/login/splunk" @@ -269,9 +267,9 @@ def inspectAppAPI(self, username:str, password:str)->None: report_json = res.json() import json - with open(self.dist/f"{self.config.build.name}-{self.config.build.version}.appinspect_api_results.html", "wb") as report: + with open(self.dist/f"{self.config.build.title}-{self.config.build.version}.appinspect_api_results.html", "wb") as report: report.write(report_html) - with open(self.dist/f"{self.config.build.name}-{self.config.build.version}.appinspect_api_results.json", "w") as report: + with open(self.dist/f"{self.config.build.title}-{self.config.build.version}.appinspect_api_results.json", "w") as report: json.dump(report_json, report,indent=3) return None @@ -306,8 +304,8 @@ def inspectAppCLI(self)-> None: included_tags = [] excluded_tags = [] - appinspect_output = self.dist/f"{self.config.build.name}-{self.config.build.version}.appinspect_cli_results.json" - appinspect_logging = self.dist/f"{self.config.build.name}-{self.config.build.version}.appinspect_cli_logging.log" + appinspect_output = self.dist/f"{self.config.build.title}-{self.config.build.version}.appinspect_cli_results.json" + appinspect_logging = self.dist/f"{self.config.build.title}-{self.config.build.version}.appinspect_cli_logging.log" try: arguments_list = [(APP_PACKAGE_ARGUMENT, str(self.getPackagePath(include_version=False)))] options_list = [] diff --git a/contentctl/output/conf_writer.py b/contentctl/output/conf_writer.py index da6ba4f0..ede4bb6d 100644 --- a/contentctl/output/conf_writer.py +++ b/contentctl/output/conf_writer.py @@ -31,7 +31,7 @@ def writeConfFileHeaderEmpty(output_path:pathlib.Path, config: Config) -> None: @staticmethod - def writeConfFile(output_path:pathlib.Path, template_name : str, config: Config, objects : list) -> None: + def writeConfFile(output_path:pathlib.Path, template_name : str, config: Config, objects : list, append:bool=True) -> None: def custom_jinja2_enrichment_filter(string, object): customized_string = string @@ -61,7 +61,11 @@ def custom_jinja2_enrichment_filter(string, object): template = j2_env.get_template(template_name) output = template.render(objects=objects, APP_NAME=config.build.prefix) output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, 'a') as f: + if append: + file_mode = 'a' + else: + file_mode = 'w' + with open(output_path, file_mode) as f: output = output.encode('ascii', 'ignore').decode('ascii') f.write(output) diff --git a/contentctl/output/templates/app.conf.j2 b/contentctl/output/templates/app.conf.j2 index e7954172..190ba31e 100644 --- a/contentctl/output/templates/app.conf.j2 +++ b/contentctl/output/templates/app.conf.j2 @@ -25,10 +25,10 @@ description = {{ objects[0].description }} [ui] is_visible = true -label = {{ objects[0].label }} +label = {{ objects[0].title }} [package] -id = {{ objects[0].id }} +id = {{ objects[0].name }} diff --git a/contentctl/output/templates/app.manifest.j2 b/contentctl/output/templates/app.manifest.j2 index 594f3058..42737a6a 100644 --- a/contentctl/output/templates/app.manifest.j2 +++ b/contentctl/output/templates/app.manifest.j2 @@ -1,21 +1,21 @@ { "schemaVersion": "1.0.0", "info": { - "title": "ES Content Updates", + "title": "{{ objects[0].title }}", "id": { "group": null, - "name": "DA-ESS-ContentUpdate", + "name": "{{ objects[0].name }}", "version": "{{ objects[0].version }}" }, "author": [ { - "name": "Splunk Security Research Team", - "email": "research@splunk.com", - "company": "Splunk" + "name": "{{ objects[0].author_name }}", + "email": "{{ objects[0].author_email }}", + "company": "{{ objects[0].author_company }}" } ], "releaseDate": null, - "description": "Explore the Analytic Stories included with ES Content Updates.", + "description": "{{ objects[0].description }}", "classification": { "intendedAudience": null, "categories": [], diff --git a/contentctl/templates/stories/splunk_app/metadata/app.conf.j2 b/contentctl/templates/stories/splunk_app/metadata/app.conf.j2 deleted file mode 100644 index 98f333f4..00000000 --- a/contentctl/templates/stories/splunk_app/metadata/app.conf.j2 +++ /dev/null @@ -1,45 +0,0 @@ -## Splunk app configuration file -## For verbose documenation please see: -## https://docs.splunk.com/Documentation/Splunk/9.0.4/Admin/Appconf - -[author={{ conf.author_name }}] -email = ={{ conf.email }} -company = ={{ conf.company }} - -[id] -name = {{ conf.name }} -version = {{ conf.version }} - -[launcher] -author = {{ conf.company }} ({{ conf.author }}) -version = ={{ conf.version }} -description = ={{ conf.description }} - -[package] -id = {{ conf.id }} - -[install] -is_configured = false -state = enabled -state_change_requires_restart = false -build = {{ conf.build }} - -[triggers] -reload.analytic_stories = simple -reload.usage_searches = simple -reload.use_case_library = simple -reload.correlationsearches = simple -reload.analyticstories = simple -reload.governance = simple -reload.managed_configurations = simple -reload.postprocess = simple -reload.content-version = simple -reload.es_investigations = simple - - - -[ui] -is_visible = true -label = {{ conf.label }} - - diff --git a/contentctl/templates/stories/splunk_app/metadata/default.meta b/contentctl/templates/stories/splunk_app/metadata/default.meta deleted file mode 100644 index f9364dd1..00000000 --- a/contentctl/templates/stories/splunk_app/metadata/default.meta +++ /dev/null @@ -1,6 +0,0 @@ -[] -access = read : [ * ], write : [ admin ] -export = system - -[savedsearches] -owner = admin \ No newline at end of file From f4b5c5a002a3a8601384f0f1859626c73bde606f Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Thu, 12 Oct 2023 17:18:24 -0700 Subject: [PATCH 05/11] A few other small changes to make sure directory has correct name and that build is always updated with current utc timestamp. --- contentctl/objects/config.py | 6 ++++++ contentctl/output/conf_output.py | 7 +++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/contentctl/objects/config.py b/contentctl/objects/config.py index 1bbfdbaa..2169647a 100644 --- a/contentctl/objects/config.py +++ b/contentctl/objects/config.py @@ -138,6 +138,12 @@ def validate_version(cls, v, values): except Exception as e: raise(ValueError(f"The specified version does not follow the semantic versioning spec (https://semver.org/). {str(e)}")) return v + + #Build will ALWAYS be the current utc timestamp + @validator('build', always=True) + def validate_build(cls, v, values): + return int(datetime.utcnow().strftime("%Y%m%d%H%M%S")) + diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index 9bf53c3d..8118553d 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -28,16 +28,16 @@ def __init__(self, input_path: str, config: Config): self.input_path = input_path self.config = config self.dist = pathlib.Path(self.input_path, self.config.build.path_root) - self.output_path = self.dist/self.config.build.title + self.output_path = self.dist/self.config.build.name self.output_path.mkdir(parents=True, exist_ok=True) template_splunk_app_path = os.path.join(os.path.dirname(__file__), 'templates/splunk_app') shutil.copytree(template_splunk_app_path, self.output_path, dirs_exist_ok=True) def getPackagePath(self, include_version:bool=False)->pathlib.Path: if include_version: - return self.dist / f"{self.config.build.title}-{self.config.build.version}.tar.gz" + return self.dist / f"{self.config.build.name}-{self.config.build.version}.tar.gz" else: - return self.dist / f"{self.config.build.title}.tar.gz" + return self.dist / f"{self.config.build.name}.tar.gz" def writeHeaders(self) -> None: ConfWriter.writeConfFileHeader(self.output_path/'default/analyticstories.conf', self.config) @@ -55,7 +55,6 @@ def writeAppConf(self): ConfWriter.writeConfFile(self.output_path/"default"/"app.conf", "app.conf.j2", self.config, [self.config.build] ) ConfWriter.writeConfFile(self.output_path/"default"/"content-version.conf", "content-version.j2", self.config, [self.config.build] ) ConfWriter.writeConfFile(self.output_path/"app.manifest", "app.manifest.j2", self.config, [self.config.build],append=False) - input(self.output_path/"app.manifest") def writeObjects(self, objects: list, type: SecurityContentType = None) -> None: if type == SecurityContentType.detections: From 11bfcbf2d1b67371562d31af17519a80f82734e4 Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:09:12 -0700 Subject: [PATCH 06/11] Wrap up appinspect api work. Check for non-ascii characters and extra spacing in string fields. --- .../detection_abstract.py | 2 +- .../security_content_object_abstract.py | 17 +++- contentctl/objects/playbook.py | 6 +- contentctl/output/conf_output.py | 94 +++++++++++-------- 4 files changed, 76 insertions(+), 43 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index a554f703..2d524a8d 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -84,7 +84,7 @@ def type_valid(cls, v, values): raise ValueError("not valid analytics type: " + values["name"]) return v - @validator('how_to_implement') + @validator('how_to_implement', 'search', 'known_false_positives') def encode_error(cls, v, values, field): return SecurityContentObject.free_text_field_valid(cls,v,values,field) 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 84a46a47..c7a3da42 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,5 +1,5 @@ from __future__ import annotations - +import re import abc import string import uuid @@ -7,6 +7,7 @@ from pydantic import BaseModel, validator, ValidationError, Field from contentctl.objects.enums import SecurityContentType from typing import Tuple + import uuid import pathlib @@ -44,14 +45,21 @@ def date_valid(cls, v, values): @staticmethod def free_text_field_valid(input_cls, v, values, field): + try: v.encode('ascii') except UnicodeEncodeError: - raise ValueError('encoding error in ' + field.name + ': ' + values["name"]) + raise ValueError(f"Ascii encoding error in {field.name} : {values['name']} with value '{v}' - {str(e)}") + + + if bool(re.search(r"[^\\]\n", v)): + raise ValueError(f"Unexpected newline(s) in {field.name}. Newline characters MUST be prefixed with \\: {values['name']} with value '{v}'") return v - @validator('description') + + @validator("name", "author", 'description') def description_valid(cls, v, values, field): + return SecurityContentObject_Abstract.free_text_field_valid(cls,v,values,field) @@ -70,4 +78,5 @@ def create_filename_to_content_dict(all_objects:list[SecurityContentObject_Abstr name_dict[str(pathlib.Path(object.file_path))] = object return name_dict - \ No newline at end of file + + \ No newline at end of file diff --git a/contentctl/objects/playbook.py b/contentctl/objects/playbook.py index d9de2761..801cebcd 100644 --- a/contentctl/objects/playbook.py +++ b/contentctl/objects/playbook.py @@ -29,4 +29,8 @@ class Playbook(SecurityContentObject): @validator('references') def references_check(cls, v, values): - return LinkValidator.SecurityContentObject_validate_references(v, values) \ No newline at end of file + return LinkValidator.SecurityContentObject_validate_references(v, values) + + @validator('how_to_implement') + def encode_error(cls, v, values, field): + return SecurityContentObject.free_text_field_valid(cls,v,values,field) \ No newline at end of file diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index 8118553d..b1b69dbd 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -11,6 +11,7 @@ import timeit import datetime import shutil +import json from contentctl.output.conf_writer import ConfWriter from contentctl.objects.enums import SecurityContentType from contentctl.objects.config import Config @@ -264,15 +265,65 @@ def inspectAppAPI(self, username:str, password:str)->None: res = get(APPINSPECT_API_REPORT, headers=headers) res.raise_for_status() report_json = res.json() - import json + with open(self.dist/f"{self.config.build.title}-{self.config.build.version}.appinspect_api_results.html", "wb") as report: report.write(report_html) with open(self.dist/f"{self.config.build.title}-{self.config.build.version}.appinspect_api_results.json", "w") as report: - json.dump(report_json, report,indent=3) + json.dump(report_json, report) + + + self.parseAppinspectJsonLogFile(self.dist/f"{self.config.build.title}-{self.config.build.version}.appinspect_api_results.json") return None + def parseAppinspectJsonLogFile(self, logfile_path:pathlib.Path, + status_types:list[str] = ["error", "failure", "manual_check", "warning"], + exception_types = ["error","failure"] )->None: + if not set(exception_types).issubset(set(status_types)): + raise Exception(f"Error - exception_types {exception_types} MUST be a subset of status_types {status_types}, but it is not") + with open(logfile_path, "r+") as logfile: + j = json.load(logfile) + #Seek back to the beginning of the file. We don't need to clear + #it sice we will always write AT LEAST the same number of characters + #back as we read (due to the addition of whitespace) + logfile.seek(0) + json.dump(j, logfile, indent=3, ) + + 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 status_types: + verbose_errors.append(f" - {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 = [] + generated_exception = False + for key in status_types: + if summary.get(key,0)>0: + msgs.append(f" - {summary.get(key,0)} {key}s") + if key in exception_types: + generated_exception = True + if len(msgs)>0 or len(verbose_errors): + summary = '\n'.join(msgs) + details = '\n'.join(verbose_errors) + summary = f"{summary}\nDetails:\n{details}" + if generated_exception: + raise Exception(f"AppInspect found [{','.join(exception_types)}] that MUST be addressed to pass AppInspect API:\n{summary}") + else: + print(f"AppInspect found [{','.join(status_types)}] that MAY cause a failure during AppInspect API:\n{summary}") + else: + print("AppInspect was successful!") + + return + def inspectAppCLI(self)-> None: try: @@ -340,38 +391,7 @@ def inspectAppCLI(self)-> None: # appinspect outputs the log in json format, but does not format it to be easier # to read (it is all in one line). Read back that file and write it so it # is easier to understand - try: - with open(appinspect_output, "r+") as logfile: - import json - j = json.load(logfile) - #Seek back to the beginning of the file. We don't need to clear - #it sice we will always write AT LEAST the same number of characters - #back as we read (due to the addition of whitespace) - 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" - {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 + + #Note that this may raise an exception itself! + self.parseAppinspectJsonLogFile(appinspect_output) + \ No newline at end of file From 9e7100a7f6183f963dd5bd63447b36af3eca6813 Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Mon, 16 Oct 2023 17:01:00 -0700 Subject: [PATCH 07/11] Improved error message for ascii or newline encoding issue --- .../security_content_object_abstract.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 c7a3da42..f253df10 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 @@ -48,12 +48,12 @@ def free_text_field_valid(input_cls, v, values, field): try: v.encode('ascii') - except UnicodeEncodeError: - raise ValueError(f"Ascii encoding error in {field.name} : {values['name']} with value '{v}' - {str(e)}") + except UnicodeEncodeError as e: + raise ValueError(f"Ascii encoding error in {values['name']}:{field.name} - {str(e)}") if bool(re.search(r"[^\\]\n", v)): - raise ValueError(f"Unexpected newline(s) in {field.name}. Newline characters MUST be prefixed with \\: {values['name']} with value '{v}'") + raise ValueError(f"Unexpected newline(s) in {values['name']}:{field.name}. Newline characters MUST be prefixed with \\") return v From 6df42f60ee64a891723c4d08eb0ebac89629b0fa Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Thu, 19 Oct 2023 10:25:28 -0700 Subject: [PATCH 08/11] Add/fix support for appinspect api usage in contentctl test. Re add notable/message parsing and validation --- contentctl/contentctl.py | 18 +++++++ .../detection_abstract.py | 49 +++++++++---------- .../security_content_object_abstract.py | 2 +- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/contentctl/contentctl.py b/contentctl/contentctl.py index ff832728..02d0c58c 100644 --- a/contentctl/contentctl.py +++ b/contentctl/contentctl.py @@ -516,6 +516,24 @@ def main(): "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='+') + + #Even though these are also options to build, make them available to test_parser + #as well to make the tool easier to use + test_parser.add_argument( + "--appinspect_api_username", + required=False, + type=str, + default=None, + help=f"Username for running AppInspect on {SecurityContentProduct.SPLUNK_APP.name} ONLY. For documentation, please review https://dev.splunk.com/enterprise/reference/appinspect/appinspectapiepref" + ) + test_parser.add_argument( + "--appinspect_api_password", + required=False, + type=str, + default=None, + help=f"Password for running AppInspect on {SecurityContentProduct.SPLUNK_APP.name} ONLY. For documentation, please review https://dev.splunk.com/enterprise/reference/appinspect/appinspectapiepref" + ) + test_parser.set_defaults(func=test) convert_parser.add_argument("-dm", "--data_model", required=False, type=str, default="cim", help="converter target, choose between cim, raw, ocsf") diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index 2d524a8d..f9510377 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -105,43 +105,42 @@ def encode_error(cls, v, values, field): # return v - # @validator("search") - # def search_obsersables_exist_validate(cls, v, values): - # if type(v) is str: - # tags:DetectionTags = values.get("tags") - # if tags == None: - # raise ValueError("Unable to parse Detection Tags. Please resolve Detection Tags errors") + @validator("search") + def search_obsersables_exist_validate(cls, v, values): + if type(v) is str: + 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] + 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.]*)\$" + #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()]) + 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}") + 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}") + 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 and values.get("status") == DetectionStatus.production.value: - # msg = "\n\t".join(error_messages) - # print("Errors found in notable validation - skipping for now") - # #raise(ValueError(msg)) + if len(error_messages) > 0 and values.get("status") == DetectionStatus.production.value: + msg = "\n\t".join(error_messages) + raise(ValueError(msg)) - # # Found everything - # return v + # Found everything + return v @validator("tests") def tests_validate(cls, v, values): - if values.get("status","") != DetectionStatus.production and not v: + if values.get("status","") == DetectionStatus.production.value and not v: raise ValueError( - "tests value is needed for production detection: " + values["name"] + "One or more tests is REQUIRED for production detection: " + values["name"] ) return v 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 f253df10..fe716d50 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 @@ -49,7 +49,7 @@ def free_text_field_valid(input_cls, v, values, field): try: v.encode('ascii') except UnicodeEncodeError as e: - raise ValueError(f"Ascii encoding error in {values['name']}:{field.name} - {str(e)}") + print(f"Potential Ascii encoding error in {values['name']}:{field.name} - {str(e)}") if bool(re.search(r"[^\\]\n", v)): From c9a17d534960fc5481a920e44957471c171ca301 Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Mon, 23 Oct 2023 12:45:31 -0700 Subject: [PATCH 09/11] Cleaner way to write app.manifest file --- contentctl/output/conf_output.py | 4 ++-- contentctl/output/conf_writer.py | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index 8778e4f9..5b07eca2 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -50,12 +50,12 @@ def writeHeaders(self) -> None: ConfWriter.writeConfFileHeader(self.output_path/'default/workflow_actions.conf', self.config) ConfWriter.writeConfFileHeader(self.output_path/'default/app.conf', self.config) ConfWriter.writeConfFileHeader(self.output_path/'default/content-version.conf', self.config) - + ConfWriter.writeConfFileHeader(self.output_path/'app.manifest', self.config) def writeAppConf(self): ConfWriter.writeConfFile(self.output_path/"default"/"app.conf", "app.conf.j2", self.config, [self.config.build] ) ConfWriter.writeConfFile(self.output_path/"default"/"content-version.conf", "content-version.j2", self.config, [self.config.build] ) - ConfWriter.writeConfFile(self.output_path/"app.manifest", "app.manifest.j2", self.config, [self.config.build],append=False) + ConfWriter.writeConfFile(self.output_path/"app.manifest", "app.manifest.j2", self.config, [self.config.build]) def writeObjects(self, objects: list, type: SecurityContentType = None) -> None: if type == SecurityContentType.detections: diff --git a/contentctl/output/conf_writer.py b/contentctl/output/conf_writer.py index ede4bb6d..da6ba4f0 100644 --- a/contentctl/output/conf_writer.py +++ b/contentctl/output/conf_writer.py @@ -31,7 +31,7 @@ def writeConfFileHeaderEmpty(output_path:pathlib.Path, config: Config) -> None: @staticmethod - def writeConfFile(output_path:pathlib.Path, template_name : str, config: Config, objects : list, append:bool=True) -> None: + def writeConfFile(output_path:pathlib.Path, template_name : str, config: Config, objects : list) -> None: def custom_jinja2_enrichment_filter(string, object): customized_string = string @@ -61,11 +61,7 @@ def custom_jinja2_enrichment_filter(string, object): template = j2_env.get_template(template_name) output = template.render(objects=objects, APP_NAME=config.build.prefix) output_path.parent.mkdir(parents=True, exist_ok=True) - if append: - file_mode = 'a' - else: - file_mode = 'w' - with open(output_path, file_mode) as f: + with open(output_path, 'a') as f: output = output.encode('ascii', 'ignore').decode('ascii') f.write(output) From c949ed4b91434da1bf03031b6427cefde3c0776a Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Mon, 23 Oct 2023 14:05:53 -0700 Subject: [PATCH 10/11] For improved compatability, remove local appinspect permanently until there is a new version. --- contentctl/output/conf_output.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index 5b07eca2..6931e076 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -330,23 +330,27 @@ def parseAppinspectJsonLogFile(self, logfile_path:pathlib.Path, def inspectAppCLI(self)-> None: try: + raise("Local spunk-appinspect Not Supported at this time (you may use the appinspect api). If you would like to locally inspect your app with" + "Python 3.7, 3.8, or 3.9 (with limited support), please refer to:\n" + "\t - https://dev.splunk.com/enterprise/docs/developapps/testvalidate/appinspect/useappinspectclitool/") from splunk_appinspect.main import ( validate, MODE_OPTION, APP_PACKAGE_ARGUMENT, OUTPUT_FILE_OPTION, LOG_FILE_OPTION, INCLUDED_TAGS_OPTION, EXCLUDED_TAGS_OPTION, PRECERT_MODE, TEST_MODE) except Exception as e: - print("******WARNING******") - if sys.version_info.major == 3 and sys.version_info.minor > 9: - print("The package splunk-appinspect was not installed due to a current issue with the library on Python3.10+. " - "Please use the following commands to set up a virtualenvironment in a different folder so you may run appinspect manually (if desired):" - "\n\tpython3.9 -m venv .venv" - "\n\tsource .venv/bin/activate" - "\n\tpython3 -m pip install splunk-appinspect" - f"\n\tsplunk-appinspect inspect {self.getPackagePath(include_version=False).relative_to(pathlib.Path('.').absolute())} --mode precert") + print(e) + # print("******WARNING******") + # if sys.version_info.major == 3 and sys.version_info.minor > 9: + # print("The package splunk-appinspect was not installed due to a current issue with the library on Python3.10+. " + # "Please use the following commands to set up a virtualenvironment in a different folder so you may run appinspect manually (if desired):" + # "\n\tpython3.9 -m venv .venv" + # "\n\tsource .venv/bin/activate" + # "\n\tpython3 -m pip install splunk-appinspect" + # f"\n\tsplunk-appinspect inspect {self.getPackagePath(include_version=False).relative_to(pathlib.Path('.').absolute())} --mode precert") - else: - print("splunk-appinspect is only compatable with Python3.9 at this time. Please see the following open issue here: https://github.com/splunk/contentctl/issues/28") - print("******WARNING******") + # else: + # print("splunk-appinspect is only compatable with Python3.9 at this time. Please see the following open issue here: https://github.com/splunk/contentctl/issues/28") + # print("******WARNING******") return # Note that all tags are available and described here: From b7511f83bb1e19072f60d9ef5bcd282d3a2397a9 Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Mon, 23 Oct 2023 14:08:41 -0700 Subject: [PATCH 11/11] Forgot to raise exception --- contentctl/output/conf_output.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index 6931e076..5fa8ce83 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -330,7 +330,7 @@ def parseAppinspectJsonLogFile(self, logfile_path:pathlib.Path, def inspectAppCLI(self)-> None: try: - raise("Local spunk-appinspect Not Supported at this time (you may use the appinspect api). If you would like to locally inspect your app with" + raise Exception("Local spunk-appinspect Not Supported at this time (you may use the appinspect api). If you would like to locally inspect your app with" "Python 3.7, 3.8, or 3.9 (with limited support), please refer to:\n" "\t - https://dev.splunk.com/enterprise/docs/developapps/testvalidate/appinspect/useappinspectclitool/") from splunk_appinspect.main import (