├── requirements.txt ├── .gitignore ├── .github ├── assets │ ├── logos │ │ ├── mcpb.png │ │ └── malscript_logo.png │ └── examples │ │ ├── help_menu.png │ │ ├── single-recipe.png │ │ ├── download_recipes.png │ │ └── recipe_chain_saved.png └── docs │ └── malscript_docs.md ├── malcore_playbook ├── __init__.py ├── lib │ ├── __init__.py │ ├── api.py │ ├── cli.py │ └── settings.py ├── main │ ├── __init__.py │ └── entry.py ├── execution │ ├── __init__.py │ └── recipe_exec.py ├── malscript │ ├── __init__.py │ └── parse.py ├── writers │ ├── __init__.py │ ├── pdf_output.py │ ├── txt_output.py │ ├── json_output.py │ └── console_output.py ├── cli_tool │ └── __init__.py └── __version__.py ├── NOTICE.md ├── setup.py ├── LICENSE.md └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | test* 3 | config.json 4 | dist/* 5 | build/* 6 | malcore_playbook.egg-info/* 7 | *.pyc -------------------------------------------------------------------------------- /.github/assets/logos/mcpb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PenetrumLLC/Malcore-Playbook/HEAD/.github/assets/logos/mcpb.png -------------------------------------------------------------------------------- /.github/assets/examples/help_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PenetrumLLC/Malcore-Playbook/HEAD/.github/assets/examples/help_menu.png -------------------------------------------------------------------------------- /.github/assets/logos/malscript_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PenetrumLLC/Malcore-Playbook/HEAD/.github/assets/logos/malscript_logo.png -------------------------------------------------------------------------------- /.github/assets/examples/single-recipe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PenetrumLLC/Malcore-Playbook/HEAD/.github/assets/examples/single-recipe.png -------------------------------------------------------------------------------- /.github/assets/examples/download_recipes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PenetrumLLC/Malcore-Playbook/HEAD/.github/assets/examples/download_recipes.png -------------------------------------------------------------------------------- /.github/assets/examples/recipe_chain_saved.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PenetrumLLC/Malcore-Playbook/HEAD/.github/assets/examples/recipe_chain_saved.png -------------------------------------------------------------------------------- /malcore_playbook/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | -------------------------------------------------------------------------------- /malcore_playbook/lib/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | -------------------------------------------------------------------------------- /malcore_playbook/main/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | -------------------------------------------------------------------------------- /malcore_playbook/execution/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | -------------------------------------------------------------------------------- /malcore_playbook/malscript/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | -------------------------------------------------------------------------------- /malcore_playbook/writers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | -------------------------------------------------------------------------------- /malcore_playbook/writers/pdf_output.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | def output(out, filename): 8 | raise NotImplementedError("PDF output is not implemented yet") 9 | -------------------------------------------------------------------------------- /malcore_playbook/cli_tool/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | import malcore_playbook.main.entry as entry 8 | 9 | 10 | def run(): 11 | """ CLI tool execution entrypoint """ 12 | entry.main() 13 | -------------------------------------------------------------------------------- /malcore_playbook/writers/txt_output.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | import json 8 | 9 | 10 | def output(out, filename): 11 | with open(filename, "w") as fh: 12 | fh.write(str(out)) 13 | return filename 14 | -------------------------------------------------------------------------------- /malcore_playbook/__version__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 1:06 PM 4 | # 5 | # 6 | 7 | # major.minor.patch.commit 8 | VERSION = "1.0.4.5" 9 | # alias for the version 10 | VERSION_ALIAS = "storm" 11 | # version string 12 | VERSION_STRING = f"{VERSION}({VERSION_ALIAS})" 13 | -------------------------------------------------------------------------------- /malcore_playbook/writers/json_output.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 12:59 PM 4 | # 5 | # 6 | 7 | import json 8 | 9 | 10 | def output(out, filename): 11 | if not isinstance(out, dict): 12 | out = {"output": out} 13 | with open(filename, 'w') as fh: 14 | json.dump(out, fh, indent=4) 15 | return filename 16 | -------------------------------------------------------------------------------- /malcore_playbook/writers/console_output.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | 7 | import json 8 | 9 | 10 | def output(out, filename): 11 | if isinstance(out, str): 12 | return f"\n{'*' * 63}\n{out}\n{'*' * 63}" 13 | else: 14 | return f"\n{'*' * 63}\n{json.dumps(out['data'], indent=2)}\n{'*' * 63}" 15 | 16 | -------------------------------------------------------------------------------- /NOTICE.md: -------------------------------------------------------------------------------- 1 | # NOTICE 2 | ### Malcore Playbook Licensing Notice 3 | 4 | This software is licensed under a dual-licensing model: 5 | 6 | #### Non-Commercial Use: 7 | ``` 8 | You are free to use, modify, and distribute Malcore Playbook under the terms of the MIT License for personal, 9 | educational, and research purposes only. 10 | ``` 11 | 12 | #### Commercial Use: 13 | ``` 14 | Any commercial use, including but not limited to use by corporations, governments, or other organizations for profit or 15 | in production environments, requires the purchase of a commercial license. 16 | ``` 17 | 18 | The full Terms of Use, including commercial licensing details, are available at: https://malcore.io/terms-of-use -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from malcore_playbook import __version__ 3 | 4 | 5 | if __name__ == "__main__": 6 | setup( 7 | name='malcore-playbook', 8 | packages=find_packages(), 9 | version=__version__.VERSION, 10 | description='Malcore Playbook automates malware analysis, malware triaging, ' 11 | 'and analyst workflows using modular recipes and DSL scripting', 12 | author="Thomas Perkins", 13 | author_email="penetrumcorp@gmail.com", 14 | install_requires=["requests"], 15 | long_description=open("README.md").read(), 16 | long_description_content_type="text/markdown", 17 | url="https://github.com/PenetrumLLC/malcore-playbook", 18 | entry_points={ 19 | 'console_scripts': [ 20 | 'malcore-playbook=malcore_playbook.cli_tool:run', 21 | 'mcpb=malcore_playbook.cli_tool:run', 22 | ] 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ### Malcore Playbook is dual licensed please see [NOTICE.md](NOTICE.md) for more information 2 | 3 | Non-Commercial Use: 4 | ```txt 5 | Copyright 2025 Penetrum LLC 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 8 | documentation files (the “Software”), to deal in the Software without restriction, including without limitation 9 | the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and 10 | to permit persons to whom the Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 16 | THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 17 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | ``` 20 | 21 | Commercial Use: 22 | ```txt 23 | Any commercial use, including but not limited to use by corporations, governments, or other organizations for profit 24 | or in production environments, requires the purchase of a commercial license. 25 | ``` 26 | -------------------------------------------------------------------------------- /malcore_playbook/lib/api.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:30 AM 4 | # 5 | # 6 | 7 | import malcore_playbook.lib.settings as settings 8 | 9 | import requests 10 | 11 | 12 | class Api(object): 13 | 14 | """ malcore API object """ 15 | 16 | def __init__(self, only_remote=False): 17 | self.api_url = "https://api.malcore.io/api" 18 | self.auth_url = "https://api.malcore.io/auth" 19 | self.plan_url = "https://api.malcore.io/plan" 20 | if not only_remote: 21 | self.conf = settings.load_conf() 22 | else: 23 | self.conf = {} 24 | 25 | def upload_file(self, filename, endpoint): 26 | """ uploada file to an endpoint """ 27 | url = f"{self.api_url}/{endpoint}" 28 | files = {'filename1': open(filename, 'rb')} 29 | headers = {'apiKey': self.conf['api_key']} 30 | req = requests.post(url, files=files, headers=headers) 31 | try: 32 | return req.json() 33 | except: 34 | return None 35 | 36 | def login(self, username, password): 37 | """ login to the API to get the user API key and auth tokens """ 38 | post_data = {"email": username, "password": password} 39 | url = f"{self.auth_url}/login" 40 | try: 41 | req = requests.post(url, data=post_data) 42 | results = req.json() 43 | except: 44 | results = None 45 | return results 46 | 47 | def list_recipes(self): 48 | """ gather a list of all available recipes """ 49 | url = "https://recipes.malcore.io/assets/dbs/files.json" 50 | req = requests.get(url) 51 | data = req.json() 52 | return data 53 | 54 | def status_check(self, uuid): 55 | """ check the status of executable file analysis """ 56 | url = f"{self.api_url}/status" 57 | data = {"uuid": uuid} 58 | headers = {'apiKey': self.conf['api_key']} 59 | req = requests.post(url, data=data, headers=headers) 60 | return req.json() 61 | -------------------------------------------------------------------------------- /malcore_playbook/execution/recipe_exec.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:30 AM 4 | # 5 | # 6 | 7 | import os 8 | import sys 9 | 10 | import malcore_playbook.lib.settings as settings 11 | 12 | 13 | def load_recipe(recipes, load_one=False, speak=True): 14 | """ import the loaded recipes """ 15 | loaded = [] 16 | for recipe in recipes: 17 | if speak: 18 | try: 19 | settings.logger.info(f"Attempting to load recipe: {recipe.__name__}") 20 | except: 21 | settings.logger.info("Attempting to load recipe") 22 | recipe_home = settings.RECIPE_HOME 23 | full_path = os.path.join(recipe_home, recipe) 24 | path, fname = os.path.split(full_path) 25 | modulename, _ = os.path.splitext(fname) 26 | if path not in sys.path: 27 | if speak: 28 | settings.logger.debug("Path not found in sys.path adding to it") 29 | sys.path.insert(0, path) 30 | try: 31 | imported_mod = __import__(modulename) 32 | if settings.user_can_use_recipe(imported_mod.__excluded_plans__): 33 | loaded.append(__import__(modulename)) 34 | else: 35 | if speak: 36 | settings.logger.warning( 37 | f"Your plan does not allow usage of recipe: {recipe}, " 38 | f"to upgrade your plan see here: https://malcore.io/pricing" 39 | ) 40 | except Exception as e: 41 | if speak: 42 | settings.logger.error(f"Cannot import recipe: {recipe}, hit error: {str(e)}") 43 | if len(loaded) != 0: 44 | if load_one: 45 | return loaded[0] 46 | else: 47 | return loaded 48 | else: 49 | settings.logger.warning("No recipes loaded successfully") 50 | return None 51 | 52 | 53 | def execute_recipe(recipe, *args, **kwargs): 54 | """ execute the loaded recipes and start processing them """ 55 | settings.logger.info(f"Attempting to execute recipe: {recipe}") 56 | try: 57 | results = recipe.plugin(*args, **kwargs) 58 | if results is not None: 59 | settings.logger.debug(f"Recipe executed successfully returning results") 60 | return results 61 | except Exception as e: 62 | settings.logger.error(f"Unable to execute recipe, got error: {str(e)}") 63 | return None 64 | -------------------------------------------------------------------------------- /.github/docs/malscript_docs.md: -------------------------------------------------------------------------------- 1 | Language type breakdown: 2 | 3 | | Feature | Classification | 4 | |-------------------------|---------------------------------------------------------------------------| 5 | | Purpose-built | DSL tailored for analysis, workflow automation, and automatic triaging | 6 | | Imperative flow control | Condition logic resembles traditional scripting | 7 | | Declarative intent | Each line is designed to express what to do based on prior outputs | 8 | | Functional style | Uses built-in functions with little not no side effects or mutable states | 9 | | Minimal syntax | Provides a compact and expressive styling | 10 | | Dot-path dereferencing | Provides the ability to access JSON-like objects with `.` | 11 | 12 | ### Built ins 13 | 14 | - `$` 15 | - Use this to set a variable for future use: `$emu` 16 | - `=` 17 | - Use in conjunction with the variable set to set the variable to the action: `$emu=ACTION` 18 | - `str()` 19 | - Use to set a string for searching in condition statements: `str('exe')` 20 | - `int()` 21 | - Use to set an integer for searching in condition statements: `int(182)` 22 | - `exec()` 23 | - Use to perform execution of a downloaded recipe, must be used with variable setting: `$emu=exec(dynamic-analysis)` 24 | - `if` 25 | - Use to perform a condition statement: `if str('exe')` 26 | - `ret` 27 | - Use to return a value from the script execution: `ret($emu)` 28 | - `.` 29 | - Use to access data within a variable: `$emu.data.[1]` 30 | - `;` 31 | - Use to end a line, must be at the end of every line: `$emu=exec(dynamic-analysis);` 32 | 33 | ### Setting and Accessing Variables 34 | 35 | To set a variable in MalScript you will need declare the variable and set it to an action, for example: `$emu=exec(dynamic-analysis);` will set the variable `$emu` to the output of the `dynamic-analysis` recipe. You will then be able to access the output from the recipe by calling the set variable. 36 | 37 | Since the recipes are based off a RESTful API on the Malcore website you are also able to access variables and list indexes within the output by chaining it with `.` (periods) to the location of the required data. For example if we have the following assigned to variable `$results`: 38 | 39 | ```json 40 | { 41 | "results": { 42 | "test1": [ 43 | {"test2": "results"} 44 | ] 45 | } 46 | } 47 | ``` 48 | 49 | We can access the `results` variable by chaining the location together: `$results.results.test1.[0].test2` will give us the string `"results"`. 50 | 51 | ### Executing Recipes and Assigning them to Variables 52 | 53 | The `exec()` built in allows you to execute a recipe. For example: `$exifData=exec(exif-data);`. To break this down: 54 | 55 | ``` 56 | $VAR_NAME # set the variable name 57 | = # Assign the variable 58 | exec( # Execute a recipe 59 | RECIPE-NAME # Execute this recipe name 60 | ) # Close the brackets 61 | ; # End the line 62 | ``` 63 | 64 | ### Example Scripts 65 | 66 | #### Functionality 67 | 68 | This is a basic overview of MalScript that provides decent understanding of functionality 69 | 70 | ```bash 71 | # This is a comment that must also end with: ; 72 | # We will set the variable $s to the output of executing the strings recipe ; 73 | $s=exec(strings); 74 | # If we find a string matching GetCurrentProcess in the strings output ; 75 | # We execute the threat-score recipe and assign the output to variable $t ; 76 | if str('GetCurrentProcess') in $s then $t=exec(threat-score); 77 | # We can access the exact score variable by using derreferencing with . ; 78 | # If 5.13 is in the JSON key score we will execute the AI classifier recipe ; 79 | if str('5.13') in $t.score then $a=exec(ai-class); 80 | # If the string safe is in the AI classifier output ; 81 | # We will execute the exif-data recipe and assign output to variable $e ; 82 | if str('safe') in $a then $e=exec(exif-data); 83 | # Then we will return the code_signature JSON variable from the exif-data output ; 84 | ret($e.code_signature); 85 | ``` 86 | 87 | #### Check Code Signing 88 | 89 | This is an example of checking if a script has a signing certificate in it 90 | 91 | ```bash 92 | # We will first create an execution for exif-data and put the results in the $exif variable ; 93 | $exif=exec(exif-data); 94 | # If we find the string 'Microsoft' in the $exif.signature_info output classify the file ; 95 | if str('Microsoft') in $exif.signature_info then $ai=exec(ai-class); 96 | # Return the classification 97 | ret($ai); 98 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Malcore Playbook 2 | 3 |

4 | 5 | [![Available Recipes](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Frecipes.malcore.io%2Fassets%2Fdbs%2Frecipe-count-badge.json&query=%24.message&style=for-the-badge&label=Available%20recipes)](https://recipes.malcore.io) 6 | [![MalScript Docs](https://img.shields.io/badge/MalScript%20Documentation-red?style=for-the-badge)](https://github.com/PenetrumLLC/Malcore-Playbook/blob/master/.github/docs/malscript_docs.md) 7 | [![Build Recipe](https://img.shields.io/badge/Build%20A%20Recipe-blue?style=for-the-badge)](https://github.com/PenetrumLLC/Malcore-Playbook-Recipes) 8 | 9 | Malcore Playbook is a powerful framework for automating malware analysis, malware triaging, and analyst workflows using modular recipes and scripting. Designed for SOC analysts, threat hunters, and cybersecurity professionals, Malcore Playbook allows users to build chains to automate workflows, and extract actionable intelligence from suspicious files through a simple, flexible scripting language, and individual recipes. 10 | 11 | ## Installation 12 | 13 | It is advised to use the this method for installation: 14 | 15 | ```shell 16 | pip install malcore-playbook 17 | ``` 18 | 19 | Then you can easily update by running: `pip install malcore-playbook --upgrade` 20 | 21 | --- 22 | 23 | You are also able to install manually like so: 24 | 25 | ```shell 26 | git clone https://github.com/PenetrumLLC/Malcore-Playbook.git && \ 27 | cd Malcore-Playbook && \ 28 | python setup.py install && \ 29 | malcore-playbook 30 | ``` 31 | 32 | ## Usage 33 | 34 | ``` 35 | usage: malcore-playbook --recipe RECIPE[,RECIPE,..] --filename FILE [--chain --script [SCRIPT] 36 | --kwargs ARG1=VAL1[,ARG2=VAL2,...]] 37 | 38 | optional arguments: 39 | -h, --help show this help message and exit 40 | 41 | required arguments: 42 | -r RECIPE-NAME [RECIPE-NAME ...], --recipe RECIPE-NAME [RECIPE-NAME ...] 43 | Recipes to execute one at a time, pass multiple using a comma seperated list 44 | (eg, recipe1,recipe2,...) 45 | -c, --chain Pass this to chain recipes together with a script, must pass the --script flag with this 46 | --filename FILENAME, -f FILENAME, --file-to-analyze FILENAME 47 | The filename that you want to process with the recipes. This is required for the recipes to work 48 | 49 | chain related arguments: 50 | --chain-script CHAIN-SCRIPT, -S CHAIN-SCRIPT, --script CHAIN-SCRIPT, -C CHAIN-SCRIPT 51 | Pass either a filename or a raw chain script in order to execute the MalScript chain 52 | 53 | recipe related arguments: 54 | --list-remote, --list-remote-recipes, -lR 55 | List all remote recipes that are available for download 56 | --list-local, --list-local-recipes, -lL 57 | List all local recipes that are available to execute 58 | --download-remote RECIPE-NAME [RECIPE-NAME ...], --download-recipe RECIPE-NAME [RECIPE-NAME ...], 59 | --download RECIPE-NAME [RECIPE-NAME ...], -D RECIPE-NAME [RECIPE-NAME ...] 60 | Pass a remote recipe name to download it to your recipe folder 61 | (pass 'all' to download all available recipes) 62 | --recipe-updates ACTION 63 | Check for recipe updates 64 | --kwargs [KWARGS [KWARGS ...]] 65 | Key and value pairs to pass to the recipe IE: arg1=var1,arg2=var2 66 | 67 | misc arguments: 68 | --force Force actions that would otherwise fail 69 | --output OUTPUT-TYPE, -O OUTPUT-TYPE, --output-type OUTPUT-TYPE 70 | Pass to control the type of output you want, default is JSON files stored in: C:\Users\saman\.mcpb 71 | --hide Hide the banner 72 | --version Show version numbers and exit 73 | ``` 74 | 75 | ## MalScript Overview 76 | 77 |

78 | 79 | MalScript is a domain-specific scripting language (DSL) built specifically for the Malcore Playbook. This language is designed to automate malware analysis and file triaging workflows. By providing the ability to chain recipes and execute them conditionally, MalScript provides a powerful declarative automation to help automate reverse engineers and analysts. MalScript combines function and imperative elements to support rule-based execution, and data inspection on real-time analysis results. 80 | 81 | Full language documentation can be found [HERE](.github/docs/malscript_docs.md) 82 | 83 | ## Example Usage 84 | 85 | The help menu: 86 | ![Help Menu](https://github.com/PenetrumLLC/Malcore-Playbook/blob/master/.github/assets/examples/help_menu.png?raw=true) 87 | 88 | Executing a single recipe 89 | ![Single Recipe](https://github.com/PenetrumLLC/Malcore-Playbook/blob/master/.github/assets/examples/single-recipe.png?raw=true) 90 | 91 | Downloading recipes: 92 | ![Download Recipes](https://github.com/PenetrumLLC/Malcore-Playbook/blob/master/.github/assets/examples/download_recipes.png?raw=true) 93 | 94 | 95 | Executing a recipe chain and saving it to a text file: 96 | ![Download Recipes](https://github.com/PenetrumLLC/Malcore-Playbook/blob/master/.github/assets/examples/recipe_chain_saved.png?raw=true) -------------------------------------------------------------------------------- /malcore_playbook/lib/cli.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:30 AM 4 | # 5 | # 6 | 7 | import sys 8 | import argparse 9 | 10 | import malcore_playbook.lib.settings as settings 11 | 12 | 13 | logger = settings.logger 14 | 15 | 16 | class Parser(argparse.ArgumentParser): 17 | 18 | """ class placeholder for the argparse item """ 19 | 20 | @staticmethod 21 | def optparse(): 22 | """ makes it easier to call from Parser().optparse() and looks nicer """ 23 | parser = argparse.ArgumentParser() 24 | 25 | parser.usage = (f"malcore-playbook --recipe RECIPE[,RECIPE,..] --filename FILE " 26 | "[--chain --script [SCRIPT] " 27 | "--kwargs ARG1=VAL1[,ARG2=VAL2,...]]") 28 | 29 | required = parser.add_argument_group("required arguments") 30 | required.add_argument( 31 | "-r", "--recipe", nargs="+", metavar="RECIPE-NAME", 32 | help="Recipes to execute one at a time, pass multiple using a comma seperated list (" 33 | "eg, recipe1,recipe2,...)", 34 | default=None, dest="useRecipe" 35 | ) 36 | required.add_argument( 37 | "-c", "--chain", action="store_true", 38 | dest="useChain", default=False, 39 | help="Pass this to chain recipes together with a script, must pass the --script flag with this" 40 | ) 41 | required.add_argument( 42 | "--filename", "-f", "--file-to-analyze", nargs=1, default=None, 43 | help="The filename that you want to process with the recipes. This is required for the recipes to work", 44 | dest="filename" 45 | ) 46 | 47 | chain_flags = parser.add_argument_group("chain related arguments") 48 | chain_flags.add_argument( 49 | "--chain-script", "-s", "--script", "-C", metavar="CHAIN-SCRIPT", 50 | dest="chainScript", default=None, 51 | help="Pass either a filename or a raw chain script in order to execute the MalScript chain" 52 | ) 53 | 54 | recipe_args = parser.add_argument_group("recipe related arguments") 55 | recipe_args.add_argument( 56 | "--list-remote", "--list-remote-recipes", "-lR", action="store_true", default=False, 57 | help="List all remote recipes that are available for download", dest="viewRemote" 58 | ) 59 | recipe_args.add_argument( 60 | "--list-local", "--list-local-recipes", "-lL", action="store_true", default=False, 61 | help="List all local recipes that are available to execute", dest="viewLocal" 62 | ) 63 | recipe_args.add_argument( 64 | "--download-remote", "--download-recipe", "--download", "-D", 65 | nargs="+", metavar="RECIPE-NAME", default=None, 66 | help="Pass a remote recipe name to download it to your recipe folder (" 67 | "pass 'all' to download all available recipes" 68 | ")", 69 | dest="downloadRecipe" 70 | ) 71 | recipe_args.add_argument( 72 | "--search", "-S", "--search-string", 73 | metavar="KEYWORD", default=None, 74 | help="Pass a search string to filter the local or remote recipe list", 75 | dest="searchString" 76 | ) 77 | recipe_args.add_argument( 78 | "--recipe-updates", "--update-recipes", "--updates", "-U", 79 | metavar="ACTION", help="Check for recipe updates", 80 | dest="checkRecipeUpdates", default=None, choices=["check", "download"] 81 | ) 82 | recipe_args.add_argument( 83 | "--kwargs", dest="kwargs", nargs="*", default={}, 84 | help="Key and value pairs to pass to the recipe IE: arg1=var1,arg2=var2" 85 | ) 86 | 87 | misc_args = parser.add_argument_group("misc arguments") 88 | misc_args.add_argument( 89 | "--force", action="store_true", default=False, 90 | help="Force actions that would otherwise fail", dest="forceAction" 91 | ) 92 | misc_args.add_argument( 93 | "--output", "-O", "--output-type", 94 | default="json", choices=["json", "pdf", "txt", "console"], 95 | metavar="OUTPUT-TYPE", dest="outputType", 96 | help=f"Pass to control the type of output you want, default is JSON files stored in: {settings.HOME}" 97 | ) 98 | misc_args.add_argument( 99 | "--hide", action="store_true", help="Hide the banner", dest="hideBanner" 100 | ) 101 | misc_args.add_argument( 102 | "--version", action="store_true", help="Show version numbers and exit", 103 | dest="showVersions" 104 | ) 105 | 106 | # Hidden args 107 | parser.add_argument( 108 | "--no-start-end", action="store_true", default=False, dest="noStartEnd", 109 | help=argparse.SUPPRESS 110 | ) 111 | parsed = parser.parse_args() 112 | 113 | # if any of these are passed we will go ahead and hide the banner and prevent the 114 | # startup and shutdown logging 115 | if parsed.viewLocal or parsed.viewRemote or parsed.showVersions: 116 | parsed.hideBanner = True 117 | parser.noStartEnd = True 118 | 119 | # process KEY=VAL pairs from the --kwargs argument and put them into a dict 120 | # for future use 121 | kwargs_dict = {} 122 | for item in parsed.kwargs: 123 | if "=" in item: 124 | key, value = item.split("=") 125 | kwargs_dict[key] = value 126 | else: 127 | logger.warning(f"Key value pair: {item} will be skipped") 128 | parsed.kwargs = kwargs_dict 129 | 130 | return parsed 131 | 132 | -------------------------------------------------------------------------------- /malcore_playbook/main/entry.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 12:43 PM 4 | # 5 | # 6 | 7 | import os 8 | import sys 9 | import datetime 10 | import logging 11 | 12 | import malcore_playbook.lib.cli as cli 13 | import malcore_playbook.lib.settings as settings 14 | import malcore_playbook.lib.api as api 15 | import malcore_playbook.execution.recipe_exec as recipe_exec 16 | import malcore_playbook.malscript.parse as ms_parser 17 | 18 | 19 | logger = settings.logger 20 | 21 | 22 | def main(): 23 | """ tie all of it together in a single pretty function """ 24 | try: 25 | if "FORCE-RELOG" in sys.argv: 26 | force = True 27 | else: 28 | force = False 29 | settings.init(force=force) 30 | parser = cli.Parser().optparse() 31 | 32 | if not parser.hideBanner: 33 | print(settings.HEADER) 34 | if parser.noStartEnd: 35 | logger.info(f"Starting up at: {datetime.datetime.now()}") 36 | settings.check_for_updates() 37 | if parser.viewRemote: 38 | _api = api.Api(only_remote=True) 39 | settings.display_recipes(_api.list_recipes(), filter_=parser.searchString) 40 | elif parser.showVersions: 41 | interp = ms_parser.MalScriptInterpreter(None) 42 | print(f"Malcore Playbook version: {settings.VERSION}") 43 | print(f"MalScript version: {interp.version}") 44 | sys.exit(1) 45 | elif parser.viewLocal: 46 | files = [f"{settings.RECIPE_HOME}{os.path.sep}{f}" for f in os.listdir(settings.RECIPE_HOME)] 47 | if len(files) == 0: 48 | logger.error("You have not downloaded any recipes, please use the --download-remote flag to start") 49 | else: 50 | dict_ = settings.create_recipe_dict_from_local(files) 51 | settings.display_recipes(dict_, filter_=parser.searchString) 52 | elif parser.checkRecipeUpdates is not None: 53 | choice = parser.checkRecipeUpdates 54 | logger.info(f"Starting to check for recipe updates, action taken: {choice}") 55 | settings.check_for_recipe_updates(force_download=True if choice == "download" else False) 56 | elif parser.downloadRecipe: 57 | _api = api.Api(only_remote=True) 58 | recipes = _api.list_recipes() 59 | selected_recipe_name = parser.downloadRecipe 60 | for selected in selected_recipe_name: 61 | if "all" in selected.lower(): 62 | settings.download_all_recipes() 63 | else: 64 | for recipe in recipes: 65 | if selected in recipe['filename']: 66 | settings.download_recipe(selected, recipe, force=parser.forceAction) 67 | logger.info("Finished processing all acceptable recipes") 68 | else: 69 | if not os.path.exists(settings.HAS_INITIALIZED_DOWNLOADS): 70 | settings.logger.warning( 71 | "You have not downloaded all the currently available recipes, " 72 | "please use --download-remote all flag to download them" 73 | ) 74 | if parser.useRecipe is not None: 75 | logger.debug("Checking if passed recipe is available in current recipe list") 76 | available_recipes = settings.load_recipes() 77 | passed_recipes = settings.create_recipe_list_from_passed(parser.useRecipe) 78 | for passed_recipe in passed_recipes: 79 | if passed_recipe in list(available_recipes): 80 | logger.info(f"Recipe found in available recipes, starting processing") 81 | loaded_recipe = recipe_exec.load_recipe([passed_recipe], load_one=True) 82 | if loaded_recipe is None: 83 | logger.error(f"Recipe not loaded, skipping") 84 | else: 85 | logger.debug("Starting execution of loaded recipe") 86 | try: 87 | exec_results = recipe_exec.execute_recipe(loaded_recipe, parser.filename, parser.kwargs) 88 | if exec_results is not None: 89 | logger.debug("Recipe executed successfully starting output parsing") 90 | output_file = settings.create_output_file( 91 | parser.outputType, parser.filename, passed_recipe.split(".")[0] 92 | ) 93 | output_results = settings.create_output(exec_results, output_file) 94 | print(output_results) 95 | else: 96 | logger.warning("Recipe did not execute successfully, skipping") 97 | except Exception as e: 98 | logger.error(f"Unable to execute recipe successfully, got error: {str(e)}") 99 | else: 100 | logging.warning(f"Passed recipe: {passed_recipe} not found in available recipes, skipping") 101 | if parser.useChain: 102 | logger.info("User passed chain process, starting the script parsing") 103 | if parser.chainScript is None: 104 | logger.fatal("You did not pass a script to execute, please pass a chain script") 105 | else: 106 | if parser.filename is None: 107 | logger.fatal("You did not pass a filename to execute") 108 | else: 109 | chain_results = settings.execute_chain(parser.chainScript, parser.filename, **parser.kwargs) 110 | output_file = settings.create_output_file( 111 | parser.outputType, parser.filename, "recipe-chain" 112 | ) 113 | output_results = settings.create_output(chain_results, output_file) 114 | print(output_results) 115 | if parser.noStartEnd: 116 | logger.debug(f"Shutting down at: {datetime.datetime.now()}") 117 | except KeyboardInterrupt: 118 | logger.fatal("User interrupted the program, shutting down") 119 | except Exception as e: 120 | import traceback 121 | traceback.print_exc() 122 | logger.fatal(f"Unhandled exception happened, MCPB is exiting: {str(e)}") -------------------------------------------------------------------------------- /malcore_playbook/malscript/parse.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 10:29 AM 4 | # 5 | # 6 | # 7 | 8 | import re 9 | 10 | import malcore_playbook.execution.recipe_exec as recipe_exec 11 | import malcore_playbook.lib.settings as settings 12 | 13 | 14 | class MalScriptParserError(Exception): 15 | 16 | def __init__(self, msg, code, offending_line): 17 | self.msg = msg 18 | self.code = code 19 | self.offending_line = offending_line 20 | super().__init__(msg) 21 | 22 | def __str__(self): 23 | return f"[Error][{self.code}]: {self.msg}\n\t-> Code causing crash: {self.offending_line}" 24 | 25 | 26 | class ScriptSyntaxError(MalScriptParserError): pass 27 | 28 | 29 | class ScriptParserError(MalScriptParserError): pass 30 | 31 | 32 | class ScriptExecutionError(MalScriptParserError): pass 33 | 34 | 35 | class InvalidScriptPassed(FileNotFoundError): pass 36 | 37 | 38 | class MalScriptInterpreter(object): 39 | 40 | """ basic interpreter for the MalScript scripting language """ 41 | 42 | def __init__(self, filename, **kwargs): 43 | self.variables = {} 44 | self.filename = filename 45 | self.kwargs = kwargs 46 | self.matcher = re.compile(r"if (.+?) in (.+?) then (.+)") 47 | self.version = "1.0" 48 | 49 | def exec_command(self, command, line_no, line): 50 | """ executes the exec() built in """ 51 | settings.logger.info(f"Executing command: {command} on filename: {self.filename}") 52 | recipe = recipe_exec.load_recipe([command], load_one=True) 53 | if recipe is None: 54 | raise ScriptExecutionError( 55 | f"Failed to execute requested recipe: {command}, lino_no: {line_no}", -3, line 56 | ) 57 | exec_results = recipe_exec.execute_recipe(recipe, self.filename, **self.kwargs) 58 | return exec_results 59 | 60 | def parse_value(self, value, line_no, line): 61 | """ parses and adds the variables to the storage dict """ 62 | value = value.strip() 63 | type_, value = value.split("(") 64 | value = value.split(")")[0] 65 | if type_.lower() == "str": 66 | return value 67 | elif type_.lower() == "int": 68 | value = value.replace('"', "").replace("'", "") 69 | if not isinstance(value, int): 70 | return int(value) 71 | else: 72 | return value 73 | elif type_.lower() == "exec": 74 | command = value 75 | return self.exec_command(command, line_no, line) 76 | else: 77 | raise ScriptSyntaxError( 78 | f"Unsupported value type: {value}, line_no: {line_no}", -2, line 79 | ) 80 | 81 | def get_nested_variables(self, var_name, condition_value, then_part, line_no, line, **kwargs): 82 | """ find nested variables data from the script """ 83 | is_from_ret = kwargs.get("is_from_ret", False) 84 | 85 | if not is_from_ret: 86 | parts = var_name.split(".") 87 | var_name = parts[0] 88 | if var_name not in self.variables: 89 | raise ScriptParserError( 90 | f"Requested variable: {var_name} not found, line_no: {line_no}", -1, line 91 | ) 92 | data = self.variables[var_name] 93 | keys = parts[1:] 94 | for key in keys: 95 | if key.startswith("["): 96 | try: 97 | index_number = int(key.split("[")[1].split("]")[0]) 98 | data = data[index_number] 99 | except: 100 | raise ScriptSyntaxError( 101 | f"Invalid index for variable: {var_name}, line_no: {line_no}", -2, line 102 | ) 103 | else: 104 | if key in data.keys(): 105 | data = data.get(key) 106 | else: 107 | raise ScriptSyntaxError( 108 | f"Invalid variable: {var_name}, line_no: {line_no}", -2, line 109 | ) 110 | var_value = data 111 | if isinstance(var_value, list): 112 | if any(condition_value == item for item in var_value): 113 | self.parse_value(then_part, line_no, line) 114 | else: 115 | if isinstance(condition_value, int): 116 | if condition_value == var_value: 117 | self.parse_line(then_part, line_no) 118 | else: 119 | if condition_value in str(var_value): 120 | self.parse_line(then_part, line_no) 121 | else: 122 | parts = var_name.split(".") 123 | var_name = parts[0] 124 | keys = parts[1:] 125 | data = self.variables[var_name] 126 | for key in keys: 127 | if key.startswith("["): 128 | try: 129 | index_number = int(key.split("[")[1].split("]")[0]) 130 | data = data[index_number] 131 | except: 132 | raise ScriptSyntaxError( 133 | f"Invalid index for variable: {var_name}, line_no: {line_no}", -2, line 134 | ) 135 | else: 136 | if key in data.keys(): 137 | data = data.get(key) 138 | else: 139 | raise ScriptSyntaxError( 140 | f"Invalid variable: {var_name}, line_no: {line_no}", -2, line 141 | ) 142 | return data 143 | 144 | def parse_line(self, line, line_no): 145 | """ parses the lines of the script """ 146 | line_no = str(line_no) 147 | line = line.strip() 148 | if line.startswith('$') and '=' in line: 149 | settings.logger.debug(f"Found variable in script, parsing variable, line_no: {line_no}") 150 | var_name, value = line.split('=', 1) 151 | var_name = var_name.strip() 152 | value = value.strip().rstrip(';') 153 | if var_name in self.variables: 154 | settings.logger.debug(f"Variable already exists from line_no: {line_no}, overwriting") 155 | del self.variables[var_name] 156 | self.variables[var_name] = self.parse_value(value, line_no, line) 157 | 158 | elif line.startswith("#"): 159 | settings.logger.debug(f"Found a commented line on line_no: {line_no}, skipping") 160 | 161 | elif line.startswith('if '): 162 | settings.logger.debug(f"Found conditional if statement on line_no: {line_no}, parsing condition") 163 | match = self.matcher.match(line) 164 | if match: 165 | settings.logger.debug(f"Condition is acceptable, starting execution") 166 | condition_value, var_name, then_part = match.groups() 167 | condition_value = self.parse_value(condition_value, line_no, line) 168 | if isinstance(condition_value, str): 169 | condition_value = condition_value.replace("'", "").replace('"', "") 170 | var_name = var_name.strip() 171 | if var_name.startswith('$'): 172 | var_name = var_name 173 | else: 174 | var_name = '$' + var_name 175 | then_part = then_part.strip() 176 | if "." in var_name: 177 | self.get_nested_variables(var_name, condition_value, then_part, line_no, line) 178 | else: 179 | if var_name not in self.variables: 180 | raise ScriptParserError( 181 | f"Requested variable: {var_name} not found, line_no: {line_no}", -1, line 182 | ) 183 | var_value = self.variables[var_name] 184 | if isinstance(var_value, list): 185 | if any(condition_value == item for item in var_value): 186 | self.parse_line(then_part, line_no) 187 | else: 188 | if condition_value in str(var_value): 189 | self.parse_line(then_part, line_no) 190 | 191 | elif line.startswith('ret'): 192 | settings.logger.debug(f"Found return statement on line_no: {line_no}, parsing return") 193 | var_name = line.split("(")[1].split(")")[0].strip() 194 | if var_name.startswith('$'): 195 | var_name = var_name 196 | else: 197 | var_name = '$' + var_name 198 | if "." in var_name: 199 | return self.get_nested_variables( 200 | var_name, None, None, line_no, line, is_from_ret=True 201 | ) 202 | else: 203 | if var_name in self.variables: 204 | return self.variables[var_name] 205 | else: 206 | raise ScriptParserError( 207 | f"Variable {var_name} not found, line_no: {line_no}", -1, line 208 | ) 209 | 210 | else: 211 | raise ScriptSyntaxError( 212 | f"Unknown line format: {line}, line_no: {line_no}", -2, line 213 | ) 214 | 215 | def start_execution(self, script): 216 | """ starts execution of the script """ 217 | settings.logger.debug(f"Starting execution of passed script") 218 | lines = script.strip().split(';') 219 | settings.logger.debug(f"Total of {len(lines)} line(s) to parse in script") 220 | result = None 221 | for i, line in enumerate(lines, start=1): 222 | if line != '': 223 | out = self.parse_line(line, i) 224 | if out is not None: 225 | result = out 226 | return result 227 | 228 | 229 | def run(script, filename, kwargs): 230 | """ pointer function to start the entire process """ 231 | if kwargs is None: 232 | kwargs = {} 233 | engine = MalScriptInterpreter(filename, **kwargs) 234 | result = engine.start_execution(script) 235 | return result 236 | -------------------------------------------------------------------------------- /malcore_playbook/lib/settings.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025. 2 | # Penetrum LLC (all rights reserved) 3 | # Copyright last updated: 5/2/25, 12:44 PM 4 | # 5 | # 6 | 7 | import os 8 | import sys 9 | import json 10 | import time 11 | import getpass 12 | import logging 13 | import datetime 14 | import hashlib 15 | from logging.handlers import RotatingFileHandler 16 | from importlib.metadata import distribution 17 | 18 | import malcore_playbook.writers.json_output as json_writer 19 | import malcore_playbook.writers.pdf_output as pdf_writer 20 | import malcore_playbook.writers.txt_output as txt_writer 21 | import malcore_playbook.writers.console_output as console_writer 22 | import malcore_playbook.malscript.parse as chain_script 23 | import malcore_playbook.lib.api as api 24 | import malcore_playbook.__version__ as version 25 | 26 | import requests 27 | 28 | 29 | class NoFilenameProvided(Exception): 30 | """ raise when there is no file provided """ 31 | pass 32 | 33 | 34 | class JsonLogFormatter(logging.Formatter): 35 | 36 | """ export the log to a JSON format and log to a JSON file """ 37 | 38 | def format(self, record): 39 | log_record = { 40 | "timestamp": datetime.datetime.fromtimestamp(record.created).isoformat(), 41 | "level": record.levelname, 42 | "logger": record.name, 43 | "filename": record.filename, 44 | "line": record.lineno, 45 | "message": record.getMessage(), 46 | } 47 | return json.dumps(log_record) 48 | 49 | 50 | # home directory where all config files are stored 51 | HOME = f"{os.path.expanduser('~')}{os.path.sep}.mcpb" 52 | # this is where the recipes are stored 53 | RECIPE_HOME = f"{HOME}{os.path.sep}.recipes" 54 | # RECIPE_HOME = "test-plugins" 55 | # the config file that contains the API keys and what not 56 | CONFIG_FILE = f"{HOME}{os.path.sep}config.json" 57 | # the directory that will contain all the output results 58 | OUTPUT_DIR = f"{HOME}{os.path.sep}results" 59 | # did the user accept the eula? 60 | ACCEPTED_EULA = f"{HOME}{os.path.sep}.accepted" 61 | # what plan does the user have? this isn't used yet 62 | PLAN_FILE = f"{HOME}{os.path.sep}.user_plan" 63 | # backup plan file incase the plans differ 64 | BACKUP_USER_PLAN = f"{HOME}{os.path.sep}.backup_plan" 65 | # has the user download anything yet? 66 | HAS_INITIALIZED_DOWNLOADS = f"{HOME}{os.path.sep}.initialized_downloads" 67 | # version of the program 68 | VERSION = version.VERSION 69 | # program alias nick 70 | VERSION_ALIAS = version.VERSION_ALIAS 71 | # logo header 72 | HEADER = f""" 73 | \033[91m• ▌ ▄ ·. \033[0m ▄▄· ▄▄▄·▄▄▄▄· 74 | \033[91m·██ ▐███▪\033[0m▐█ ▌▪▐█ ▄█▐█ ▀█▪ 75 | \033[91m▐█ ▌▐▌▐█·\033[0m██ ▄▄ ██▀·▐█▀▀█▄ 76 | \033[91m██ ██▌▐█▌\033[0m▐███▌▐█▪·•██▄▪▐█ 77 | \033[91m▀▀ █▪▀▀▀·\033[0m▀▀▀ .▀ ·▀▀▀▀ v{VERSION}({VERSION_ALIAS}) 78 | Malcore-Playbook 79 | """ 80 | # the eula 81 | EULA = """MALCORE PLAYBOOK END USER LICENSE AGREEMENT (EULA) 82 | 83 | 1. LICENSE GRANT 84 | Malcore grants you a limited, non-exclusive, non-transferable, revocable license to use the Malcore Playbook software ("Software") solely for personal, non-commercial, and evaluation purposes during the trial period. 85 | 86 | 2. TRIAL PERIOD 87 | The Software may be used under a free trial license for evaluation purposes only. The trial period is limited to 30 days from the initial installation date, unless otherwise explicitly granted in writing by Malcore. Continuation of use beyond the trial period without obtaining a valid commercial license is strictly prohibited for any commercial, organizational, or institutional purpose. 88 | 89 | 3. COMMERCIAL USE 90 | Use of the Software by any entity other than an individual for non-commercial personal evaluation is considered Commercial Use. This includes, but is not limited to: 91 | - Use by businesses, corporations, LLCs, partnerships, government agencies, educational institutions, and non-profits; 92 | - Use on behalf of an employer; 93 | - Use in support of business operations, revenue-generating activities, cybersecurity services, malware analysis services, or internal security operations. 94 | Commercial Use requires the purchase of an active commercial license, regardless of any technical limitations or continued functionality of the Software beyond the trial period. 95 | 96 | 4. LICENSE COMPLIANCE 97 | By using the Software, you agree to comply with all license terms, including timely acquisition of a commercial license where required. Failure to obtain an appropriate license for Commercial Use constitutes a violation of this EULA and may result in legal action, penalties, or termination of access. 98 | 99 | 5. UNAUTHORIZED MODIFICATION AND CIRCUMVENTION 100 | You agree not to attempt to modify, tamper with, disable, reverse-engineer, or circumvent any technical limitations, license controls, or usage restrictions in the Software. Malcore reserves the right to audit Software usage to verify compliance. 101 | 102 | 6. OWNERSHIP 103 | Malcore retains all rights, title, and interest in and to the Software, including all intellectual property rights. No ownership or other rights are transferred under this EULA except as expressly stated. 104 | 105 | 7. DISCLAIMER OF WARRANTIES 106 | The Software is provided "AS IS" without warranties of any kind, express or implied. Malcore disclaims all warranties, including but not limited to implied warranties of merchantability, fitness for a particular purpose, and non-infringement. 107 | 108 | 8. LIMITATION OF LIABILITY 109 | In no event shall Malcore, its affiliates, or its licensors be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, arising from your use of the Software. 110 | 111 | 9. GOVERNING LAW 112 | This EULA is governed by and construed in accordance with the laws of Virginia USA. 113 | 114 | 10. AMENDMENTS 115 | Malcore reserves the right to modify or update this EULA at any time. Continued use of the Software following any changes constitutes your acceptance of those changes. 116 | 117 | USER ACCEPTANCE 118 | By installing, copying, accessing, or otherwise using the Malcore Playbook Software, you acknowledge that you have read, understood, and agree to be bound by the terms of this EULA. 119 | 120 | NOTICE: Commercial use of Malcore Playbook without an active commercial license is prohibited. Unauthorized commercial use may result in retroactive licensing fees, account suspension, and/or legal action.""" 121 | 122 | 123 | def init(force=False): 124 | """ initialization function that starts the init of the program """ 125 | if force: 126 | print("Forcing config refactoring") 127 | if not os.path.exists(HOME) or force: 128 | try: 129 | try: 130 | os.makedirs(HOME) 131 | os.makedirs(RECIPE_HOME) 132 | except: 133 | pass 134 | print( 135 | "You will need to accept out EULA before you begin, you will only see this once, " 136 | "you can find our terms of service here: https://malcore.io/terms-of-use" 137 | ) 138 | print(f"\n{EULA}\n") 139 | is_accepted = False 140 | acceptable_answers = ('yes', 'no') 141 | while not is_accepted: 142 | answer = input("To accept type 'yes' to decline type 'no': ").strip() 143 | if answer.lower() not in list(acceptable_answers): 144 | logger.warning("Please type 'yes' or 'no'") 145 | elif answer.lower() == 'yes': 146 | open(ACCEPTED_EULA, "a+").write(f"Accepted on: {datetime.datetime.utcnow().isoformat()} UTC") 147 | is_accepted = True 148 | else: 149 | print("You have declined the EULA, this program will now exit") 150 | os.remove(HOME) 151 | return 152 | trial_file = f"{HOME}/.trial" 153 | with open(trial_file, "w") as fh: 154 | json.dump({"trial_end_date": int(time.time())}, fh) 155 | with open(PLAN_FILE, "w") as fh: 156 | fh.write("*") 157 | print("A free 30 day trial has been activated for you!") 158 | with open(CONFIG_FILE, 'w') as fh: 159 | print( 160 | "You will need to login to start using the Malcore Playbook. If you do not have an account " 161 | "please make one here: https://app.malcore.io/register" 162 | ) 163 | api_ = api.Api(only_remote=True) 164 | entered = False 165 | while not entered: 166 | username = input("Enter your email: ") 167 | password = getpass.getpass("Enter your password: ") 168 | if username == "" or password == "": 169 | print("Please enter your credentials") 170 | else: 171 | data = api_.login(username, password) 172 | if data['data'] is None: 173 | print("Got error while trying to login:") 174 | for warning in data['messages']: 175 | print(f"Type: {warning['type']} Message: {warning['message']}") 176 | print("Please try again") 177 | else: 178 | key = data['data']['user']['apiKey'] 179 | user_plan = data['data']['user']['subscription']['name'] 180 | plan_id = data['data']['user']['subscription']['planId'] 181 | file_size_limit = data['data']['user']['subscription']['fileSizeLimit'] 182 | entered = True 183 | key = key.strip() 184 | json.dump({"api_key": key, "plan_id": plan_id, "file_size_limit": file_size_limit}, fh) 185 | with open(BACKUP_USER_PLAN, 'w') as fh1: 186 | fh1.write(user_plan) 187 | if force: 188 | print("Config refactored successfully, you will need to rerun the program to start") 189 | else: 190 | print("Initialization completed, you will need to rerun the program to start") 191 | exit(1) 192 | except KeyboardInterrupt: 193 | print("User quit install, removing home directory") 194 | try: 195 | os.remove(HOME) 196 | except: 197 | print(f"Failed to remove home directory do so manually: {HOME}") 198 | sys.exit(1) 199 | except Exception as e: 200 | print(f"Caught error: {str(e)}, please start the program again") 201 | try: 202 | os.remove(HOME) 203 | except: 204 | print(f"Failed to remove the HOME directory to restart install, do so manually: {HOME}") 205 | sys.exit(1) 206 | else: 207 | check_is_trial() 208 | with open(CONFIG_FILE, 'r') as fh: 209 | return json.load(fh) 210 | 211 | 212 | def setup_logger(logger_name="MalcorePlaybook"): 213 | """ logger setup """ 214 | log_dir = f"{HOME}" 215 | if not os.path.exists(log_dir): 216 | init() 217 | logger = logging.getLogger(logger_name) 218 | logger.setLevel(logging.DEBUG) 219 | file_formatter = logging.Formatter('[%(asctime)s][%(module)s:%(funcName)s:%(lineno)d][%(name)s][%(levelname)s] %(message)s') 220 | output_formatter = logging.Formatter('[%(asctime)s][%(name)s][%(levelname)s] %(message)s') 221 | output_handler = logging.StreamHandler() 222 | output_handler.setLevel(logging.DEBUG) 223 | output_handler.setFormatter(output_formatter) 224 | stream_handler = RotatingFileHandler(f"{log_dir}/malcore-playbook.json", maxBytes=1_000_000, backupCount=3) 225 | stream_handler.setLevel(logging.DEBUG) 226 | stream_handler.setFormatter(JsonLogFormatter()) 227 | file_handler = RotatingFileHandler(f"{log_dir}/malcore-playbook.log", maxBytes=1_000_000, backupCount=3) 228 | file_handler.setLevel(logging.DEBUG) 229 | file_handler.setFormatter(file_formatter) 230 | logger.addHandler(file_handler) 231 | logger.addHandler(output_handler) 232 | logger.addHandler(stream_handler) 233 | return logger 234 | 235 | 236 | logger = setup_logger() 237 | 238 | 239 | def load_conf(): 240 | """ load the config file """ 241 | with open(CONFIG_FILE, 'r') as fh: 242 | return json.load(fh) 243 | 244 | 245 | def display_recipes(recipes, filter_=None): 246 | """ display the recipes passed in a pretty format """ 247 | max_display_chars = 25 248 | col_width = 35 249 | s = f"{'Recipe:'.ljust(col_width)}{'Version'.ljust(col_width)}{'Author'}" 250 | print("-" * (len(s) + 10)) 251 | print(s) 252 | for recipe in recipes: 253 | if filter_ is None: 254 | recipe_name = recipe["filename"].split(".")[0] 255 | recipe_name = recipe_name[:max_display_chars] 256 | version = recipe['version'][:max_display_chars] 257 | author = recipe['author'][:max_display_chars] 258 | print(f"{recipe_name.ljust(col_width)}{version.ljust(col_width)}{author}") 259 | else: 260 | recipe_name = recipe["filename"].split(".")[0] 261 | if filter_ in recipe_name: 262 | recipe_name = recipe_name[:max_display_chars] 263 | version = recipe['version'][:max_display_chars] 264 | author = recipe['author'][:max_display_chars] 265 | print(f"{recipe_name.ljust(col_width)}{version.ljust(col_width)}{author}") 266 | print("-" * (len(s) + 10)) 267 | 268 | 269 | def create_recipe_dict_from_local(local_files): 270 | """ creates a dict from local recipe files""" 271 | recipes = [] 272 | for file_ in local_files: 273 | if not any(p in file_ for p in ("__pycache__", "__init__.py")): 274 | path, fname = os.path.split(file_) 275 | modulename, _ = os.path.splitext(fname) 276 | if path not in sys.path: 277 | sys.path.insert(0, path) 278 | i = __import__(modulename) 279 | author = i.__author__ 280 | version = i.__version__ 281 | filename = file_.split(os.path.sep)[-1] 282 | recipes.append({"filename": filename, "version": version, "author": author}) 283 | return recipes 284 | 285 | 286 | def download_recipe(recipe, full_data, force=False, show_output=True): 287 | """ download the external recipes into the RECIPE directory """ 288 | try: 289 | output_dir = f"{RECIPE_HOME}{os.path.sep}{recipe}.py" 290 | logger.info(f"Attempting to download recipe: {recipe} to {output_dir}") 291 | if os.path.exists(output_dir): 292 | if not force: 293 | logger.error("Recipe exists, skipping installation") 294 | return 295 | else: 296 | logger.debug("Recipe exists, forcing installation") 297 | url = f"https://recipes.malcore.io/recipes/{recipe}.py" 298 | logger.info(f"Downloading recipe from: {url}") 299 | if show_output: 300 | print(f"Recipe metadata:\n" 301 | f"\tHashsum: {full_data['hashsum']}\n" 302 | f"\tVersion: {full_data['version']}\n" 303 | f"\tAuthor: {full_data['author']}") 304 | with open(output_dir, 'wb') as fh: 305 | logger.debug("Starting download ....") 306 | with requests.get(url, stream=True) as req: 307 | req.raise_for_status() 308 | for chunk in req.iter_content(chunk_size=1024): 309 | if chunk: 310 | fh.write(chunk) 311 | logger.info(f"Recipe downloaded successfully to: {output_dir}") 312 | return True 313 | except Exception as e: 314 | logger.error(f"Hit error: {str(e)} while downloading recipe") 315 | return False 316 | 317 | 318 | def load_recipes(): 319 | """ load all the local recipes """ 320 | return os.listdir(RECIPE_HOME) 321 | 322 | 323 | def create_recipe_list_from_passed(passed): 324 | """ create a list of files from loaded recipes """ 325 | logger.debug(f"Creating recipe list from passed, total of {len(passed)} recipe(s) to process") 326 | results = [] 327 | for recipe in passed: 328 | results.append(f"{recipe}.py") 329 | return results 330 | 331 | 332 | def hash_file(filename): 333 | """ get the hash of a file """ 334 | if isinstance(filename, list): 335 | filename = filename[0] 336 | with open(filename, 'rb') as fh: 337 | h = hashlib.sha256() 338 | h.update(fh.read()) 339 | return h.hexdigest() 340 | 341 | 342 | def create_output_file(output_type, filename, recipe): 343 | """ creates a output file from the passed data """ 344 | logger.debug(f"Creating output, passed type: {output_type}") 345 | file_hash = hash_file(filename) 346 | ext = output_type.lower() 347 | if ext != "console": 348 | if not os.path.exists(OUTPUT_DIR): 349 | os.makedirs(OUTPUT_DIR) 350 | if not os.path.exists(f"{OUTPUT_DIR}{os.path.sep}{output_type}"): 351 | os.makedirs(f"{OUTPUT_DIR}{os.path.sep}{output_type}") 352 | file_path = f"{OUTPUT_DIR}{os.path.sep}{output_type}{os.path.sep}{file_hash}-{recipe}.{ext}" 353 | logger.debug(f"Output file path generated, will use path: {file_path}") 354 | return file_path 355 | else: 356 | logger.debug("Output will be console based") 357 | return "CONSOLE" 358 | 359 | 360 | def create_output(output_results, output_file): 361 | """ creates the file that the output will be stored in """ 362 | if output_file.endswith(".json"): 363 | output = json_writer.output(output_results, output_file) 364 | elif output_file.endswith("pdf"): 365 | output = pdf_writer.output(output_results, output_file) 366 | elif output_file.endswith("txt"): 367 | output = txt_writer.output(output_results, output_file) 368 | elif output_file == "CONSOLE": 369 | output = console_writer.output(output_results, output_file) 370 | else: 371 | raise NotImplementedError("That output type is not implemented yet") 372 | return output 373 | 374 | 375 | def get_user_plan(): 376 | """ get the plan the user is currently using """ 377 | return open(PLAN_FILE).read() 378 | 379 | 380 | def user_can_use_recipe(allowed): 381 | """ can the user execute the passed recipe? """ 382 | user_plan = get_user_plan() 383 | if check_is_trial(): 384 | return True 385 | if allowed is None: 386 | return True 387 | else: 388 | if allowed.lower() in user_plan.lower(): 389 | return False 390 | return True 391 | 392 | 393 | def check_is_trial(): 394 | """ is the user under the trial? """ 395 | trial_file = f"{HOME}/.trial" 396 | if not os.path.exists(trial_file): 397 | return False 398 | else: 399 | try: 400 | data = json.load(open(trial_file)) 401 | except: 402 | data = None 403 | if data is None: 404 | return False 405 | else: 406 | trial_end_timestamp = data['trial_end_date'] 407 | today = datetime.datetime.utcnow() 408 | trial_end_date = datetime.datetime.utcfromtimestamp(trial_end_timestamp) 409 | trial_end_date = (trial_end_date + datetime.timedelta(days=30)).date() 410 | if today.date() > trial_end_date: 411 | logger.warning( 412 | "Your premium trial has expired, please sign up for a plan here: https://malcore.io/pricing" 413 | ) 414 | try: 415 | with open(PLAN_FILE, 'w') as fh: 416 | new_plan = open(BACKUP_USER_PLAN).read() 417 | fh.write(new_plan) 418 | os.remove(trial_file) 419 | except Exception as e: 420 | logger.error(f"Failed to remove trial file, got error: {str(e)}") 421 | else: 422 | already_said = f"{HOME}/.spoken" 423 | if not os.path.exists(already_said): 424 | logger.info( 425 | f"You are currently on a premium trial membership! Your trial ends on: {trial_end_date}. " 426 | f"By using this trial you are accepting our Terms of Service: https://malcore.io/terms-of-use, " 427 | f"to upgrade your plan please see here: https://malcore.io/pricing" 428 | ) 429 | open(already_said, "w").close() 430 | return True 431 | 432 | 433 | def execute_chain(script, filename, **kwargs): 434 | """ execute the MalScript """ 435 | if os.path.exists(script): 436 | extension = os.path.splitext(script)[1] 437 | acceptable_extensions = (".mals", ".mal", ".ms") 438 | if any(extension == e for e in list(acceptable_extensions)): 439 | exec_script = open(script).read() 440 | else: 441 | raise chain_script.InvalidScriptPassed( 442 | f"Script extension is unverifiable (not any of: {', '.join(list(acceptable_extensions))}), " 443 | f"will not execute the script" 444 | ) 445 | else: 446 | exec_script = script 447 | return chain_script.run(exec_script, filename, kwargs) 448 | 449 | 450 | def download_all_recipes(only_list=False): 451 | """ download all external recipes """ 452 | percent = lambda part, whole: round((part / whole) * 100, 2) 453 | _api = api.Api(only_remote=True) 454 | data = _api.list_recipes() 455 | total_recipes = len(data) 456 | if only_list: 457 | return data 458 | total_downloaded = 0 459 | for recipe in data: 460 | is_successful = download_recipe(recipe['filename'].split(".")[0], recipe, force=True) 461 | if is_successful: 462 | total_downloaded += 1 463 | open(HAS_INITIALIZED_DOWNLOADS, "a+").close() 464 | logger.info(f"Downloaded {total_downloaded} recipes out of {total_recipes} ({percent(total_downloaded, total_recipes)}%)") 465 | 466 | 467 | def check_for_recipe_updates(force_download=False): 468 | """ check if any of the local recipes need to be updated from the external source """ 469 | import malcore_playbook.execution.recipe_exec as recipe_exec 470 | 471 | updates_needed = [] 472 | total_needed = 0 473 | 474 | if force_download: 475 | logger.debug("Will be downloading all recipes that need to be updated automatically") 476 | 477 | downloaded_recipes = load_recipes() 478 | remote_recipes = download_all_recipes(only_list=True) 479 | imported_recipes = recipe_exec.load_recipe(downloaded_recipes, speak=False) 480 | local_recipes = [[i.__hashsum__, i.__file__.split(os.path.sep)[-1]] for i in imported_recipes] 481 | for remote_recipe in remote_recipes: 482 | for local_recipe in local_recipes: 483 | if local_recipe[1] == remote_recipe['filename']: 484 | if not local_recipe[0] == remote_recipe['hashsum']: 485 | total_needed += 1 486 | logger.warning( 487 | f"Recipe: {local_recipe[1]} has an update available to version: {remote_recipe['version']}" 488 | ) 489 | updates_needed.append(local_recipe[1]) 490 | if force_download: 491 | download_recipe(remote_recipe['filename'].split(".")[0], remote_recipe, force=True) 492 | if len(updates_needed) == 0: 493 | logger.info("There are no recipes that need to be updated") 494 | else: 495 | logger.warning(f"There are a total of {total_needed} recipe(s) that require an update") 496 | 497 | 498 | def check_for_updates(): 499 | """ checks if the program is up to date or not """ 500 | try: 501 | current_version = VERSION 502 | url = f"https://pypi.org/pypi/malcore-playbook/json" 503 | req = requests.get(url, timeout=3) 504 | data = req.json() 505 | newest_version = data['info']['version'] 506 | if current_version < newest_version: 507 | logger.warning(f"New version available, it is highly suggested that you update to version: {newest_version}") 508 | except: 509 | logger.warning("Unable to check for a newer version") 510 | --------------------------------------------------------------------------------