├── .env ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── blueprints ├── access.py ├── assessment.py ├── assessment_export.py ├── assessment_import.py ├── assessment_utils.py ├── testcase.py └── testcase_utils.py ├── compose.yml ├── custom ├── knowledgebase │ └── T1003.yaml ├── reports │ └── sample.docx └── testcases.json ├── entrypoint.sh ├── flask.cfg ├── model.py ├── pops-backup.py ├── purpleops.py ├── requirements.txt ├── seeder.py ├── static ├── images │ ├── demo.gif │ ├── logo.ico │ └── logo.png ├── scripts │ ├── access.js │ ├── access.random_pass.js │ ├── assessment.js │ ├── assessment.stats.js │ ├── assessments.js │ ├── bootstrap-select.min.js │ ├── bootstrap-table-cookie.min.js │ ├── bootstrap-table-filter-control.min.js │ ├── bootstrap-table.min.js │ ├── bootstrap.bundle.min.js │ ├── jquery.min.js │ ├── popper.min.js │ └── testcase.js └── style │ ├── bootstrap-icons.css │ ├── bootstrap-select.min.css │ ├── bootstrap-table.min.css │ ├── bootstrap.min.css │ ├── bootstrap.min.css.map │ ├── bootstrappulse.min.css │ └── fonts │ └── bootstrap-icons.woff2 ├── templates ├── access.html ├── access_modals.html ├── assessment.html ├── assessment_hexagons.svg ├── assessment_modals.html ├── assessment_navigator.html ├── assessment_stats.html ├── assessments.html ├── assessments_modals.html ├── login.html ├── macros.html ├── master.html ├── master_modals.html ├── mfa_register.html ├── mfa_verify.html ├── password_change.html ├── testcase.html ├── testcase_blue.html ├── testcase_modals.html └── testcase_red.html └── utils.py /.env: -------------------------------------------------------------------------------- 1 | MONGO_DB=assessments3 2 | MONGO_HOST=mongodb 3 | MONGO_PORT=27017 4 | 5 | FLASK_DEBUG=True 6 | FLASK_MFA=False 7 | 8 | HOST=0.0.0.0 9 | PORT=8888 10 | NAME=dev 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env*/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Dev artifacts 132 | *.xlsx 133 | files/ 134 | 135 | supervisord.pid 136 | sampledata/sigma/* 137 | external/* 138 | INITIAL_ADMIN_PASSWORD.TXT -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # pull official base image 2 | FROM python:3.11.3-slim-buster 3 | 4 | # set work directory 5 | WORKDIR /usr/src/app 6 | 7 | # set environment variables 8 | ENV PYTHONDONTWRITEBYTECODE 1 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | # install system dependencies 12 | RUN apt-get update && apt-get install -y netcat git 13 | 14 | # install dependencies 15 | RUN pip install --upgrade pip 16 | COPY ./requirements.txt /usr/src/app/requirements.txt 17 | RUN pip install -r requirements.txt 18 | 19 | # copy project 20 | COPY . /usr/src/app/ 21 | 22 | # run entrypoint.sh 23 | ENTRYPOINT ["/usr/src/app/entrypoint.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Willem Mouton & Harrison Mitchell 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | PurpleOps Logo 4 |
5 | PurpleOps 6 |
7 |

8 | 9 |

An open-source self-hosted purple team management web application.

10 | 11 |

12 | 13 | 14 |

15 | 16 |

17 | Key Features • 18 | Installation • 19 | Contact Us • 20 | Credit • 21 | License 22 |

23 | 24 |

25 | 26 |

27 | 28 | ## Key Features 29 | 30 | * Template engagements and testcases 31 | * Framework friendly 32 | * Role-based Access Control & MFA 33 | * Inbuilt DOCX reporting + custom template support 34 | 35 | How PurpleOps is different: 36 | 37 | * No attribution needed 38 | * Hackable, no "no-reversing" clauses 39 | * No over complications with tomcat, redis, manual database transplanting and an obtuce permission model 40 | 41 | ## Installation 42 | 43 | ### Default 44 | 45 | ```bash 46 | # Clone this repository 47 | $ git clone https://github.com/CyberCX-STA/PurpleOps 48 | 49 | # Go into the repository 50 | $ cd PurpleOps 51 | 52 | # Alter PurpleOps settings (if you want to customize anything but should work out the box) 53 | $ nano .env 54 | 55 | # Run the app with docker (add `-d` to run in background) 56 | $ sudo docker compose up 57 | 58 | # PurpleOps should now by available on http://localhost:5000, it is recommended to add a reverse proxy such as nginx or Apache in front of it if you want to expose this to the outside world. 59 | ``` 60 | 61 |
62 |

Kali

63 | 64 | ```bash 65 | # Install docker-compose 66 | sudo apt install docker-compose -y 67 | 68 | # Clone this repository 69 | $ git clone https://github.com/CyberCX-STA/PurpleOps 70 | 71 | # Go into the repository 72 | $ cd PurpleOps 73 | 74 | # Alter PurpleOps settings (if you want to customize anything but should work out the box) 75 | $ nano .env 76 | 77 | # Run the app with docker (add `-d` to run in background) 78 | $ sudo docker-compose up 79 | 80 | # PurpleOps should now by available on http://localhost:5000, it is recommended to add a reverse proxy such as nginx or Apache in front of it if you want to expose this to the outside world. 81 | ``` 82 |
83 | 84 |
85 |

Manual

86 | 87 | ```bash 88 | # Alternatively 89 | $ sudo docker run --name mongodb -d -p 27017:27017 mongo 90 | $ pip3 install -r requirements.txt 91 | $ python3 seeder.py 92 | $ python3 purpleops.py 93 | ``` 94 |
95 | 96 |
97 |

NGINX Reverse Proxy + Certbot

98 | 99 | Replace 2x `purpleops.example.com` with your FQDN and ensure your box is open internet-wide on 80/443. 100 | 101 | ```bash 102 | sudo apt install nginx certbot python3-certbot-nginx -y 103 | sudo nano /etc/nginx/sites-available/purpleops # Paste below file 104 | sudo ln -s /etc/nginx/sites-available/purpleops /etc/nginx/sites-enabled/ 105 | sudo certbot --nginx -d purpleops.example.com 106 | sudo service nginx restart 107 | ``` 108 | 109 | ``` 110 | server { 111 | listen 80; 112 | server_name purpleops.example.com; 113 | 114 | location / { 115 | proxy_pass http://localhost:5000; 116 | proxy_set_header Host $host; 117 | proxy_set_header X-Real-IP $remote_addr; 118 | } 119 | } 120 | ``` 121 |
122 | 123 |
124 |

IP Whitelisting with ufw

125 | 126 | ```bash 127 | sudo apt install ufw -y 128 | sudo ufw allow 22 129 | sudo ufw deny 80 130 | sudo ufw deny 443 131 | sudo ufw insert 1 allow from 100.100.100.100/24 to any port 443 132 | sudo ufw enable 133 | ``` 134 |
135 | 136 |
137 |

Resetting MFA

138 | 139 | ```bash 140 | sudo docker exec -it purpleops flask --app purpleops.py shell 141 | from model import User; user = User.objects(email="userto@reset.here").first(); user.tf_totp_secret = None; user.save() 142 | ``` 143 |
144 | 145 | ## Contact Us 146 | 147 | We would love to hear back from you, if something is broken or have and idea to make it better add a ticket or connect to us on the [PurpleOps Discord](https://discord.gg/2xeA6FB3GJ) or email us at pops@purpleops.app | `@_w_m__` 148 | 149 | ## Credits 150 | 151 | - Atomic Red Team ([LICENSE](https://github.com/redcanaryco/atomic-red-team/blob/master/LICENSE.txt)) for sample commands 152 | - [CyberCX](https://cybercx.com.au/) for foundational support 153 | 154 | ## License 155 | 156 | Apache 157 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO Living Document 2 | 3 | ## MVP 4 | 5 | - Platform 6 | - [X] Authentication - Users can login 7 | - [~] Authorisation - Users only have access to particular projects 8 | - [X] Account provisioning 9 | - [X] Creating engagements 10 | - [~] Interface with Git KB 11 | - [X] Interface with Twilight (via export functionality?) 12 | - [X] Dirty bootstrap UI 13 | - [ ] Configuration file 14 | - [ ] Logging 15 | 16 | - Engagement management 17 | - [X] Creating / Editing / Delete 18 | - [X] Prompt / Warn on deletion 19 | - [X] Alter access controls / perms per user 20 | 21 | - Engagement 22 | - [~] Import test cases from repository 23 | - [X] Import test cases from MitreLayer dump 24 | - [X] Delete test case 25 | - [X] Duplicate test case 26 | 27 | - Test cases 28 | - [X] General 29 | - [X] Name 30 | - [X] Tagging 31 | - [X] Sigma rules 32 | - [X] Red 33 | - [X] Case start / stop 34 | - [X] Phase 35 | - [X] Source 36 | - [X] Target(s) 37 | - [X] TTP Boilerplate description 38 | - [X] Execution description 39 | - [X] Command description 40 | - [X] Technique number 41 | - [X] Red tools 42 | - [X] Evidence 43 | - [X] Blue 44 | - [X] Outcome 45 | - [X] Prevention level 46 | - [X] Detection level 47 | - [X] Blue tools 48 | - [X] Freeform text for SIEM event IDs / links etc. 49 | - [X] Evidence 50 | - [X] Target / source / red+blue tool bank / selection modals 51 | 52 | ## "V2 thing" 53 | 54 | - [ ] Platform 55 | - [X] IP whitelisting 56 | - [ ] Pretty up UI 57 | - [X] Teams / slack integrations 58 | 59 | - [ ] Engagement management 60 | - [X] Renaming 61 | - [X] Duplicate 62 | - [X] Delete 63 | - [X] Import from template 64 | - [X] Export to template 65 | - [ ] Start / end engagement overall timeline 66 | 67 | - [ ] Engagement 68 | - [ ] Attack / escalation path mindmap 69 | - [ ] Timeline 70 | 71 | - [ ] Test cases 72 | - [~] Pull fresh content / sync from git 73 | - [ ] Push to approval / review queue to update KB 74 | - [ ] JS lib for image cropping / redacting / highlighting evidence inline 75 | -------------------------------------------------------------------------------- /blueprints/access.py: -------------------------------------------------------------------------------- 1 | from model import * 2 | from utils import applyFormData 3 | from flask import Blueprint, redirect, request, render_template, jsonify 4 | from flask_security import auth_required, utils, current_user, roles_accepted 5 | 6 | blueprint_access = Blueprint('blueprint_access', __name__) 7 | 8 | @blueprint_access.route('/password/changed', methods = ['GET']) 9 | @auth_required() 10 | def passwordchanged(): 11 | current_user.initpwd = False 12 | current_user.save() 13 | return redirect("/") 14 | 15 | @blueprint_access.route('/manage/access', methods = ['GET']) 16 | @auth_required() 17 | @roles_accepted('Admin') 18 | def users(): 19 | return render_template( 20 | 'access.html', 21 | users = User.objects, 22 | assessments = Assessment.objects, 23 | roles = Role.objects 24 | ) 25 | 26 | @blueprint_access.route('/manage/access/user', methods = ['POST']) 27 | @auth_required() 28 | @roles_accepted('Admin') 29 | def createuser(): 30 | user = user_datastore.create_user( 31 | email = request.form['email'], 32 | username = request.form['username'], 33 | password = utils.hash_password(request.form['password']), 34 | roles = [Role.objects(name=role).first() for role in request.form.getlist('roles')], 35 | assessments = [Assessment.objects(name=assessment).first() for assessment in request.form.getlist('assessments')] 36 | ) 37 | return jsonify(user.to_json()), 200 38 | 39 | @blueprint_access.route('/manage/access/user/', methods = ['POST', 'DELETE']) 40 | @auth_required() 41 | @roles_accepted('Admin') 42 | def edituser(id): 43 | origUser = User.objects(id=id).first() 44 | user = User.objects(id=id).first() 45 | if request.method == 'POST': 46 | if "password" in request.form and request.form['password'].strip(): 47 | user.password = utils.hash_password(request.form['password']) 48 | 49 | user = applyFormData(user, request.form, ["username", "email"]) 50 | # You cannot rename the inbuilt admin account 51 | if origUser.username == "admin" and user.username != "admin": 52 | user.username = "admin" 53 | 54 | user.roles = [] 55 | for role in request.form.getlist('roles'): 56 | user.roles.append(Role.objects(name=role).first()) 57 | # You cannot de-admin the inbuilt admin, re-add admin wiped admin role 58 | if user.username == "admin" and "Admin" not in [u.name for u in user.roles]: 59 | user.roles.append(Role.objects(name="Admin").first()) 60 | 61 | user.assessments = [] 62 | for assessment in request.form.getlist('assessments'): 63 | user.assessments.append(Assessment.objects(name=assessment).first()) 64 | # Admin users have implied access to all assessments, wipe selected assessments 65 | if "Admin" in [u.name for u in user.roles]: 66 | user.assessments = [] 67 | 68 | user.save() 69 | return jsonify(user.to_json()), 200 70 | 71 | if request.method == 'DELETE': 72 | # Prevent inbuilt admin deletion 73 | if user.username != "admin": 74 | user.delete() 75 | user.save() 76 | return "", 200 -------------------------------------------------------------------------------- /blueprints/assessment.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from model import * 4 | from glob import glob 5 | from utils import applyFormData, user_assigned_assessment 6 | from flask_security import auth_required, roles_accepted, current_user 7 | from flask import Blueprint, render_template, request, jsonify 8 | from blueprints.assessment_utils import assessmenthexagons 9 | 10 | blueprint_assessment = Blueprint('blueprint_assessment', __name__) 11 | 12 | @blueprint_assessment.route('/assessment', methods = ['POST']) 13 | @auth_required() 14 | @roles_accepted('Admin') 15 | def newassessment(): 16 | assessment = Assessment( 17 | name = request.form['name'], 18 | description = request.form['description'] 19 | ) 20 | assessment.save() 21 | 22 | if not os.path.exists(f"files/{str(assessment.id)}"): 23 | os.makedirs(f"files/{str(assessment.id)}") 24 | 25 | return jsonify(assessment.to_json()), 200 26 | 27 | @blueprint_assessment.route('/assessment/', methods = ['POST']) 28 | @auth_required() 29 | @roles_accepted('Admin') 30 | def editassessment(id): 31 | assessment = Assessment.objects(id=id).first() 32 | assessment = applyFormData(assessment, request.form, ["name", "description"]) 33 | assessment.save() 34 | 35 | return jsonify(assessment.to_json()), 200 36 | 37 | @blueprint_assessment.route('/assessment/', methods = ['DELETE']) 38 | @auth_required() 39 | @roles_accepted('Admin') 40 | def deleteassessment(id): 41 | assessment = Assessment.objects(id=id).first() 42 | [testcase.delete() for testcase in TestCase.objects(assessmentid=id).all()] 43 | if os.path.exists(f"files/{str(assessment.id)}"): 44 | shutil.rmtree(f"files/{str(assessment.id)}") 45 | assessment.delete() 46 | return "", 200 47 | 48 | @blueprint_assessment.route('/assessment/', methods = ['GET']) 49 | @auth_required() 50 | @user_assigned_assessment 51 | def loadassessment(id): 52 | return render_template( 53 | 'assessment.html', 54 | testcases = TestCase.objects(assessmentid=id).all(), 55 | assessment = Assessment.objects(id=id).first(), 56 | templates = TestCaseTemplate.objects(), 57 | mitres = sorted( 58 | [[m["mitreid"], m["name"]] for m in Technique.objects()], 59 | key=lambda m: m[0] 60 | ), 61 | tactics = [tactic["name"] for tactic in Tactic.objects()], 62 | hexagons = assessmenthexagons(id), 63 | reports = [f.split("/")[-1] for f in sorted(glob("custom/reports/*.docx"))] 64 | ) -------------------------------------------------------------------------------- /blueprints/assessment_export.py: -------------------------------------------------------------------------------- 1 | import os 2 | import csv 3 | import json 4 | import shutil 5 | from model import * 6 | from docxtpl import DocxTemplate 7 | from utils import user_assigned_assessment 8 | from werkzeug.utils import secure_filename 9 | from flask_security import auth_required, current_user 10 | from flask import Blueprint, request, send_from_directory 11 | 12 | blueprint_assessment_export = Blueprint('blueprint_assessment_export', __name__) 13 | 14 | # CSV / JSON export (we testcase[].to_json() then CSV the JSON dict, so function is reused) 15 | @blueprint_assessment_export.route('/assessment//export/',methods = ['GET']) 16 | @auth_required() 17 | @user_assigned_assessment 18 | def exportassessment(id, filetype): 19 | if filetype not in ["json", 'csv']: 20 | return 401 21 | 22 | assessment = Assessment.objects(id=id).first() 23 | if current_user.has_role("Blue"): 24 | testcases = TestCase.objects(assessmentid=str(assessment.id), visible=True).all() 25 | else: 26 | testcases = TestCase.objects(assessmentid=str(assessment.id)).all() 27 | 28 | jsonDict = [] 29 | for testcase in testcases: 30 | jsonDict.append(testcase.to_json(raw=True)) 31 | 32 | # Write JSON and if JSON requested, deliver file and return 33 | with open(f'files/{str(assessment.id)}/export.json', 'w') as f: 34 | json.dump(jsonDict, f, indent=4) 35 | if filetype == "json": 36 | return send_from_directory('files', f"{str(assessment.id)}/export.{filetype}", as_attachment=True) 37 | 38 | # Otherwise flatten JSON arrays into comma delimited strings 39 | for t, testcase in enumerate(jsonDict): 40 | for field in ["sources", "targets", "tools", "controls", "tags", "redfiles", "bluefiles"]: 41 | jsonDict[t][field] = ",".join(testcase[field]) 42 | 43 | # Convert the JSON dict to CSV and deliver 44 | with open(f'files/{str(assessment.id)}/export.csv', 'w', encoding='UTF8', newline='') as f: 45 | if not testcases: 46 | f.write("") 47 | else: 48 | writer = csv.DictWriter(f, fieldnames=jsonDict[0].keys()) 49 | writer.writeheader() 50 | writer.writerows(jsonDict) 51 | 52 | return send_from_directory('files', f"{str(assessment.id)}/export.{filetype}", as_attachment=True) 53 | 54 | @blueprint_assessment_export.route('/assessment//export/campaign', methods = ['GET']) 55 | @auth_required() 56 | @user_assigned_assessment 57 | def exportcampaign(id): 58 | assessment = Assessment.objects(id=id).first() 59 | if current_user.has_role("Blue"): 60 | testcases = TestCase.objects(assessmentid=str(assessment.id), visible=True).all() 61 | else: 62 | testcases = TestCase.objects(assessmentid=str(assessment.id)).all() 63 | 64 | jsonDict = [] 65 | for testcase in testcases: 66 | # Generate a full JSON dump but then filter to only the applicable fields 67 | fullJson = testcase.to_json(raw=True) 68 | campaignJson = {} 69 | for field in ["mitreid", "tactic", "name", "objective", "actions", "tools", "uuid", "tags"]: 70 | campaignJson[field] = fullJson[field] 71 | jsonDict.append(campaignJson) 72 | 73 | with open(f'files/{str(assessment.id)}/campaign.json', 'w') as f: 74 | json.dump(jsonDict, f, indent=4) 75 | 76 | return send_from_directory('files', f"{str(assessment.id)}/campaign.json", as_attachment=True) 77 | 78 | @blueprint_assessment_export.route('/assessment//export/templates',methods = ['GET']) 79 | @auth_required() 80 | @user_assigned_assessment 81 | def exporttestcases(id): 82 | # Hijack the campaign exporter and inject a "provider" field 83 | exportcampaign(id) 84 | with open(f'files/{id}/campaign.json', 'r') as f: 85 | jsonDict = json.load(f) 86 | 87 | for t, _ in enumerate(jsonDict): 88 | jsonDict[t]["provider"] = "???" 89 | 90 | with open(f'files/{id}/testcases.json', 'w') as f: 91 | json.dump(jsonDict, f, indent=4) 92 | 93 | return send_from_directory('files', f"{id}/testcases.json", as_attachment=True) 94 | 95 | @blueprint_assessment_export.route('/assessment//export/report',methods = ['POST']) 96 | @auth_required() 97 | @user_assigned_assessment 98 | def exportreport(id): 99 | assessment = Assessment.objects(id=id).first().to_json(raw=True) 100 | 101 | if not os.path.isfile(f"custom/reports/{secure_filename(request.form['report'])}"): 102 | return "", 401 103 | 104 | # Hijack assessment JSON export 105 | exportassessment(id, "json") 106 | with open(f'files/{id}/export.json', 'r') as f: 107 | testcases = json.load(f) 108 | 109 | doc = DocxTemplate(f"custom/reports/{secure_filename(request.form['report'])}") 110 | doc.render({ 111 | "assessment": assessment, 112 | "testcases": testcases 113 | }) 114 | doc.save(f'files/{id}/report.docx') 115 | 116 | return send_from_directory('files', f"{id}/report.docx", as_attachment=True) 117 | 118 | @blueprint_assessment_export.route('/assessment//export/navigator',methods = ['GET']) 119 | @auth_required() 120 | @user_assigned_assessment 121 | def exportnavigator(id): 122 | # Sanity check to ensure assessment exists and to die if not 123 | _ = Assessment.objects(id=id).first() 124 | navigator = { 125 | "name": Assessment.objects(id=id).first().name, 126 | "versions": { 127 | # "attack": "13", "Required" but no warning - so ignoring 128 | # "navigator": "4.9.1", "Required" but no warning - so ignoring 129 | "layer": "4.5" 130 | }, 131 | "domain": "enterprise-attack", 132 | "sorting": 3, 133 | "layout": { 134 | "layout": "flat", 135 | "aggregateFunction": "average", 136 | "showID": True, 137 | "showName": True, 138 | "showAggregateScores": True, 139 | "countUnscored": False 140 | }, 141 | "hideDisabled": False, 142 | "techniques": [], 143 | "gradient": { 144 | "colors": [ 145 | "#ff6666ff", 146 | "#ffe766ff", 147 | "#8ec843ff" 148 | ], 149 | "minValue": 0, 150 | "maxValue": 100 151 | }, 152 | "showTacticRowBackground": True, 153 | "tacticRowBackground": "#593196", 154 | "selectTechniquesAcrossTactics": True, 155 | "selectSubtechniquesWithParent": False 156 | } 157 | 158 | for technique in Technique.objects().all(): 159 | if current_user.has_role("Blue"): 160 | testcases = TestCase.objects(assessmentid=id, visible=True, mitreid=technique.mitreid).all() 161 | else: 162 | testcases = TestCase.objects(assessmentid=id, mitreid=technique.mitreid).all() 163 | ttp = { 164 | "techniqueID": technique.mitreid 165 | } 166 | 167 | if testcases: 168 | count = 0 169 | outcomes = {"Prevented": 0, "Alerted": 0, "Logged": 0, "Missed": 0} 170 | for testcase in testcases: 171 | if testcase.outcome in outcomes.keys(): 172 | count += 1 173 | outcomes[testcase.outcome] += 1 174 | 175 | if count: 176 | score = int((outcomes["Prevented"] * 3 + outcomes["Alerted"] * 2 + 177 | outcomes["Logged"]) / (count * 3) * 100) 178 | ttp["score"] = score 179 | 180 | for tactic in technique.tactics: 181 | tactic = tactic.lower().strip().replace(" ", "-") 182 | tacticTTP = dict(ttp) 183 | tacticTTP["tactic"] = tactic 184 | navigator["techniques"].append(tacticTTP) 185 | 186 | with open(f'files/{id}/navigator.json', 'w') as f: 187 | json.dump(navigator, f, indent=4) 188 | 189 | return send_from_directory('files', f"{id}/navigator.json", as_attachment=True) 190 | 191 | @blueprint_assessment_export.route('/assessment//export/entire',methods = ['GET']) 192 | @auth_required() 193 | @user_assigned_assessment 194 | def exportentire(id): 195 | assessment = Assessment.objects(id=id).first() 196 | 197 | # Exports fresh JSON as precursor to CSV, so we get both 198 | exportassessment(id, "csv") 199 | # Exports fresh campaign template as precursor to testcase templates, so we get both 200 | exporttestcases(id) 201 | 202 | exportnavigator(id) 203 | 204 | # Export assessment meta JSON 205 | with open(f'files/{id}/meta.json', 'w') as f: 206 | json.dump(assessment.to_json(raw=True), f) 207 | 208 | # ZIP up the above generated files and testcase evidence and deliver 209 | if not current_user.has_role("Blue"): 210 | shutil.make_archive("files/" + id, 'zip', "files/" + id) 211 | else: 212 | # If they're blue then they can only export the evidence files of visible testcases 213 | shutil.copytree(f"files/{id}", f"files/tmp{id}") 214 | testcases = TestCase.objects(assessmentid=str(assessment.id)).all() 215 | for testcase in testcases: 216 | if not testcase.visible and os.path.isdir(f"files/tmp{id}/{str(testcase.id)}"): 217 | shutil.rmtree(f"files/tmp{id}/{str(testcase.id)}") 218 | shutil.make_archive(f"files/{id}", 'zip', f"files/tmp{id}") 219 | shutil.rmtree(f"files/tmp{id}") 220 | 221 | return send_from_directory('files', f"{id}.zip", as_attachment=True, download_name=f"{assessment.name}.zip") 222 | -------------------------------------------------------------------------------- /blueprints/assessment_import.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import string 4 | import shutil 5 | from model import * 6 | from utils import user_assigned_assessment 7 | from flask import Blueprint, request, jsonify 8 | from werkzeug.utils import secure_filename 9 | from flask_security import auth_required, roles_accepted 10 | 11 | blueprint_assessment_import = Blueprint('blueprint_assessment_import', __name__) 12 | 13 | @blueprint_assessment_import.route('/assessment//import/template', methods = ['POST']) 14 | @auth_required() 15 | @roles_accepted('Admin', 'Red') 16 | @user_assigned_assessment 17 | def testcasetemplates(id): 18 | newcases = [] 19 | for templateid in request.json["ids"]: 20 | template = TestCaseTemplate.objects(id=templateid).first() 21 | newcase = TestCase( 22 | name = template.name, 23 | mitreid = template.mitreid, 24 | tactic = template.tactic, 25 | objective = template.objective, 26 | actions = template.actions, 27 | rednotes = template.rednotes, 28 | assessmentid = id 29 | ).save() 30 | newcases.append(newcase.to_json()) 31 | 32 | return jsonify(newcases), 200 33 | 34 | @blueprint_assessment_import.route('/assessment//import/navigator', methods = ['POST']) 35 | @auth_required() 36 | @roles_accepted('Admin', 'Red') 37 | @user_assigned_assessment 38 | def testcasenavigator(id): 39 | newcases = [] 40 | navigatorTestcases = json.loads(request.files['file'].read()) 41 | for testcase in navigatorTestcases["techniques"]: 42 | tactic = string.capwords(testcase["tactic"].replace("-", " ")) 43 | templates = TestCaseTemplate.objects(mitreid=testcase["techniqueID"], tactic=tactic).all() 44 | if templates: 45 | newcase = TestCase( 46 | name = templates[0].name, 47 | mitreid = templates[0].mitreid, 48 | tactic = templates[0].tactic, 49 | objective = templates[0].objective, 50 | actions = templates[0].actions, 51 | assessmentid = id 52 | ).save() 53 | else: 54 | newcase = TestCase( 55 | name = Technique.objects(mitreid=testcase["techniqueID"]).first().name, 56 | mitreid = testcase["techniqueID"], 57 | tactic = tactic, 58 | assessmentid = id 59 | ).save() 60 | newcases.append(newcase.to_json()) 61 | 62 | return jsonify(newcases), 200 63 | 64 | @blueprint_assessment_import.route('/assessment//import/campaign', methods = ['POST']) 65 | @auth_required() 66 | @roles_accepted('Admin', 'Red') 67 | @user_assigned_assessment 68 | def testcasecampaign(id): 69 | newcases = [] 70 | campaignTestcases = json.loads(request.files['file'].read()) 71 | assessment = Assessment.objects(id=id).first() 72 | for testcase in campaignTestcases: 73 | newcase = TestCase() 74 | newcase.assessmentid = id 75 | for field in ["name", "mitreid", "tactic", "objective", "actions", "tools", "uuid", "tags"]: 76 | if field in testcase: 77 | if field not in ["tools", "tags"]: 78 | newcase[field] = testcase[field] 79 | else: 80 | multis = [] 81 | for multi in testcase[field]: 82 | name, desc = multi.split("|") 83 | if name in [i.name for i in assessment[field]]: 84 | multis.append([str(i.id) for i in assessment[field] if i.name == name][0]) 85 | continue 86 | elif field == "tools": 87 | newMulti = Tool(name=name, description=desc) 88 | elif field == "tags": 89 | newMulti = Tag(name=name, colour=desc) 90 | assessment[field].append(newMulti) 91 | assessment[field].save() 92 | multis.append(str(newMulti.id)) 93 | newcase[field] = multis 94 | 95 | newcase.save() 96 | newcases.append(newcase.to_json()) 97 | 98 | return jsonify(newcases), 200 99 | 100 | @blueprint_assessment_import.route('/assessment/import/entire', methods = ['POST']) 101 | @auth_required() 102 | @roles_accepted('Admin') 103 | def importentire(): 104 | assessment = Assessment(name="Importing...") 105 | assessment.save() 106 | assessmentID = str(assessment.id) 107 | 108 | os.makedirs(f"files/{assessmentID}/tmp") 109 | f = request.files['file'] 110 | f.save(f"files/{assessmentID}/tmp/entire.zip") 111 | shutil.unpack_archive( 112 | f"files/{assessmentID}/tmp/entire.zip", 113 | f"files/{assessmentID}/tmp/", 114 | "zip" 115 | ) 116 | 117 | with open(f"files/{assessmentID}/tmp/meta.json", 'r') as f: 118 | meta = json.load(f) 119 | for key in ["name", "description"]: 120 | assessment[key] = meta[key] 121 | assessment.save() 122 | 123 | with open(f"files/{assessmentID}/tmp/export.json", 'r') as f: 124 | export = json.load(f) 125 | 126 | assessmentMultis = { 127 | "sources": {}, 128 | "targets": {}, 129 | "tools": {}, 130 | "controls": {}, 131 | "tags": {} 132 | } 133 | 134 | for oldTestcase in export: 135 | newTestcase = TestCase() 136 | newTestcase.assessmentid = assessmentID 137 | newTestcase.save() 138 | testcaseID = str(newTestcase.id) 139 | 140 | for field in ["name", "objective", "actions", "rednotes", "bluenotes", 141 | "uuid", "mitreid", "tactic", "state", "prevented", "preventedrating", 142 | "alerted", "alertseverity", "logged", "detectionrating", 143 | "priority", "priorityurgency", "visible", "outcome"]: 144 | newTestcase[field] = oldTestcase[field] 145 | 146 | for field in ["starttime", "endtime", "detecttime", "modifytime"]: 147 | if oldTestcase[field] != "None": 148 | newTestcase[field] = datetime.datetime.strptime(oldTestcase[field].split(".")[0], "%Y-%m-%d %H:%M:%S") 149 | 150 | for field in ["sources", "targets", "tools", "controls", "tags"]: 151 | newTestcase[field] = [] 152 | 153 | for multi in oldTestcase[field]: 154 | if multi in assessmentMultis[field]: 155 | newTestcase[field].append(assessmentMultis[field][multi]) 156 | else: 157 | name, details = multi.split("|") 158 | if field == "sources": 159 | newMulti = Source(name=name, description=details) 160 | elif field == "targets": 161 | newMulti = Target(name=name, description=details) 162 | elif field == "tools": 163 | newMulti = Tool(name=name, description=details) 164 | elif field == "controls": 165 | newMulti = Control(name=name, description=details) 166 | elif field == "tags": 167 | newMulti = Tag(name=name, colour=details) 168 | assessment[field].append(newMulti) 169 | assessment[field].save() 170 | assessmentMultis[field][f"{newMulti.name}|{newMulti.description if field != 'tags' else newMulti.colour}"] = str(assessment[field][-1].id) 171 | newTestcase[field].append(assessmentMultis[field][f"{newMulti.name}|{newMulti.description if field != 'tags' else newMulti.colour}"]) 172 | 173 | for field in ["redfiles", "bluefiles"]: 174 | newFiles = [] 175 | for file in oldTestcase[field]: 176 | origFilePath, caption = file.split("|") 177 | _, _, oldTestcaseID, name = [secure_filename(i) for i in origFilePath.split("/")] 178 | origFilePath = f'files/{assessmentID}/tmp/{oldTestcaseID}/{name}' 179 | # If an exported attachment is corrupt, don't import and continue 180 | if not os.path.exists(origFilePath): 181 | continue 182 | if not os.path.exists(f"files/{assessmentID}/{testcaseID}"): 183 | os.makedirs(f"files/{assessmentID}/{testcaseID}") 184 | newFilePath = f"files/{assessmentID}/{testcaseID}/{name}" 185 | shutil.copy2(origFilePath, newFilePath) 186 | newFiles.append({"name": name, "path": newFilePath, "caption": caption}) 187 | if field == "redfiles": 188 | newTestcase.update(set__redfiles=newFiles) 189 | elif field == "bluefiles": 190 | newTestcase.update(set__bluefiles=newFiles) 191 | newTestcase.save() 192 | 193 | return jsonify(assessment.to_json()), 200 194 | -------------------------------------------------------------------------------- /blueprints/assessment_utils.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from model import * 3 | from time import time 4 | from copy import deepcopy 5 | from utils import user_assigned_assessment 6 | from flask_security import auth_required, roles_accepted, current_user 7 | from blueprints.assessment_export import exportnavigator 8 | from flask import Blueprint, render_template, request, send_from_directory, make_response, jsonify 9 | 10 | blueprint_assessment_utils = Blueprint('blueprint_assessment_utils', __name__) 11 | 12 | @blueprint_assessment_utils.route('/assessment//multi/', methods = ['POST']) 13 | @auth_required() 14 | @roles_accepted('Admin', 'Red', 'Blue') 15 | @user_assigned_assessment 16 | def assessmentmulti(id, field): 17 | if field not in ["sources", "targets", "tools", "controls", "tags"]: 18 | return '', 418 19 | 20 | assessment = Assessment.objects(id=id).first() 21 | 22 | newObjs = [] 23 | for row in request.json["data"]: 24 | obj = { 25 | "sources": Source(), 26 | "targets": Target(), 27 | "tools": Tool(), 28 | "controls": Control(), 29 | "tags": Tag(), 30 | }[field] 31 | 32 | # If pre-existing, then edit pre-existing to preserve ID 33 | if row["id"] in [str(o.id) for o in assessment[field]]: 34 | obj = assessment[field].filter(id=row["id"]).first() 35 | obj.name = row["name"] 36 | if field == "tags": 37 | obj.colour = row["colour"] 38 | else: 39 | obj.description = row["description"] 40 | newObjs.append(obj) 41 | assessment[field] = newObjs 42 | assessment[field].save() 43 | 44 | return jsonify(assessment.multi_to_json(field)), 200 45 | 46 | @blueprint_assessment_utils.route('/assessment//navigator', methods = ['GET']) 47 | @auth_required() 48 | @user_assigned_assessment 49 | def assessmentnavigator(id): 50 | assessment = Assessment.objects(id=id).first() 51 | 52 | # Create and store one-time secret; timestamp and ip for later comparison in 53 | # the unauthed `thisurl`.json endpoint 54 | secret = secrets.token_urlsafe() 55 | assessment.navigatorexport = f"{int(time())}|{request.remote_addr}|{secret}" 56 | assessment.save() 57 | 58 | exportnavigator(id) 59 | 60 | return render_template('assessment_navigator.html', assessment=assessment, secret=secret) 61 | 62 | @blueprint_assessment_utils.route('/assessment//navigator.json', methods = ['GET']) 63 | def assessmentnavigatorjson(id): 64 | assessment = Assessment.objects(id=id).first() 65 | timestamp, ip, secret = assessment.navigatorexport.split("|") 66 | 67 | # This endpoint is unauthed so that we can embed the ATT&CK Navigator and 68 | # allow it to fetch a layer.json on behalf of the user. To mitigate security issues 69 | # the endpoint needs to be hit 70 | # 1. Within 10 seconds of hitting the authed endpoint /assessment//navigator 71 | # 2. With the same IP used to his the above authed endpoint 72 | # 3. With a one-time secret key returned in the above authed endpoint 73 | # 4. From the mitre-attack origin (yes this is spoofable, but why not) 74 | # if (int(time()) - int(timestamp) <= 30 and 75 | #request.remote_addr == ip and 76 | # request.args.get("secret") == secret): # and 77 | #request.origin == "https://mitre-attack.github.io"): 78 | response = make_response(send_from_directory('files', f"{id}/navigator.json")) 79 | response.headers.add('Access-Control-Allow-Origin', '*') 80 | return response 81 | 82 | return "", 401 83 | 84 | @blueprint_assessment_utils.route('/assessment//stats',methods = ['GET']) 85 | @auth_required() 86 | @user_assigned_assessment 87 | def assessmentstats(id): 88 | assessment = Assessment.objects(id=id).first() 89 | if current_user.has_role("Blue"): 90 | testcases = TestCase.objects(assessmentid=str(assessment.id), visible=True).all() 91 | else: 92 | testcases = TestCase.objects(assessmentid=str(assessment.id)).all() 93 | 94 | # Initalise metrics that are captured 95 | stats = { 96 | "All": { 97 | "Prevented": 0, "Alerted": 0, "Logged": 0, "Missed": 0, 98 | "Critical": 0, "High": 0, "Medium": 0, "Low": 0, "Informational": 0, 99 | "scoresPrevent": [], "scoresDetect": [], 100 | "priorityType": [], "priorityUrgency": [], 101 | "controls": [] 102 | } 103 | } 104 | 105 | # What MITRE tactics do we currently have data for? 106 | activeTactics = list(set([t["tactic"] for t in testcases if t["state"] == "Complete"])) 107 | 108 | for testcase in testcases: 109 | if testcase["tactic"] in activeTactics: 110 | # Initalise tactic if not in the dataframe yet 111 | if testcase["tactic"] not in stats: 112 | stats[testcase["tactic"]] = deepcopy(stats["All"]) 113 | 114 | # Populate prevented/alerted/logged/missed stats 115 | if testcase["outcome"]: 116 | stats[testcase["tactic"]][testcase["outcome"]] += 1 117 | 118 | # Populate alert severities 119 | if testcase["alertseverity"]: 120 | stats[testcase["tactic"]][testcase["alertseverity"]] += 1 121 | 122 | # Store scores to later average with 123 | if testcase["preventedrating"] and testcase["preventedrating"] != "N/A": 124 | stats[testcase["tactic"]]["scoresPrevent"].append(float(testcase["preventedrating"])) 125 | if testcase["detectionrating"]: 126 | stats[testcase["tactic"]]["scoresDetect"].append(float(testcase["detectionrating"])) 127 | 128 | # Collate priorities, ratings and controls 129 | if testcase["priority"] and testcase["priority"] != "N/A": 130 | stats[testcase["tactic"]]["priorityType"].append(testcase["priority"]) 131 | if testcase["priorityurgency"] and testcase["priorityurgency"] != "N/A": 132 | stats[testcase["tactic"]]["priorityUrgency"].append(testcase["priorityurgency"]) 133 | if testcase["controls"]: 134 | controls = [] 135 | for control in testcase["controls"]: 136 | controls.append([c.name for c in assessment.controls if str(c.id) == control][0]) 137 | stats[testcase["tactic"]]["controls"].extend(controls) 138 | 139 | # We've populated per-tactic data, this function adds it all together for an "All" tactic 140 | for tactic in stats: 141 | if tactic == "All": 142 | continue 143 | for key in ["Prevented", "Alerted", "Logged", "Missed", "Critical", "High", "Medium", "Low", "Informational"]: 144 | stats["All"][key] += stats[tactic][key] 145 | for key in ["scoresPrevent", "scoresDetect", "priorityType", "priorityUrgency", "controls"]: 146 | stats["All"][key].extend(stats[tactic][key]) 147 | 148 | return render_template( 149 | 'assessment_stats.html', 150 | assessment=assessment, 151 | stats=stats, 152 | hexagons=assessmenthexagons(id) 153 | ) 154 | 155 | @blueprint_assessment_utils.route('/assessment//assessment_hexagons.svg',methods = ['GET']) 156 | @auth_required() 157 | @user_assigned_assessment 158 | def assessmenthexagons(id): 159 | # Use SVG to create the hexagon graph because making a hex grid in HTML is a no 160 | tactics = ["Execution", "Command and Control", "Discovery", "Persistence", "Privilege Escalation", "Credential Access", "Lateral Movement", "Exfiltration", "Impact"] 161 | 162 | shownHexs = [] 163 | hiddenHexs = [] 164 | for i in range(len(tactics)): 165 | if not TestCase.objects(assessmentid=id, tactic=tactics[i], state="Complete").count(): 166 | hiddenHexs.append({ 167 | "display": "none", 168 | "stroke": "#ffffff", 169 | "fill": "#ffffff", 170 | "arrow": "rgba(0, 0, 0, 0)", 171 | "text": "" 172 | }) 173 | continue 174 | 175 | score = (TestCase.objects(assessmentid=id, tactic=tactics[i], outcome="Prevented").count() + 176 | TestCase.objects(assessmentid=id, tactic=tactics[i], outcome="Alerted").count() - 177 | TestCase.objects(assessmentid=id, tactic=tactics[i], outcome="Missed").count()) 178 | if score > 1: 179 | color = "#B8DF43" 180 | elif score < -1: 181 | color = "#FB6B64" 182 | else: 183 | color = "#FFC000" 184 | 185 | shownHexs.append({ 186 | "display": "block", 187 | "stroke": color, 188 | "fill": "#eeeeee", 189 | "arrow": "rgb(0, 0, 0)", 190 | "text": tactics[i] 191 | }) 192 | 193 | # Dynamic SVG height and width depending on # hexs as CSS has no visibility 194 | # over which hexs are shown so we can center it for prettyness 195 | if len(shownHexs) == 0: 196 | height = 0 197 | if len(shownHexs) <= 4: 198 | height = 115 199 | elif len(shownHexs) <= 7: 200 | height = 230 201 | else: 202 | height = 347 203 | 204 | if len(shownHexs) == 0: 205 | width = 0 206 | if len(shownHexs) == 1: 207 | width = 100 208 | elif len(shownHexs) == 2: 209 | width = 240 210 | elif len(shownHexs) == 3: 211 | width = 380 212 | else: 213 | width = 517 214 | 215 | return render_template('assessment_hexagons.svg', hexs = [*shownHexs, *hiddenHexs], height = height, width = width) -------------------------------------------------------------------------------- /blueprints/testcase.py: -------------------------------------------------------------------------------- 1 | import os 2 | from model import * 3 | from utils import * 4 | from datetime import datetime 5 | from werkzeug.utils import secure_filename 6 | from flask import Blueprint, render_template, request, jsonify 7 | from flask_security import auth_required, roles_accepted, current_user 8 | 9 | blueprint_testcase = Blueprint('blueprint_testcase', __name__) 10 | 11 | @blueprint_testcase.route('/testcase//single', methods = ['POST']) 12 | @auth_required() 13 | @roles_accepted('Admin', 'Red') 14 | @user_assigned_assessment 15 | def newtestcase(id): 16 | newcase = TestCase() 17 | newcase.assessmentid = id 18 | newcase = applyFormData(newcase, request.form, ["name", "mitreid", "tactic"]) 19 | newcase.save() 20 | return jsonify(newcase.to_json()), 200 21 | 22 | @blueprint_testcase.route('/testcase/',methods = ['GET']) 23 | @auth_required() 24 | @user_assigned_assessment 25 | def runtestcasepost(id): 26 | testcase = TestCase.objects(id=id).first() 27 | assessment = Assessment.objects(id=testcase.assessmentid).first() 28 | 29 | if not testcase.visible and current_user.has_role("Blue"): 30 | return ("", 403) 31 | 32 | return render_template('testcase.html', 33 | testcase = testcase, 34 | testcases = TestCase.objects(assessmentid=str(assessment.id)).all(), 35 | tactics = Tactic.objects().all(), 36 | assessment = assessment, 37 | kb = KnowlegeBase.objects(mitreid=testcase.mitreid).first(), 38 | templates = TestCaseTemplate.objects(mitreid=testcase["mitreid"]), 39 | mitres = [[m["mitreid"], m["name"]] for m in Technique.objects()], 40 | sigmas = Sigma.objects(mitreid=testcase["mitreid"]), 41 | multi = { 42 | "sources": assessment.sources, 43 | "targets": assessment.targets, 44 | "tools": assessment.tools, 45 | "controls": assessment.controls, 46 | "tags": assessment.tags 47 | } 48 | ) 49 | 50 | @blueprint_testcase.route('/testcase/',methods = ['POST']) 51 | @auth_required() 52 | @roles_accepted('Admin', 'Red', 'Blue') 53 | @user_assigned_assessment 54 | def testcasesave(id): 55 | testcase = TestCase.objects(id=id).first() 56 | isBlue = current_user.has_role("Blue") 57 | 58 | if not testcase.visible and isBlue: 59 | return ("", 403) 60 | 61 | directFields = ["name", "objective", "actions", "rednotes", "bluenotes", "uuid", "mitreid", "tactic", "state", "prevented", "preventedrating", "alertseverity", "logged", "detectionrating", "priority", "priorityurgency"] if not isBlue else ["bluenotes", "prevented", "alerted", "alertseverity"] 62 | listFields = ["sources", "targets", "tools", "controls", "tags"] 63 | boolFields = ["alerted", "logged", "visible"] if not isBlue else ["alerted", "logged"] 64 | timeFields = ["starttime", "endtime"] 65 | fileFields = ["redfiles", "bluefiles"] if not isBlue else ["bluefiles"] 66 | 67 | testcase = applyFormData(testcase, request.form, directFields) 68 | testcase = applyFormListData(testcase, request.form, listFields) 69 | testcase = applyFormBoolData(testcase, request.form, boolFields) 70 | testcase = applyFormTimeData(testcase, request.form, timeFields) 71 | 72 | if not os.path.exists(f"files/{testcase.assessmentid}/{str(testcase.id)}"): 73 | os.makedirs(f"files/{testcase.assessmentid}/{str(testcase.id)}") 74 | 75 | for field in fileFields: 76 | files = [] 77 | for file in testcase[field]: 78 | if file.name.lower().split(".")[-1] in ["png", "jpg", "jpeg"]: 79 | caption = request.form[field.replace("files", "").upper() + file.name] 80 | else: 81 | caption = "" 82 | files.append({ 83 | "name": secure_filename(file.name), 84 | "path": file.path, 85 | "caption": caption 86 | }) 87 | for file in request.files.getlist(field): 88 | if request.files.getlist(field)[0].filename: 89 | filename = secure_filename(file.filename) 90 | path = f"files/{testcase.assessmentid}/{str(testcase.id)}/{filename}" 91 | file.save(path) 92 | files.append({"name": filename, "path": path, "caption": ""}) 93 | if field == "redfiles": 94 | testcase.update(set__redfiles=files) 95 | else: 96 | testcase.update(set__bluefiles=files) 97 | 98 | testcase.modifytime = datetime.utcnow() 99 | if "logged" in request.form and request.form["logged"] == "Yes" and not testcase.detecttime: 100 | testcase.detecttime = datetime.utcnow() 101 | 102 | if testcase.prevented in ["Yes", "Partial"]: 103 | testcase.outcome = "Prevented" 104 | elif testcase.alerted: 105 | testcase.outcome = "Alerted" 106 | elif testcase.logged: 107 | testcase.outcome = "Logged" 108 | elif not testcase.logged and testcase.prevented: 109 | testcase.outcome = "Missed" 110 | else: 111 | testcase.outcome = "" 112 | 113 | # This is some sanity check code where we check if some of the UI elements are out of sync with the backend. This is trggered by the horrible tabs bug 114 | # Does not fix user not saving test case before navigating away 115 | # Todo: Turns this BS code into a single mongoengine query against the subdocument list 116 | assessment = Assessment.objects(id=testcase.assessmentid).first() 117 | for field in listFields: 118 | ids = [] 119 | valid_ids = [] 120 | for t in assessment[field]: 121 | ids.append(str(t.id)) 122 | for field_id in testcase[field]: 123 | if field_id in ids: 124 | valid_ids.append(field_id) 125 | testcase[field] = valid_ids 126 | testcase.save() 127 | 128 | return "", 200 129 | -------------------------------------------------------------------------------- /blueprints/testcase_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from model import * 4 | from utils import user_assigned_assessment 5 | from werkzeug.utils import secure_filename 6 | from flask import Blueprint, request, send_from_directory, jsonify 7 | from flask_security import auth_required, roles_accepted, current_user 8 | 9 | blueprint_testcase_utils = Blueprint('blueprint_testcase_utils', __name__) 10 | 11 | @blueprint_testcase_utils.route('/testcase//toggle-visibility', methods = ['GET']) 12 | @auth_required() 13 | @roles_accepted('Admin', 'Red') 14 | @user_assigned_assessment 15 | def testcasevisibility(id): 16 | newcase = TestCase.objects(id=id).first() 17 | newcase.visible = not newcase.visible 18 | newcase.save() 19 | 20 | return jsonify(newcase.to_json()), 200 21 | 22 | @blueprint_testcase_utils.route('/testcase//clone', methods = ['GET']) 23 | @auth_required() 24 | @roles_accepted('Admin', 'Red') 25 | @user_assigned_assessment 26 | def testcaseclone(id): 27 | orig = TestCase.objects(id=id).first() 28 | newcase = TestCase() 29 | copy = ["name", "assessmentid", "objective", "actions", "rednotes", "mitreid", "uuid", "tactic", "tools", "tags"] 30 | for field in copy: 31 | newcase[field] = orig[field] 32 | newcase.name = orig["name"] + " (Copy)" 33 | newcase.save() 34 | 35 | return jsonify(newcase.to_json()), 200 36 | 37 | @blueprint_testcase_utils.route('/testcase//delete', methods = ['GET']) 38 | @auth_required() 39 | @roles_accepted('Admin', 'Red') 40 | @user_assigned_assessment 41 | def testcasedelete(id): 42 | testcase = TestCase.objects(id=id).first() 43 | assessment = Assessment.objects(id=testcase.assessmentid).first() 44 | if os.path.exists(f"files/{str(assessment.id)}/{str(testcase.id)}"): 45 | shutil.rmtree(f"files/{str(assessment.id)}/{str(testcase.id)}") 46 | testcase.delete() 47 | 48 | return "", 200 49 | 50 | @blueprint_testcase_utils.route('/testcase//evidence//', methods = ['DELETE']) 51 | @auth_required() 52 | @roles_accepted('Admin', 'Red', 'Blue') 53 | @user_assigned_assessment 54 | def deletefile(id, colour, file): 55 | if colour not in ["red", "blue"]: 56 | return 401 57 | if colour == "red" and current_user.has_role("Blue"): 58 | return 403 59 | 60 | testcase = TestCase.objects(id=id).first() 61 | # Sanity check to prevent death if the image has already been removed 62 | path = f"files/{testcase.assessmentid}/{testcase.id}/{secure_filename(file)}" 63 | if os.path.isfile(path): 64 | os.remove(path) 65 | 66 | files = [] 67 | for f in testcase["redfiles" if colour == "red" else "bluefiles"]: 68 | if f.name != file: 69 | files.append(f) 70 | 71 | if colour == "red": 72 | testcase.update(set__redfiles=files) 73 | else: 74 | testcase.update(set__bluefiles=files) 75 | 76 | return '', 204 77 | 78 | @blueprint_testcase_utils.route('/testcase//evidence/', methods = ['GET']) 79 | @auth_required() 80 | @user_assigned_assessment 81 | def fetchFile(id, file): 82 | testcase = TestCase.objects(id=id).first() 83 | 84 | return send_from_directory( 85 | 'files', 86 | f"{testcase.assessmentid}/{str(testcase.id)}/{secure_filename(file)}", 87 | as_attachment = True if "download" in request.args else False 88 | ) 89 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | mongodb: 5 | image: mongo:7.0-rc 6 | container_name: mongodb 7 | ports: 8 | - 27017 9 | volumes: 10 | - mongodb_data:/data/db 11 | purpleops: 12 | build: . 13 | container_name: purpleops 14 | command: gunicorn --bind 0.0.0.0:5000 purpleops:app 15 | ports: 16 | - "5000:5000" 17 | env_file: 18 | - ./.env 19 | depends_on: 20 | - mongodb 21 | volumes: 22 | - purpleops_data:/usr/src/app/ 23 | - ./custom:/usr/src/app/custom 24 | volumes: 25 | mongodb_data: 26 | purpleops_data: 27 | -------------------------------------------------------------------------------- /custom/knowledgebase/T1003.yaml: -------------------------------------------------------------------------------- 1 | mitreid: T1003 2 | overview: Example 3 | advice: Example 4 | provider: Example -------------------------------------------------------------------------------- /custom/reports/sample.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberCX-STA/PurpleOps/2d91a5e3b4a9256f4a049c8e423462ff30a475c6/custom/reports/sample.docx -------------------------------------------------------------------------------- /custom/testcases.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "mitreid": "T1569.002", 4 | "tactic": "Execution", 5 | "name": "Service Execution via sc.exe", 6 | "objective": "Execute malware via a Windows service. See: https://github.com/redcanaryco/atomic-red-team/blob/master/atomics/T1543.003/src/AtomicService.cs", 7 | "actions": "sc.exe create DiskClean binPath= sample.exe\r\nsc.exe start DiskClean", 8 | "tools": [ 9 | "boing" 10 | ], 11 | "tags": [ 12 | "Conti" 13 | ], 14 | "provider": "Indy" 15 | }, 16 | { 17 | "mitreid": "T1003.001", 18 | "tactic": "Credential Access", 19 | "name": "Dump LSASS Memory Using Task Manager", 20 | "objective": "Use Task Manager to extract the memory of the LSASS process to recover credentials.", 21 | "actions": "Ctrl + Shift + Esc > Details > Right Click lsass.exe > Create dump file", 22 | "tools": [ 23 | "tooly" 24 | ], 25 | "tags": [ 26 | "Conti", 27 | "Local Admin" 28 | ], 29 | "provider": "Indy" 30 | } 31 | ] -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Waiting for Mongo..." 4 | 5 | while ! nc -z "$MONGO_HOST" 27017; do 6 | sleep 0.1 7 | done 8 | 9 | python seeder.py 10 | 11 | exec "$@" 12 | -------------------------------------------------------------------------------- /flask.cfg: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | MONGODB_SETTINGS = { 7 | 'db': getenv('MONGO_DB'), 8 | 'host': getenv('MONGO_HOST'), 9 | 'port': int(getenv('MONGO_PORT')) 10 | } 11 | 12 | DEBUG = getenv("FLASK_DEBUG") 13 | SECURITY_TRACKABLE = True 14 | SESSION_COOKIE_SAMESITE = "Strict" 15 | 16 | SECRET_KEY = getenv("FLASK_SECRET_KEY") 17 | SECURITY_PASSWORD_SALT = getenv("FLASK_SECURITY_PASSWORD_SALT") 18 | 19 | SECURITY_TWO_FACTOR = getenv("FLASK_MFA") == "True" 20 | SECURITY_TWO_FACTOR_REQUIRED = getenv("FLASK_MFA") == "True" 21 | SECURITY_LOGIN_USER_TEMPLATE = "login.html" 22 | SECURITY_TWO_FACTOR_ENABLED_METHODS = ['authenticator'] 23 | SECURITY_TWO_FACTOR_SETUP_URL = "/mfa/register" 24 | SECURITY_TWO_FACTOR_TOKEN_VALIDATION_URL = "/mfa/verify" 25 | SECURITY_TWO_FACTOR_SETUP_TEMPLATE = "mfa_register.html" 26 | SECURITY_TWO_FACTOR_VERIFY_CODE_TEMPLATE = "mfa_verify.html" 27 | SECURITY_TWO_FACTOR_ALWAYS_VALIDATE = False 28 | SECURITY_TWO_FACTOR_LOGIN_VALIDITY = "1 weeks" 29 | SECURITY_TOTP_ISSUER = f"PurpleOps - {getenv('NAME')}" 30 | SECURITY_TOTP_SECRETS = {"1": getenv("FLASK_SECURITY_TOTP_SECRETS")} 31 | SECURITY_CHANGEABLE = True 32 | SECURITY_CHANGE_URL = "/password/change" 33 | SECURITY_CHANGE_PASSWORD_TEMPLATE = "password_change.html" 34 | SECURITY_POST_CHANGE_VIEW = "/password/changed" 35 | SECURITY_SEND_REGISTER_EMAIL = False 36 | SECURITY_SEND_PASSWORD_CHANGE_EMAIL = False 37 | SECURITY_SEND_PASSWORD_RESET_EMAIL = False 38 | SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL = False 39 | SECURITY_PASSWORD_LENGTH_MIN = 12 40 | SECURITY_TWO_FACTOR_RESCUE_EMAIL = False 41 | SECURITY_EMAIL_VALIDATOR_ARGS = {"check_deliverability": False} 42 | 43 | 44 | # TODO review post pentest 45 | # SESSION_COOKIE_SECURE = True 46 | # REMEMBER_COOKIE_SECURE = True 47 | # WTF_CSRF_ENABLED = True 48 | # SECURITY_CSRF_COOKIE = {"samesite": "Strict", "httponly": False, "secure": True} -------------------------------------------------------------------------------- /model.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from bson.objectid import ObjectId 3 | from flask import escape 4 | from flask_mongoengine import MongoEngine 5 | from flask_security import UserMixin, RoleMixin, MongoEngineUserDatastore 6 | 7 | db = MongoEngine() 8 | 9 | class Tactic(db.Document): 10 | mitreid = db.StringField() 11 | name = db.StringField() 12 | 13 | 14 | class Technique(db.Document): 15 | mitreid = db.StringField() 16 | name = db.StringField() 17 | description = db.StringField() 18 | detection = db.StringField() 19 | tactics = db.ListField(db.StringField()) 20 | 21 | 22 | # TODO how to generalise? 4x same object 23 | class Source(db.EmbeddedDocument): 24 | id = db.ObjectIdField( required=True, default=ObjectId ) 25 | name = db.StringField() 26 | description = db.StringField(default="") 27 | 28 | def to_json(self, raw=False): 29 | return { 30 | "id": str(self.id), 31 | "name": esc(self.name, raw), 32 | "description": esc(self.description, raw) 33 | } 34 | 35 | 36 | class Target(db.EmbeddedDocument): 37 | id = db.ObjectIdField( required=True, default=ObjectId ) 38 | name = db.StringField() 39 | description = db.StringField(default="") 40 | 41 | def to_json(self, raw=False): 42 | return { 43 | "id": str(self.id), 44 | "name": esc(self.name, raw), 45 | "description": esc(self.description, raw) 46 | } 47 | 48 | 49 | class Tool(db.EmbeddedDocument): 50 | id = db.ObjectIdField( required=True, default=ObjectId ) 51 | name = db.StringField() 52 | description = db.StringField(default="") 53 | 54 | def to_json(self, raw=False): 55 | return { 56 | "id": str(self.id), 57 | "name": esc(self.name, raw), 58 | "description": esc(self.description, raw) 59 | } 60 | 61 | 62 | class Control(db.EmbeddedDocument): 63 | id = db.ObjectIdField( required=True, default=ObjectId ) 64 | name = db.StringField() 65 | description = db.StringField(default="") 66 | 67 | def to_json(self, raw=False): 68 | return { 69 | "id": str(self.id), 70 | "name": esc(self.name, raw), 71 | "description": esc(self.description, raw) 72 | } 73 | 74 | 75 | class Tag(db.EmbeddedDocument): 76 | id = db.ObjectIdField( required=True, default=ObjectId ) 77 | name = db.StringField() 78 | colour = db.StringField(default="#ff0000") 79 | 80 | def to_json(self, raw=False): 81 | return { 82 | "id": str(self.id), 83 | "name": esc(self.name, raw), 84 | "colour": esc(self.colour, raw) # AU spelling is non-negotiable xx 85 | } 86 | 87 | 88 | class File(db.EmbeddedDocument): 89 | name = db.StringField() 90 | path = db.StringField() 91 | caption = db.StringField(default="") 92 | 93 | 94 | class KnowlegeBase(db.Document): 95 | mitreid = db.StringField() 96 | overview = db.StringField() 97 | advice = db.StringField() 98 | provider = db.StringField() 99 | 100 | 101 | class Sigma(db.Document): 102 | mitreid = db.StringField() 103 | name = db.StringField() 104 | description = db.StringField() 105 | url = db.StringField() 106 | 107 | 108 | class TestCaseTemplate(db.Document): 109 | name = db.StringField() 110 | mitreid = db.StringField(default="") 111 | tactic = db.StringField(default="") 112 | objective = db.StringField(default="") 113 | actions = db.StringField(default="") 114 | rednotes = db.StringField(default="") 115 | uuid = db.StringField(default="") 116 | provider = db.StringField(default="") 117 | 118 | 119 | class TestCase(db.Document): 120 | assessmentid = db.StringField() 121 | name = db.StringField() 122 | objective = db.StringField(default="") 123 | actions = db.StringField(default="") 124 | rednotes = db.StringField(default="") 125 | bluenotes = db.StringField(default="") 126 | uuid = db.StringField(default="") 127 | mitreid = db.StringField() 128 | tactic = db.StringField() 129 | sources = db.ListField(db.StringField()) 130 | targets = db.ListField(db.StringField()) 131 | tools = db.ListField(db.StringField()) 132 | controls = db.ListField(db.StringField()) 133 | tags = db.ListField(db.StringField()) 134 | state = db.StringField(default="Pending") 135 | prevented = db.StringField() 136 | preventedrating = db.StringField() 137 | alerted = db.BooleanField() 138 | alertseverity = db.StringField() 139 | logged = db.BooleanField() 140 | detectionrating = db.StringField() 141 | priority = db.StringField() 142 | priorityurgency = db.StringField() 143 | starttime = db.DateTimeField() 144 | endtime = db.DateTimeField() 145 | detecttime = db.DateTimeField() 146 | redfiles = db.EmbeddedDocumentListField(File) 147 | bluefiles = db.EmbeddedDocumentListField(File) 148 | visible = db.BooleanField(default=False) 149 | modifytime = db.DateTimeField(default=datetime.datetime.utcnow) 150 | outcome = db.StringField(default="") 151 | 152 | def to_json(self, raw=False): 153 | jsonDict = {} 154 | for field in ["assessmentid", "name", "objective", "actions", "rednotes", "bluenotes", 155 | "uuid", "mitreid", "tactic", "state", "prevented", "preventedrating", 156 | "alerted", "alertseverity", "logged", "detectionrating", 157 | "priority", "priorityurgency", "visible", "outcome"]: 158 | jsonDict[field] = esc(self[field], raw) 159 | for field in ["id", "detecttime", "modifytime", "starttime", "endtime"]: 160 | jsonDict[field] = str(self[field]).split(".")[0] 161 | for field in ["tags", "sources", "targets", "tools", "controls"]: 162 | jsonDict[field] = self.to_json_multi(field) 163 | for field in ["redfiles", "bluefiles"]: 164 | files = [] 165 | for file in self[field]: 166 | files.append(f"{file.path}|{file.caption}") 167 | jsonDict[field] = files 168 | return jsonDict 169 | 170 | def to_json_multi(self, field): 171 | assessment = Assessment.objects(id=self.assessmentid).first() 172 | strs = [] 173 | for i in self[field]: 174 | # Pipe delimit name and desc/colour for export/display 175 | if field != "tags": 176 | strs.append([f"{j.name}|{j.description}" for j in assessment[field] if str(j.id) == i][0]) 177 | else: 178 | strs.append([f"{j.name}|{j.colour}" for j in assessment[field] if str(j.id) == i][0]) 179 | return strs 180 | 181 | class Assessment(db.Document): 182 | name = db.StringField() 183 | description = db.StringField(default="") 184 | created = db.DateTimeField(default=datetime.datetime.utcnow) 185 | targets = db.EmbeddedDocumentListField(Target) 186 | sources = db.EmbeddedDocumentListField(Source) 187 | tools = db.EmbeddedDocumentListField(Tool) 188 | controls = db.EmbeddedDocumentListField(Control) 189 | tags = db.EmbeddedDocumentListField(Tag) 190 | navigatorexport = db.StringField(default="") 191 | 192 | def get_progress(self): 193 | # Returns string with % of "missed|logged|alerted|prevented|pending" 194 | testcases = TestCase.objects(assessmentid=str(self.id)).count() 195 | if testcases == 0: 196 | return "0|0|0|0|0" 197 | outcomes = [] 198 | for outcome in ["Prevented", "Alerted", "Logged", "Missed"]: 199 | outcomes.append(str(round( 200 | TestCase.objects(assessmentid=str(self.id), outcome=outcome).count() / 201 | testcases * 100 202 | , 2))) 203 | return "|".join(outcomes) 204 | 205 | def to_json(self, raw=False): 206 | return { 207 | "id": str(self.id), 208 | "name": esc(self.name, raw), 209 | "description": esc(self.description, raw), 210 | "progress": self.get_progress(), 211 | "created": str(self.created).split(".")[0] 212 | } 213 | 214 | def multi_to_json(self, field, raw=False): 215 | return [item.to_json(raw=raw) for item in self[field]] 216 | 217 | 218 | class Role(db.Document, RoleMixin): 219 | name = db.StringField(max_length=80, unique=True) 220 | description = db.StringField(max_length=255, default="") 221 | 222 | 223 | class User(db.Document, UserMixin): 224 | email = db.StringField(max_length=255) 225 | username = db.StringField(max_length=255, unique=True, nullable=True) 226 | password = db.StringField(max_length=255) 227 | roles = db.ListField(db.ReferenceField(Role), default=[]) 228 | assessments = db.ListField(db.ReferenceField(Assessment), default=[]) 229 | initpwd = db.BooleanField(default=True) 230 | active = db.BooleanField(default=True) 231 | 232 | last_login_at = db.DateTimeField() 233 | current_login_at = db.DateTimeField() 234 | last_login_ip = db.StringField() 235 | current_login_ip = db.StringField() 236 | login_count = db.IntField() 237 | 238 | fs_uniquifier = db.StringField(max_length=255, unique=True) 239 | tf_primary_method = db.StringField(max_length=64, nullable=True) 240 | tf_totp_secret = db.StringField(max_length=255, nullable=True) 241 | 242 | def assessment_list(self): 243 | if "Admin" in [r.name for r in self.roles]: 244 | return [a.id for a in Assessment.objects()] 245 | else: 246 | return [a.id for a in self.assessments] 247 | 248 | def to_json(self, raw=False): 249 | return { 250 | "id": str(self.id), 251 | "username": esc(self.username, raw), 252 | "email": esc(self.email, raw), 253 | "roles": [r.name for r in self.roles], 254 | "assessments": [a.name for a in self.assessments], 255 | "current_login_at": self.current_login_at, 256 | "current_login_ip": self.current_login_ip 257 | } 258 | 259 | 260 | user_datastore = MongoEngineUserDatastore(db, User, Role) 261 | 262 | def esc(s, raw): 263 | if raw: 264 | return s 265 | else: 266 | return escape(s) 267 | -------------------------------------------------------------------------------- /pops-backup.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import os 3 | import json 4 | import shutil 5 | import logging 6 | import argparse 7 | from model import * 8 | from dotenv import load_dotenv 9 | from pathlib import Path 10 | from flask import Flask 11 | 12 | logging.basicConfig(format='%(asctime)s %(message)s', datefmt='%m/%d/%Y %I:%M:%S %p', level=logging.DEBUG) 13 | load_dotenv() 14 | 15 | app = Flask(__name__) 16 | app.config.from_pyfile("flask.cfg") 17 | 18 | def exportassessment(assessment, filetype): 19 | testcases = TestCase.objects(assessmentid=str(assessment.id)).all() 20 | 21 | jsonDict = [] 22 | for testcase in testcases: 23 | jsonDict.append(testcase.to_json(raw=True)) 24 | 25 | # Write JSON and if JSON requested, deliver file and return 26 | with open(f"{args.backupdir}/{str(assessment.id)}/export.json", 'w') as f: 27 | json.dump(jsonDict, f, indent=4) 28 | 29 | # Otherwise flatten JSON arrays into comma delimited strings 30 | for t, testcase in enumerate(jsonDict): 31 | for field in ["sources", "targets", "tools", "controls", "tags", "redfiles", "bluefiles"]: 32 | jsonDict[t][field] = ",".join(testcase[field]) 33 | 34 | # Convert the JSON dict to CSV and deliver 35 | with open(f"{args.backupdir}/{str(assessment.id)}/export.csv", 'w', encoding='UTF8', newline='') as f: 36 | if not testcases: 37 | f.write("") 38 | else: 39 | writer = csv.DictWriter(f, fieldnames=jsonDict[0].keys()) 40 | writer.writeheader() 41 | writer.writerows(jsonDict) 42 | 43 | def exportcampaign(id): 44 | assessment = Assessment.objects(id=id).first() 45 | testcases = TestCase.objects(assessmentid=str(assessment.id)).all() 46 | 47 | jsonDict = [] 48 | for testcase in testcases: 49 | # Generate a full JSON dump but then filter to only the applicable fields 50 | fullJson = testcase.to_json(raw=True) 51 | campaignJson = {} 52 | for field in ["mitreid", "tactic", "name", "objective", "actions", "tools", "uuid", "tags"]: 53 | campaignJson[field] = fullJson[field] 54 | jsonDict.append(campaignJson) 55 | 56 | with open(f"{args.backupdir}/{str(assessment.id)}/campaign.json", 'w') as f: 57 | json.dump(jsonDict, f, indent=4) 58 | 59 | 60 | def exporttestcases(id): 61 | # Hijack the campaign exporter and inject a "provider" field 62 | exportcampaign(id) 63 | with open(f"{args.backupdir}/{id}/campaign.json", 'r') as f: 64 | jsonDict = json.load(f) 65 | 66 | for t, _ in enumerate(jsonDict): 67 | jsonDict[t]["provider"] = "???" 68 | 69 | with open(f"{args.backupdir}/{id}/testcases.json", 'w') as f: 70 | json.dump(jsonDict, f, indent=4) 71 | 72 | def exportnavigator(id): 73 | # Sanity check to ensure assessment exists and to die if not 74 | _ = Assessment.objects(id=id).first() 75 | navigator = { 76 | "name": Assessment.objects(id=id).first().name, 77 | "domain": "enterprise-attack", 78 | "sorting": 3, 79 | "layout": { 80 | "layout": "flat", 81 | "aggregateFunction": "average", 82 | "showID": True, 83 | "showName": True, 84 | "showAggregateScores": True, 85 | "countUnscored": False 86 | }, 87 | "hideDisabled": False, 88 | "techniques": [], 89 | "gradient": { 90 | "colors": [ 91 | "#ff6666ff", 92 | "#ffe766ff", 93 | "#8ec843ff" 94 | ], 95 | "minValue": 0, 96 | "maxValue": 100 97 | }, 98 | "showTacticRowBackground": True, 99 | "tacticRowBackground": "#593196", 100 | "selectTechniquesAcrossTactics": True, 101 | "selectSubtechniquesWithParent": False 102 | } 103 | 104 | for technique in Technique.objects().all(): 105 | testcases = TestCase.objects(assessmentid=id, mitreid=technique.mitreid).all() 106 | ttp = { 107 | "techniqueID": technique.mitreid 108 | } 109 | 110 | if testcases: 111 | count = 0 112 | outcomes = {"Prevented": 0, "Alerted": 0, "Logged": 0, "Missed": 0} 113 | for testcase in testcases: 114 | if testcase.outcome in outcomes.keys(): 115 | count += 1 116 | outcomes[testcase.outcome] += 1 117 | 118 | if count: 119 | score = int((outcomes["Prevented"] * 3 + outcomes["Alerted"] * 2 + 120 | outcomes["Logged"]) / (count * 3) * 100) 121 | ttp["score"] = score 122 | 123 | for tactic in technique.tactics: 124 | tactic = tactic.lower().strip().replace(" ", "-") 125 | tacticTTP = dict(ttp) 126 | tacticTTP["tactic"] = tactic 127 | navigator["techniques"].append(tacticTTP) 128 | 129 | with open(f"{args.backupdir}/{id}/navigator.json", 'w') as f: 130 | json.dump(navigator, f, indent=4) 131 | 132 | 133 | def exportentirebackupid(assessment): 134 | # Clear previous backup artifacts in the backup dir 135 | shutil.rmtree(f"{args.backupdir}/{str(assessment.id)}", ignore_errors=True) 136 | Path(f"{args.backupdir}/{str(assessment.id)}").mkdir(parents=True, exist_ok=True) 137 | 138 | exportassessment(assessment, "csv") 139 | exporttestcases(assessment.id) 140 | exportnavigator(assessment.id) 141 | 142 | with open(f'{args.backupdir}/{assessment.id}/meta.json', 'w') as f: 143 | json.dump(assessment.to_json(raw=True), f) 144 | 145 | #Copy remaining uploaded artifacts across to the backup dir 146 | shutil.copytree(f"{args.files}/{str(assessment.id)}", f"{args.backupdir}/{str(assessment.id)}", dirs_exist_ok=True) 147 | shutil.make_archive(f"{args.backupdir}/{str(assessment.id)}", 'zip', f"{args.backupdir}/{str(assessment.id)}") 148 | 149 | 150 | if __name__ == "__main__": 151 | try: 152 | parser = argparse.ArgumentParser() 153 | parser.add_argument("-d", "--dir", dest="backupdir", help="The backup directory",required=True) 154 | parser.add_argument("-f", "--files", dest="files", help="The PurpleOps files directory", default="files") 155 | parser.add_argument("-H", "--host", help="Mongodb host. Default: host defined in flask.cfg") 156 | parser.add_argument("-p", "--port", type=int,help="Mongodb port. Default: port defined in flask.cfg") 157 | args = parser.parse_args() 158 | 159 | logging.info("Backup started") 160 | if not args.port is None: 161 | app.config["MONGODB_SETTINGS"]["port"] = args.port #32768 162 | if not args.host is None: 163 | app.config["MONGODB_SETTINGS"]["host"] = args.host 164 | 165 | db.init_app(app) 166 | 167 | if not os.path.isdir(args.files): 168 | raise Exception("Invalid PurpleOps files directory, does not exist") 169 | 170 | if os.path.isdir(args.backupdir): 171 | if os.path.abspath(args.backupdir) == os.path.abspath(args.files): 172 | raise Exception("Invalid backup director, can't backup to the PurpleOps files directory") 173 | 174 | for a in Assessment.objects().all(): 175 | logging.info(f"Exporting: {a.name}") 176 | exportentirebackupid(a) 177 | 178 | logging.info("Backup completed") 179 | except: 180 | logging.exception("Error during backup, please fix and try again") 181 | -------------------------------------------------------------------------------- /purpleops.py: -------------------------------------------------------------------------------- 1 | import os 2 | from model import * 3 | from dotenv import load_dotenv 4 | from flask import Flask, render_template, redirect 5 | from flask_security import Security, auth_required, current_user 6 | 7 | from blueprints import access, assessment, assessment_utils, assessment_import, assessment_export, testcase, testcase_utils 8 | 9 | load_dotenv() 10 | 11 | app = Flask(__name__) 12 | app.config.from_pyfile("flask.cfg") 13 | 14 | app.register_blueprint(access.blueprint_access) 15 | app.register_blueprint(assessment.blueprint_assessment) 16 | app.register_blueprint(assessment_utils.blueprint_assessment_utils) 17 | app.register_blueprint(assessment_import.blueprint_assessment_import) 18 | app.register_blueprint(assessment_export.blueprint_assessment_export) 19 | app.register_blueprint(testcase.blueprint_testcase) 20 | app.register_blueprint(testcase_utils.blueprint_testcase_utils) 21 | 22 | db.init_app(app) 23 | 24 | security = Security(app, user_datastore) 25 | 26 | @app.route('/') 27 | @app.route('/index') 28 | @auth_required() 29 | def index(): 30 | if current_user.initpwd: 31 | return redirect("/password/change") 32 | assessments = Assessment.objects().all() 33 | return render_template('assessments.html', assessments=assessments) 34 | 35 | # mitigates cve-2023-49438 - can be removed with Flask-Security-Too >=5.3.3 36 | # see: https://github.com/brandon-t-elliott/CVE-2023-49438 37 | @app.after_request 38 | def fix_location_header(response): 39 | response.autocorrect_location_header = True 40 | return response 41 | 42 | if __name__ == "__main__": 43 | app.run(host=os.getenv('HOST'), port=int(os.getenv('PORT'))) 44 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==3.7.1 2 | asn1crypto==1.5.1 3 | Babel==2.12.1 4 | bcrypt==4.0.1 5 | bleach==6.0.0 6 | blinker==1.6.2 7 | cbor2==5.4.6 8 | certifi==2023.7.22 9 | cffi==1.15.1 10 | charset-normalizer==3.2.0 11 | click==8.1.6 12 | cryptography==41.0.6 13 | dnspython==2.4.0 14 | docxcompose==1.4.0 15 | docxtpl==0.16.7 16 | email-validator==2.0.0.post2 17 | et-xmlfile==1.1.0 18 | exceptiongroup==1.1.2 19 | Flask==2.2.5 20 | Flask-BabelEx==0.9.4 21 | Flask-Login==0.6.2 22 | Flask-Mail==0.9.1 23 | flask-mailman==0.3.0 24 | flask-mongoengine==1.0.0 25 | Flask-Principal==0.4.0 26 | Flask-Security==3.0.0 27 | Flask-Security-Too==5.0.2 28 | Flask-SQLAlchemy==3.0.5 29 | Flask-WTF==1.1.1 30 | gitdb==4.0.10 31 | GitPython==3.1.41 32 | greenlet==2.0.2 33 | h11==0.14.0 34 | httpcore==0.17.3 35 | idna==3.4 36 | importlib-metadata==6.8.0 37 | importlib-resources==6.0.0 38 | itsdangerous==2.1.2 39 | Jinja2==3.1.3 40 | lxml==4.9.3 41 | MarkupSafe==2.1.3 42 | mkdocs-material-extensions==1.1.1 43 | mongoengine==0.27.0 44 | openpyxl==3.1.2 45 | passlib==1.7.4 46 | phonenumberslite==8.13.17 47 | pycparser==2.21 48 | pydantic==1.10.11 49 | pymongo==4.4.1 50 | pyOpenSSL==23.2.0 51 | pypng==0.20220715.0 52 | python-docx==0.8.11 53 | python-dotenv==1.0.0 54 | pytz==2023.3 55 | PyYAML==6.0.1 56 | qrcode==7.4.2 57 | requests==2.31.0 58 | six==1.16.0 59 | smmap==5.0.0 60 | sniffio==1.3.0 61 | speaklater==1.3 62 | SQLAlchemy==2.0.19 63 | SQLAlchemy-Utils==0.41.1 64 | typing_extensions==4.7.1 65 | urllib3==2.0.4 66 | webauthn==1.9.0 67 | webencodings==0.5.1 68 | Werkzeug==2.3.6 69 | WTForms==3.0.1 70 | zipp==3.16.2 71 | gunicorn==20.1.0 72 | -------------------------------------------------------------------------------- /seeder.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import yaml 5 | import uuid 6 | import shutil 7 | import dotenv 8 | import secrets 9 | import requests 10 | import passlib.totp 11 | from model import * 12 | from git import Repo 13 | from glob import glob 14 | from flask import Flask 15 | from openpyxl import load_workbook 16 | 17 | dotenvFile = dotenv.find_dotenv() 18 | dotenv.load_dotenv(dotenvFile) 19 | 20 | PWD = os.getcwd() 21 | 22 | app = Flask(__name__) 23 | app.config.from_pyfile("flask.cfg") 24 | db.init_app(app) 25 | 26 | if not os.path.exists(f"{PWD}/external/"): 27 | os.makedirs(f"{PWD}/external") 28 | 29 | ### 30 | 31 | def pullMitreAttack (component): 32 | # Pull the HTML page to find and download the link to the latest framework version 33 | req = requests.get(f"https://github.com/CyberCX-STA/PurpleOps-Deps/raw/master/attack.mitre/15.1/enterprise-attack-v15.1-{component}.xlsx") 34 | if req.status_code == 200: 35 | #req = r.text.split('"') 36 | #url = [x for x in req if "xlsx" in x and "enterprise" in x and "docs" in x and component in x][0] 37 | #req = requests.get("https://attack.mitre.org" + url) 38 | with open(f"{PWD}/external/mitre-{component}.xlsx", "wb") as mitreXLSX: 39 | mitreXLSX.write(req.content) 40 | 41 | wb = load_workbook(f"{PWD}/external/mitre-{component}.xlsx", read_only=True) 42 | ws = wb.active 43 | 44 | headers = [col.value for col in list(ws.rows)[0]] 45 | 46 | return [ws.rows, headers] 47 | else: 48 | print(f"Failed getting information from MITRE [{req.status_code}]") 49 | sys.exit() 50 | 51 | def parseMitreTactics (): 52 | rows, headers = pullMitreAttack("tactics") 53 | for row in rows: 54 | if row[0].value == "ID": # Skip header row 55 | continue 56 | Tactic( 57 | mitreid = row[headers.index("ID")].value, 58 | name = row[headers.index("name")].value 59 | ).save() 60 | 61 | def parseMitreTechniques (): 62 | rows, headers = pullMitreAttack("techniques") 63 | for row in rows: 64 | if row[0].value == "ID": # Skip header row 65 | continue 66 | Technique( 67 | mitreid = row[headers.index("ID")].value, 68 | name = row[headers.index("name")].value, 69 | description = row[headers.index("description")].value, 70 | detection = row[headers.index("detection")].value or "Missing data.", 71 | tactics = row[headers.index("tactics")].value.split(",") 72 | ).save() 73 | 74 | # Include a default reporting writeup - can be overwritten with customs 75 | KnowlegeBase( 76 | mitreid = row[headers.index("ID")].value, 77 | overview = row[headers.index("description")].value, 78 | advice = row[headers.index("detection")].value or "Missing data.", 79 | provider = "MITRE" 80 | ).save() 81 | 82 | def pullSigma (): 83 | if os.path.exists(f"{PWD}/external/sigma") and os.path.isdir(f"{PWD}/external/sigma"): 84 | shutil.rmtree(f"{PWD}/external/sigma") 85 | Repo.clone_from("https://github.com/SigmaHQ/sigma", f"{PWD}/external/sigma") 86 | 87 | def parseSigma (): 88 | pullSigma() 89 | for sigmaRule in glob(f'{PWD}/external/sigma/rules/**/*.yml', recursive=True): 90 | with open(sigmaRule, "r") as sigmaFile: 91 | yml = yaml.safe_load(sigmaFile) 92 | 93 | url = "https://github.com/SigmaHQ/sigma/blob/master/rules" 94 | url += sigmaRule.replace(f"{PWD}/external/sigma/rules", "") 95 | 96 | # ART stores relevant MitreIDs in tags, parse them out 97 | associatedTTP = [] 98 | if "tags" in yml: 99 | for tag in yml["tags"]: 100 | search = re.search(r'attack\.([tT]\d\d\d\d(\.\d\d\d)*)', tag) 101 | if search: 102 | associatedTTP.append(search.group(1).upper()) 103 | 104 | for ttp in associatedTTP: 105 | Sigma( 106 | mitreid = ttp, 107 | name = yml["title"], 108 | description = yml["description"], 109 | url=url 110 | ).save() 111 | 112 | def pullAtomicRedTeam (): 113 | if os.path.exists(f"{PWD}/external/art") and os.path.isdir(f"{PWD}/external/art"): 114 | shutil.rmtree(f"{PWD}/external/art") 115 | Repo.clone_from("https://github.com/redcanaryco/atomic-red-team", f"{PWD}/external/art") 116 | 117 | def parseAtomicRedTeam (): 118 | pullAtomicRedTeam() 119 | for artTestcases in glob(f'{PWD}/external/art/atomics/T*/*.yaml', recursive=True): 120 | with open(artTestcases, "r") as artFile: 121 | yml = yaml.safe_load(artFile) 122 | 123 | for artTestcase in yml["atomic_tests"]: 124 | # If there's no command, then we don't want it 125 | if "command" in artTestcase["executor"]: 126 | baseCommand = artTestcase["executor"]["command"].strip() 127 | # If there's variables in the command, populate it with the 128 | # default sample variables e.g. #{dumpname} > lsass.dmp 129 | if "input_arguments" in artTestcase and isinstance(artTestcase["input_arguments"], dict): 130 | for i in artTestcase["input_arguments"].keys(): 131 | k = "#{" + i + "}" 132 | baseCommand = baseCommand.replace(k, str(artTestcase["input_arguments"][i]["default"])) 133 | 134 | TestCaseTemplate( 135 | name = artTestcase["name"], 136 | mitreid = yml["attack_technique"], 137 | # Infer the relevant tactic from the first match from MITRE techniques 138 | tactic = Technique.objects(mitreid=yml["attack_technique"]).first()["tactics"][0], 139 | objective = artTestcase["description"], 140 | actions = baseCommand, 141 | provider = "ART" 142 | ).save() 143 | 144 | def parseCustomTestcases (): 145 | for customTestcase in glob(f'{PWD}/custom/testcases/*.yaml'): 146 | with open(customTestcase, "r") as customTestcaseFile: 147 | yml = yaml.safe_load(customTestcaseFile) 148 | 149 | TestCaseTemplate( 150 | name = yml["name"], 151 | mitreid = yml["mitreid"], 152 | tactic = yml["tactic"], 153 | objective = yml["objective"], 154 | actions = yml["actions"], 155 | provider = yml["provider"] 156 | ).save() 157 | 158 | def parseCustomKBs (): 159 | for customKB in glob(f'{PWD}/custom/knowledgebase/*.yaml'): 160 | with open(customKB, "r") as customKBFile: 161 | yml = yaml.safe_load(customKBFile) 162 | 163 | # Overwrite the reporting KB for the mitre id with the custom writeup 164 | KB = KnowlegeBase.objects(mitreid=yml["mitreid"]).first() 165 | KB.overview = yml["overview"] 166 | KB.advice = yml["advice"] 167 | KB.provider = yml["provider"] 168 | KB.save() 169 | 170 | def prepareRolesAndAdmin (): 171 | if Role.objects().count() == 0: 172 | for role in ["Admin", "Red", "Blue", "Spectator"]: 173 | roleObj = Role(name=role) 174 | roleObj.save() 175 | 176 | if User.objects().count() == 0: 177 | password = str(uuid.uuid4()) 178 | dotenv.set_key(dotenvFile, "POPS_ADMIN_PWD", password) 179 | # TODO set to invalid email 180 | user_datastore.create_user( 181 | email = 'admin@purpleops.com', 182 | username = 'admin', 183 | password = password, 184 | roles = [Role.objects(name="Admin").first()], 185 | initpwd = False 186 | ) 187 | print("==============================================================\n\n\n") 188 | print(f"\tCreated initial admin: U: admin@purpleops.com P: {password}") 189 | print("\n\n\n==============================================================") 190 | 191 | def populateSecrets (): 192 | if Role.objects().count() == 0: 193 | dotenv.set_key( 194 | dotenvFile, 195 | "FLASK_SECURITY_PASSWORD_SALT", 196 | str(secrets.SystemRandom().getrandbits(128)) 197 | ) 198 | dotenv.set_key( 199 | dotenvFile, 200 | "FLASK_SECRET_KEY", 201 | secrets.token_urlsafe() 202 | ) 203 | dotenv.set_key( 204 | dotenvFile, 205 | "FLASK_SECURITY_TOTP_SECRETS", 206 | f"{{1: {passlib.totp.generate_secret()}}}" 207 | ) 208 | 209 | ##### 210 | 211 | if Tactic.objects.count() == 0: 212 | print("==============================================================\n\n\n") 213 | print(f"\t NEW INSTANCE DETECTED, LETS GET THE DATA WE NEED") 214 | print("\n\n\n==============================================================") 215 | 216 | Tactic.objects.delete() 217 | Technique.objects.delete() 218 | Sigma.objects.delete() 219 | TestCaseTemplate.objects.delete() 220 | KnowlegeBase.objects.delete() 221 | # Role.objects.delete() 222 | # User.objects.delete() 223 | 224 | print("Pulling MITRE tactics") 225 | parseMitreTactics() 226 | 227 | print("Pulling MITRE techniques") 228 | parseMitreTechniques() 229 | 230 | print("Pulling SIGMA detections") 231 | parseSigma() 232 | 233 | print("Pulling Atomic Red Team testcases") 234 | parseAtomicRedTeam() 235 | 236 | print("Parsing Custom testcases") 237 | parseCustomTestcases() 238 | 239 | print("Parsing Custom KBs") 240 | parseCustomKBs() 241 | 242 | print("Populating (randomising) secrets") 243 | populateSecrets() 244 | 245 | print("Preparing roles and initial admin") 246 | prepareRolesAndAdmin() 247 | -------------------------------------------------------------------------------- /static/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberCX-STA/PurpleOps/2d91a5e3b4a9256f4a049c8e423462ff30a475c6/static/images/demo.gif -------------------------------------------------------------------------------- /static/images/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberCX-STA/PurpleOps/2d91a5e3b4a9256f4a049c8e423462ff30a475c6/static/images/logo.ico -------------------------------------------------------------------------------- /static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberCX-STA/PurpleOps/2d91a5e3b4a9256f4a049c8e423462ff30a475c6/static/images/logo.png -------------------------------------------------------------------------------- /static/scripts/access.js: -------------------------------------------------------------------------------- 1 | // Delay table showing until page is loaded to prevent jumping 2 | $(function () { 3 | $('#userTable').show() 4 | }) 5 | 6 | var row = null 7 | var rowData = null 8 | 9 | function newUserModal(e) { 10 | // This mess allows us to reuse the modal between new and edit user 11 | $("#userDetailForm").trigger('reset') 12 | $('#userDetailForm').attr('action', '/manage/access/user') 13 | $('#userDetailLabel').text("New User") 14 | $('#userDetailButton').text("Create") 15 | $('#password').attr("type", "text") 16 | $('#userDetailForm #roles').selectpicker('val', ""); 17 | $('#userDetailForm #assessments').selectpicker('val', ""); 18 | $('#userDetailModal').modal('show') 19 | } 20 | 21 | function editUserModal(e) { 22 | // Globally store the clicked row for AJAX operations 23 | row = $(e).closest("tr") 24 | rowData = $('#userTable').bootstrapTable('getData')[row.data("index")] 25 | $("#userDetailForm").trigger('reset') 26 | $('#userDetailForm').attr('action', '/manage/access/user/' + rowData.id) 27 | $('#userDetailLabel').text("Edit User") 28 | $('#userDetailButton').text("Update") 29 | // Make it look like a password is in the form, stops admins getting scared 30 | // that altering a user's details will wipe their password whilst still 31 | // giving them a chance to change it 32 | $('#password').attr("type", "password") 33 | $('#password').val(" ".repeat(128)) 34 | $('#userDetailForm #username').val(rowData.username) 35 | $('#userDetailForm #email').val(rowData.email) 36 | $('#userDetailForm #roles').selectpicker('val', rowData.roles.split(", ")); 37 | $('#userDetailForm #assessments').selectpicker('val', rowData.assessments.split(", ")); 38 | $('#userDetailModal').modal('show') 39 | } 40 | 41 | function deleteUserModal(e) { 42 | // Globally store the clicked row for AJAX operations 43 | row = $(e).closest("tr") 44 | rowData = $('#userTable').bootstrapTable('getData')[row.data("index")] 45 | $('#deleteUserForm').attr('action', '/manage/access/user/' + rowData.id) 46 | $('#deleteUserWarning').text(`Really Delete ${rowData.username}?`) 47 | $('#deleteUserModal').modal('show') 48 | } 49 | 50 | // Hook the native new/edit user HTML form to catch and action the response 51 | $("#userDetailForm").submit(function(e){ 52 | e.preventDefault(); 53 | 54 | fetch(e.target.action, { 55 | method: 'POST', 56 | body: new URLSearchParams(new FormData(e.target)) 57 | }).then((response) => { 58 | return response.json(); 59 | }).then((body) => { 60 | // Format assessment cell 61 | if (body.roles.includes("Admin")) { 62 | body.assessments = "*" 63 | } else if (body.assessments.length) { 64 | body.assessments = body.assessments.join(", ") 65 | } else { 66 | body.assessments = "-" 67 | } 68 | 69 | newRow = { 70 | id: body.id, 71 | username: body.username, 72 | email: body.email, 73 | roles: body.roles.length ? body.roles.join(", ") : "-", 74 | assessments: body.assessments, 75 | actions: body.username 76 | } 77 | 78 | // This function is shared between new and edit user, so do we need to 79 | // edit a row or create a new one? 80 | if ($('#userTable').bootstrapTable('getRowByUniqueId', body.id)) { 81 | $('#userTable').bootstrapTable('updateRow', { 82 | index: row.data("index"), 83 | row: newRow 84 | }) 85 | } else { 86 | $('#userTable').bootstrapTable('append', [newRow]) 87 | } 88 | 89 | $('#userDetailModal').modal('hide') 90 | }) 91 | }); 92 | 93 | // AJAX DELETE user call 94 | $('#deleteUserButton').click(function() { 95 | $.ajax({ 96 | url: '/manage/access/user/' + rowData.id, 97 | type: 'DELETE', 98 | success: function(result) { 99 | $('#userTable').bootstrapTable('removeByUniqueId', rowData.id) 100 | $('#deleteUserModal').modal('hide') 101 | } 102 | }); 103 | }); 104 | 105 | // Template for the action buttons (i.e. edit / delete user) 106 | function actionFormatter(username) { 107 | return ` 108 |
109 | 112 | ${username == "admin" ? "" : ` 113 | 116 | `} 117 |
118 | ` 119 | } 120 | 121 | function timeFormatter(utcip) { 122 | utc = utcip.split("|")[0] 123 | ip = utcip.split("|")[1] 124 | cell = "" 125 | if (!utc || utc == "-") { 126 | return "-" 127 | } 128 | 129 | offset = new Date().getTimezoneOffset() 130 | local = new Date(utc); 131 | local.setMinutes(local.getMinutes() - offset * 2); 132 | 133 | return `${local.toISOString().slice(0,16)} (${ip})` 134 | } -------------------------------------------------------------------------------- /static/scripts/assessment.js: -------------------------------------------------------------------------------- 1 | // Onload 2 | $(function () { 3 | // The cookie extension oddly somtimes force shows the ID column, so force it hidden 4 | $('#assessmentTable').bootstrapTable('hideColumn', 'id') 5 | // Delay table showing until page is loaded to prevent jumping 6 | $('#assessmentTable').show() 7 | }) 8 | 9 | var row = null 10 | var rowData = null 11 | 12 | // Pop modal when adding a new raw testcase and clear old data 13 | $('#newTestcase').click(function() { 14 | $("#newTestcaseForm").trigger('reset') 15 | $('#newTestcaseModal').modal('show') 16 | }); 17 | 18 | // Wrapper for formatting server responses into table rows 19 | function formatRow(response) { 20 | return { 21 | add: response.add, 22 | id: response.id, 23 | mitreid: response.mitreid, 24 | name: response.name, 25 | tactic: response.tactic, 26 | state: response.state, 27 | visible: response.visible, 28 | tags: response.tags.join(","), 29 | uuid: response.uuid, 30 | start: response.starttime != "None" ? response.starttime : "", 31 | modified: response.modifytime, 32 | preventscore: response.preventedrating !== null ? response.preventedrating : "", 33 | detectscore: response.detectionrating !== null ? response.detectionrating : "", 34 | outcome: response.outcome, 35 | actions: "", 36 | } 37 | } 38 | 39 | // AJAX new testcase POST and table append 40 | $("#newTestcaseForm").submit(function(e){ 41 | e.preventDefault(); 42 | fetch(e.target.action, { 43 | method: 'POST', 44 | body: new URLSearchParams(new FormData(e.target)) 45 | }).then((response) => { 46 | return response.json(); 47 | }).then((body) => { 48 | $('#assessmentTable').bootstrapTable('append', [formatRow(body)]) 49 | $('#newTestcaseModal').modal('hide') 50 | }) 51 | }); 52 | 53 | // AJAX new testcase from template POST and table append 54 | $('#testcaseTemplatesButton').click(function() { 55 | $.ajax({ 56 | url: `/assessment/${window.location.href.split("/").slice(-1)[0]}/import/template`, 57 | type: 'POST', 58 | data: JSON.stringify({ 59 | ids: $('#testcaseTemplateTable').bootstrapTable('getSelections').map(row => row.id) 60 | }), 61 | dataType: 'json', 62 | contentType: "application/json; charset=utf-8", 63 | success: function(result) { 64 | result.forEach(result => { 65 | $('#assessmentTable').bootstrapTable('append', [formatRow(result)]) 66 | }) 67 | $('#testcaseTemplatesModal').modal('hide') 68 | } 69 | }); 70 | }); 71 | 72 | // AJAX new testcases from navigator template POST and table append 73 | $("#navigatorTemplateForm").submit(function(e){ 74 | e.preventDefault(); 75 | fetch(e.target.action, { 76 | method: 'POST', 77 | body: new FormData(e.target) 78 | }).then((response) => { 79 | return response.json(); 80 | }).then((body) => { 81 | body.forEach(result => { 82 | $('#assessmentTable').bootstrapTable('append', [formatRow(result)]) 83 | }) 84 | $('#testcaseNavigatorModal').modal('hide') 85 | }) 86 | }); 87 | 88 | // AJAX new testcases from campaign template POST and table append 89 | $("#campaignTemplateForm").submit(function(e){ 90 | e.preventDefault(); 91 | fetch(e.target.action, { 92 | method: 'POST', 93 | body: new FormData(e.target) 94 | }).then((response) => { 95 | return response.json(); 96 | }).then((body) => { 97 | body.forEach(result => { 98 | $('#assessmentTable').bootstrapTable('append', [formatRow(result)]) 99 | }) 100 | $('#testcaseCampaignModal').modal('hide') 101 | }) 102 | }); 103 | 104 | // Toggle visibility of testcase AJAX 105 | function visibleTest(event) { 106 | event.stopPropagation(); 107 | row = $(event.target).closest("tr") 108 | rowData = $('#assessmentTable').bootstrapTable('getData')[row.data("index")] 109 | $.ajax({ 110 | url: `/testcase/${rowData.id}/toggle-visibility`, 111 | type: 'GET', 112 | success: function(body) { 113 | $('#assessmentTable').bootstrapTable('updateByUniqueId', { 114 | id: body.id, 115 | row: formatRow(body), 116 | replace: true 117 | }) 118 | } 119 | }); 120 | }; 121 | 122 | // Testcase clone AJAX POST and row update 123 | function cloneTest(event) { 124 | event.stopPropagation(); 125 | row = $(event.target).closest("tr") 126 | rowData = $('#assessmentTable').bootstrapTable('getData')[row.data("index")] 127 | $.ajax({ 128 | url: `/testcase/${rowData.id}/clone`, 129 | type: 'GET', 130 | success: function(body) { 131 | $('#assessmentTable').bootstrapTable('insertRow', { 132 | index: row.data("index") + 1, 133 | row: formatRow(body) 134 | }) 135 | } 136 | }); 137 | }; 138 | 139 | // Testcase delete AJAX POST and remove from table 140 | function deleteTest(event) { 141 | event.stopPropagation(); 142 | row = $(event.target).closest("tr") 143 | rowData = $('#assessmentTable').bootstrapTable('getData')[row.data("index")] 144 | $.ajax({ 145 | url: `/testcase/${rowData.id}/delete`, 146 | type: 'GET', 147 | success: function(body) { 148 | $('#assessmentTable').bootstrapTable('removeByUniqueId', rowData.id) 149 | } 150 | }); 151 | }; 152 | 153 | // Table formatters 154 | function nameFormatter(name, row) { 155 | return `${name}` 156 | } 157 | 158 | function visibleFormatter(name) { 159 | return (name == "True" || name == true) ? "✅" : "❌" 160 | } 161 | 162 | function tagFormatter(tags) { 163 | html = [] 164 | tags.split(",").forEach(tag => { 165 | html.push(`${tag.split("|")[0]}`) 166 | }) 167 | return html.join(" ") 168 | } 169 | 170 | function actionFormatter() { 171 | return ` 172 |
173 | 176 | 179 | 182 |
183 | ` 184 | } 185 | 186 | function timeFormatter(utc) { 187 | if (!utc.length) { 188 | return utc 189 | } 190 | 191 | offset = new Date().getTimezoneOffset() 192 | local = new Date(utc); 193 | local.setMinutes(local.getMinutes() - offset); 194 | return local.toISOString().slice(0,16) 195 | } 196 | 197 | function bgFormatter(value) { 198 | bg = "" 199 | text = "" 200 | if (["Missed", "1.0", "1.5"].includes(value)) { 201 | bg = "danger" 202 | } else if (["Running", "Logged", "2.0", "2.5"].includes(value)) { 203 | bg = "warning" 204 | } else if (["Alerted", "3.0", "3.5"].includes(value)) { 205 | bg = "success" 206 | } else if (["Prevented", "4.0", "4.5", "5.0"].includes(value)) { 207 | bg = "info" 208 | text = "light" 209 | } else if (["Complete"].includes(value)) { 210 | bg = "primary" 211 | text = "light" 212 | } else if (["Pending"].includes(value)) { 213 | bg = "light" 214 | } else if (["False", false, "0.0", "0.5"].includes(value)) { 215 | bg = "dark" 216 | text = "light" 217 | } 218 | css = {background: `var(--bs-${bg})`} 219 | if (text.length) { 220 | css["color"] = `var(--bs-${text})` 221 | } 222 | return {css: css} 223 | } 224 | 225 | // Show # selected testcases 226 | $('#assessmentTable').on( 'check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table', function (e) { 227 | selectedIds = $("#assessmentTable").bootstrapTable('getSelections').map(i => i.id) 228 | if (selectedIds.length > 0) { 229 | $("#selected-count").show() 230 | $("#selected-count").text(`(${selectedIds.length} selected)`) 231 | } 232 | else { 233 | $("#selected-count").hide() 234 | } 235 | } ); 236 | -------------------------------------------------------------------------------- /static/scripts/assessment.stats.js: -------------------------------------------------------------------------------- 1 | // Template pie chart styles 2 | var pieChartOptions = { 3 | chart: { 4 | width: 380, 5 | type: 'pie', 6 | }, 7 | title: { 8 | text: '', 9 | align: 'left' 10 | } 11 | }; 12 | 13 | // Template bar chart styles 14 | var barChartOptions = { 15 | series: [], 16 | chart: { 17 | type: 'bar', 18 | height: 370 // Originally 350 19 | }, 20 | plotOptions: { 21 | bar: { 22 | horizontal: false, 23 | columnWidth: '55%', 24 | endingShape: 'rounded' 25 | }, 26 | }, 27 | dataLabels: { 28 | enabled: false 29 | }, 30 | stroke: { 31 | show: true, 32 | width: 2, 33 | colors: ['transparent'] 34 | }, 35 | xaxis: { 36 | categories: [], 37 | labels: { 38 | show: true, 39 | rotate: -45, 40 | rotateAlways: true, 41 | trim: false, 42 | }, 43 | }, 44 | yaxis: { 45 | title: { 46 | text: 'Count' 47 | }, 48 | tickAmount: 2 // Originally 1 49 | }, 50 | fill: { 51 | opacity: 1 52 | }, 53 | title: { 54 | text: '', 55 | align: 'left' 56 | }, 57 | }; 58 | 59 | // Custom bar chart styles for individual Tactics results 60 | var barChartOptionsCustom = { 61 | series: [{ 62 | name: 'Results', 63 | data: [] 64 | }], 65 | chart: { 66 | type: 'bar', 67 | height: 370 // Originally 350 68 | }, 69 | plotOptions: { 70 | bar: { 71 | horizontal: false, 72 | columnWidth: '55%', 73 | endingShape: 'rounded' 74 | }, 75 | }, 76 | dataLabels: { 77 | enabled: true // Originally false 78 | }, 79 | stroke: { 80 | show: true, 81 | width: 2, 82 | colors: ['transparent'] 83 | }, 84 | xaxis: { 85 | type: 'category', 86 | categories: ["Prevented","Alerted","Logged","Missed"], 87 | labels: { 88 | show: false, 89 | rotate: -45, 90 | rotateAlways: true, 91 | trim: false, 92 | }, 93 | }, 94 | yaxis: { 95 | show: true, 96 | title: { 97 | text: 'Count', 98 | rotate: -90, 99 | }, 100 | labels: { 101 | show: true, 102 | rotate: 0, 103 | }, 104 | tickAmount: 2 // Originally 1 105 | }, 106 | fill: { 107 | opacity: 1 108 | }, 109 | title: { 110 | text: '', 111 | align: 'left' 112 | }, 113 | }; 114 | 115 | // Template boxplot styling 116 | var boxChartOptions = { 117 | series: [ 118 | { 119 | type: 'boxPlot', 120 | data: Object.keys(tacticStats).map((i) => { 121 | return { 122 | x: i, 123 | y: boxPlotVals(tacticStats[i]["scoresPrevent"]) 124 | } 125 | }) 126 | } 127 | ], 128 | chart: { 129 | type: 'boxPlot', 130 | height: 350 131 | }, 132 | title: { 133 | text: 'Prevention and Detection Scores per Tactic', 134 | align: 'left' 135 | } 136 | }; 137 | 138 | // Boxplot helper functions 139 | function getPercentile(data, percentile) { 140 | data.sort(numSort); 141 | var index = (percentile / 100) * data.length; 142 | var result; 143 | if (Math.floor(index) == index) { 144 | result = (data[(index - 1)] + data[index]) / 2; 145 | } 146 | else { 147 | result = data[Math.floor(index)]; 148 | } 149 | return result; 150 | } 151 | function numSort(a, b) { 152 | return a - b; 153 | } 154 | function boxPlotVals(data) { 155 | return [ 156 | Math.min.apply(Math, data), 157 | getPercentile(data, 25), 158 | getPercentile(data, 50), 159 | getPercentile(data, 75), 160 | Math.max.apply(Math, data) 161 | ] 162 | } 163 | 164 | 165 | // Outcomes pie chart 166 | var resultsPie = JSON.parse(JSON.stringify(pieChartOptions)); 167 | resultsPie.title.text = "Outcomes" 168 | keys = ["Prevented", "Alerted", "Logged", "Missed"] 169 | resultsPie.series = keys.map((t) => { 170 | return tacticStats["All"][t] 171 | }) 172 | resultsPie.labels = keys 173 | var chart = new ApexCharts(document.querySelector("#resultspie"), resultsPie); 174 | chart.render(); 175 | 176 | 177 | // Outcome bar chart (Excluding "All") 178 | var results = JSON.parse(JSON.stringify(barChartOptions)); 179 | results.title.text = "Outcome per Tactic" 180 | results.series = ["Prevented", "Alerted", "Logged", "Missed"].map((t) => { 181 | return { 182 | name: t, 183 | data: Object.keys(tacticStats).filter((i) => i !== "All").map((i) => { 184 | return tacticStats[i][t] 185 | }) 186 | } 187 | }) 188 | results.xaxis.categories = Object.keys(tacticStats).filter((i) => i !== "All"); 189 | var chart = new ApexCharts(document.querySelector("#results"), results); 190 | chart.render(); 191 | 192 | 193 | // Alert bar chart (Excluding "All") 194 | var alerts = JSON.parse(JSON.stringify(barChartOptions)); 195 | alerts.title.text = "Alert Severities per Tactic" 196 | alerts.series = ["Informational", "Low", "Medium", "High", "Critical"].map((t) => { 197 | return { 198 | name: t, 199 | data: Object.keys(tacticStats).filter((i) => i !== "All").map((i) => { 200 | return tacticStats[i][t] 201 | }) 202 | } 203 | }) 204 | alerts.xaxis.categories = Object.keys(tacticStats).filter((i) => i !== "All"); 205 | var chart = new ApexCharts(document.querySelector("#alerts"), alerts); 206 | chart.render(); 207 | 208 | 209 | // Priority bar chart (Excluding "All") 210 | var priorities = JSON.parse(JSON.stringify(barChartOptions)); 211 | priorities.title.text = "Priority Action and Priority per Tactic" 212 | priorities.series = ["Prevent", "Detect", "Low", "Medium", "High"].map((t) => { 213 | return { 214 | name: t, 215 | data: Object.keys(tacticStats).filter((i) => i !== "All").map((i) => { 216 | return tacticStats[i]["priorityType"].concat(tacticStats[i]["priorityUrgency"]).filter(x => x === t).length 217 | }) 218 | } 219 | }) 220 | priorities.xaxis.categories = Object.keys(tacticStats).filter((i) => i !== "All"); 221 | var chart = new ApexCharts(document.querySelector("#priorities"), priorities); 222 | chart.render(); 223 | 224 | 225 | // Control bar chart (Excluding "All") 226 | var controls = JSON.parse(JSON.stringify(barChartOptions)); 227 | controls.title.text = "Controls per Tactic" 228 | controlKeys = [...new Set(tacticStats["All"]["controls"])] 229 | controls.series = controlKeys.map((t) => { 230 | return { 231 | name: t, 232 | data: Object.keys(tacticStats).filter((i) => i !== "All").map((i) => { 233 | return tacticStats[i]["controls"].filter(x => x === t).length 234 | }) 235 | } 236 | }) 237 | controls.xaxis.categories = Object.keys(tacticStats).filter((i) => i !== "All"); 238 | var chart = new ApexCharts(document.querySelector("#controls"), controls); 239 | chart.render(); 240 | 241 | 242 | // Prev/detect box plot chart 243 | var chart = new ApexCharts(document.querySelector("#scores"), boxChartOptions); 244 | chart.render(); 245 | 246 | 247 | function isObjectNotEmpty(obj) { 248 | if (obj === undefined) { 249 | return false; // If object is undefined, return false 250 | } 251 | for (var key in obj) { 252 | if (obj.hasOwnProperty(key) && obj[key] !== 0) { 253 | return true; // If any non-zero value is found, return true 254 | } 255 | } 256 | return false; // If all values are zero or undefined, return false 257 | } 258 | 259 | var noDataOptions = { 260 | text: 'There is no data available for this Tactic', 261 | align: 'center', 262 | verticalAlign: 'middle', 263 | offsetX: 0, 264 | offsetY: 0, 265 | style: { 266 | fontSize: '14px', 267 | } 268 | }; 269 | 270 | function renderTacticChart(name, filteredTacticStats, chartContainerId) { 271 | var results = JSON.parse(JSON.stringify(barChartOptionsCustom)); 272 | results.title.text = name + " Results"; 273 | if (isObjectNotEmpty(filteredTacticStats)) { 274 | // Loop over each element and set each value from filteredTacticStats 275 | results.series = ["Prevented", "Alerted", "Logged", "Missed"].map((t) => { 276 | return { 277 | name: t, 278 | data: [filteredTacticStats[t]] // Put the count of each outcome into an array 279 | }; 280 | }); 281 | results.xaxis.categories = [name]; 282 | var chart = new ApexCharts(document.querySelector(chartContainerId), results); 283 | chart.render(); 284 | } else { 285 | console.warn(`No data available for rendering the '${name}' chart.`); 286 | var chart = new ApexCharts(document.querySelector(chartContainerId), { 287 | ...results, 288 | noData: noDataOptions 289 | }); 290 | chart.render(); 291 | } 292 | } 293 | 294 | // Render individual chart results of each Tactic - Outputs "No data available" if chart is empty 295 | var Tactics = ["Reconnaissance","Resource Development","Initial Access","Execution","Persistence","Privilege Escalation","Defense Evasion","Credential Access","Discovery","Lateral Movement","Collection","Command and Control","Exfiltration","Impact"] 296 | for (const tactic of Tactics) { 297 | renderTacticChart(tactic, tacticStats[tactic], `#results${tactic.toLowerCase().split(' ').join('')}`); 298 | } -------------------------------------------------------------------------------- /static/scripts/assessments.js: -------------------------------------------------------------------------------- 1 | // Delay table showing until page is loaded to prevent jumping 2 | $(function () { 3 | $('#assessmentsTable').show() 4 | }) 5 | 6 | var row = null 7 | var rowData = null 8 | 9 | $('#newAssessment').click(function() { 10 | $("#newAssessmentForm").trigger('reset') 11 | $('#newAssessmentForm').attr('action', '/assessment') 12 | $('#newAssessmentLabel').text("New Assessment") 13 | $('#newAssessmentButton').text("Create") 14 | $('#newAssessmentModal').modal('show') 15 | }); 16 | 17 | function editAssessmentModal(e) { 18 | // Globally store the clicked row for AJAX operations 19 | row = $(e).closest("tr") 20 | rowData = $('#assessmentsTable').bootstrapTable('getData')[row.data("index")] 21 | $("#newAssessmentForm").trigger('reset') 22 | $('#newAssessmentForm').attr('action', `/assessment/${rowData.id}`) 23 | $('#newAssessmentLabel').text("Edit Assessment") 24 | $('#newAssessmentButton').text("Update") 25 | $('#newAssessmentForm #name').val(rowData.name) 26 | $('#newAssessmentForm #description').val(rowData.description) 27 | $('#newAssessmentModal').modal('show') 28 | } 29 | 30 | function deleteAssessmentModal(e) { 31 | // Globally store the clicked row for AJAX operations 32 | row = $(e).closest("tr") 33 | rowData = $('#assessmentsTable').bootstrapTable('getData')[row.data("index")] 34 | $('#deleteAssessmentForm').attr('action', `/assessment/${rowData.id}`) 35 | $('#deleteAssessmentWarning').text(`Really Delete ${rowData.name}?`) 36 | $('#deleteAssessmentModal').modal('show') 37 | } 38 | 39 | // Hook the native new/edit assessment HTML form to catch and action the response 40 | $("#newAssessmentForm").submit(function(e){ 41 | e.preventDefault(); 42 | 43 | fetch(e.target.action, { 44 | method: 'POST', 45 | body: new URLSearchParams(new FormData(e.target)) 46 | }).then((response) => { 47 | return response.json(); 48 | }).then((body) => { 49 | newRow = { 50 | id: body.id, 51 | name: body.name, 52 | description: body.description, 53 | progress: body.progress, 54 | actions: "" 55 | } 56 | 57 | // This function is shared between new and edit assessment, so do we 58 | // need to edit a row or create a new one? 59 | if ($('#assessmentsTable').bootstrapTable('getRowByUniqueId', body.id)) { 60 | $('#assessmentsTable').bootstrapTable('updateRow', { 61 | index: row.data("index"), 62 | row: newRow, 63 | replace: true 64 | }) 65 | } else { 66 | $('#assessmentsTable').bootstrapTable('append', [newRow]) 67 | } 68 | 69 | $('#newAssessmentModal').modal('hide') 70 | $('#newAssessmentForm').trigger('reset') 71 | }) 72 | }); 73 | 74 | // Submit entire testcase and AJAX add new row 75 | $("#importAssessmentForm").submit(function(e){ 76 | e.preventDefault(); 77 | 78 | fetch(e.target.action, { 79 | method: 'POST', 80 | body: new FormData(e.target) 81 | }).then((response) => { 82 | return response.json(); 83 | }).then((body) => { 84 | newRow = { 85 | id: body.id, 86 | name: body.name, 87 | description: body.description, 88 | progress: body.progress, 89 | actions: "" 90 | } 91 | $('#assessmentsTable').bootstrapTable('append', [newRow]) 92 | $('#importAssessmentModal').modal('hide') 93 | $('#importAssessmentForm').trigger('reset') 94 | }) 95 | }); 96 | 97 | // AJAX DELETE assessment call 98 | $('#deleteAssessmentButton').click(function() { 99 | $.ajax({ 100 | url: `/assessment/${rowData.id}`, 101 | type: 'DELETE', 102 | success: function(result) { 103 | $('#assessmentsTable').bootstrapTable('removeByUniqueId', rowData.id) 104 | $('#deleteAssessmentModal').modal('hide') 105 | } 106 | }); 107 | }); 108 | 109 | function nameFormatter(name, row) { 110 | return `${name}` 111 | } 112 | 113 | function progressFormatter(progress) { 114 | return ` 115 |
116 |
117 |
118 |
119 |
120 |
121 | ` 122 | } 123 | 124 | function actionFormatter() { 125 | return ` 126 |
127 | 130 | 133 |
134 | ` 135 | } -------------------------------------------------------------------------------- /static/scripts/testcase.js: -------------------------------------------------------------------------------- 1 | // When the new source/target etc. button is clicked, add a new row 2 | $('.multiNew').click(function(event) { 3 | type = event.target.id.replace("NewButton", "") // Hacky 4 | newRow = { 5 | id: `tmp-${Date.now()}`, // Rows need unique IDs, so give it the time 6 | name: "", 7 | delete: "" 8 | } 9 | type == "tags" ? newRow.colour = "" : newRow.description = "" 10 | $(`#${type}Table`).bootstrapTable("append", [newRow]) 11 | }) 12 | 13 | // When the source/target etc. table/modal is saved, post updates and refresh table 14 | $('.multiButton').click(function(event) { 15 | type = event.target.id.replace("multi", "").replace("Button", "").toLowerCase() + "s" // Hacky 16 | $.ajax({ 17 | url: `${$("#assessment-crumb-button").attr("href")}/multi/${type}`, 18 | type: 'POST', 19 | 20 | data: JSON.stringify({ 21 | data: $(`#${type}Table`).bootstrapTable("getData") 22 | }), 23 | dataType: 'json', 24 | contentType: "application/json; charset=utf-8", 25 | success: function(result) { 26 | // The model doesn't have a delete field but the table requires it 27 | result.map(row => row.delete = "") 28 | $(`#${type}Table`).bootstrapTable("load", result) 29 | $(event.target).closest(".modal").modal("hide") 30 | 31 | // Selectpicker plugin doesn't support populating a dropdown with 32 | // values and names seperately so we need to populate the HTML 33 | // manually and force it to refresh 34 | selectedIDs = $(`#${type}`).val() 35 | $(".dynopt-" + type).remove(); 36 | result.forEach(function(i) { 37 | selected = selectedIDs.includes(i.id) ? "selected" : "" 38 | pill = type == "tags" ? `data-content="${i.name}"` : "" 39 | $(`#${type}`).append(``); 40 | }) 41 | $(`#${type}`).selectpicker('destroy'); 42 | $(`#${type}`).selectpicker(); 43 | } 44 | }); 45 | }) 46 | 47 | // If "manage" is selected in a multi dropdown, remove the selection and pop manage modal 48 | $('.selectpicker').change(function(event) { 49 | type = event.target.id 50 | if ($(`#${type}`).val().includes("Manage")) { 51 | $(event.target).selectpicker('val', $(`#${type}`).val().filter(item => item !== "Manage")) 52 | $(event.target).selectpicker('toggle'); 53 | $(`#multi${type[0].toUpperCase() + type.slice(1, -1)}Modal`).modal('show') 54 | } 55 | }) 56 | 57 | // When source/target etc. names/descriptions are changed, update table value ready for POST 58 | $('.multiTable').on('change', '.multi', function(event) { 59 | $(event.delegateTarget).bootstrapTable("updateCellByUniqueId", { 60 | id: $(event.target).closest("tr").data("uniqueid"), 61 | field: event.target.name, 62 | value: event.target.value 63 | }) 64 | }); 65 | 66 | // When a source/target etc. is deleted, nuke the row from the table 67 | function deleteMultiRow(event) { 68 | tableId = $(event.target).closest("table")[0].id 69 | $(`#${tableId}`).bootstrapTable( 70 | "removeByUniqueId", 71 | $(event.target).closest("tr").data("uniqueid") 72 | ) 73 | } 74 | 75 | // Multi modal formatters 76 | function nameFormatter(val) { 77 | return `` 78 | } 79 | 80 | function descriptionFormatter(val) { 81 | return `` 82 | } 83 | 84 | function colourFormatter(val) { 85 | return `` 86 | } 87 | 88 | function deleteFormatter() { 89 | return ` 90 | 93 | ` 94 | } 95 | 96 | // Dynamic 18 | 19 | 20 | {{ macros.modalTail(name="newAssessment", cancelLabel="Cancel", actionLabel="Create") }} 21 | 22 | 23 | 24 | {{ macros.modalHead(name="deleteAssessment", title="Delete Assessment") }} 25 |
ERROR
26 | {{ macros.modalTail(name="deleteAssessment", cancelLabel="No", actionLabel="Delete") }} 27 | 28 | 29 |
30 | {{ macros.modalHead(name="importAssessment", title="Import Assessment") }} 31 |
32 |

Select the Assessment.zip file output from Export > Entire Assessment.

33 | 34 |
35 | {{ macros.modalTail(name="deleteAssessment", cancelLabel="Cancel", actionLabel="Import") }} 36 |
37 | -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "master.html" %} 2 | {% block content %} 3 | 4 | 23 |
24 | {{ macros.logo() }} 25 | {%- with messages = get_flashed_messages(with_categories=true) -%} 26 | {% if messages %} 27 | {% for category, message in messages %} 28 | 31 | {% endfor %} 32 | {% endif %} 33 | {%- endwith %} 34 |

Sign in

35 |
36 | {{ login_user_form.hidden_tag() }} 37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 | 47 | 48 |
49 | {{ macros.render_field_errors(login_user_form.email) }} 50 | {{ macros.render_field_errors(login_user_form.password) }} 51 | {{ macros.render_field_errors(login_user_form.csrf_token) }} 52 | 53 |
54 |
55 | 56 | {% endblock %} -------------------------------------------------------------------------------- /templates/macros.html: -------------------------------------------------------------------------------- 1 | {% macro modalHead(name, title, xl=False) -%} 2 | 23 | {%- endmacro %} 24 | 25 | {% macro toast(title) -%} 26 |
27 | 33 |
34 | {%- endmacro %} 35 | 36 | {% macro render_field_errors(field) -%} 37 |

38 | {{ field.label }} {{ field(**kwargs)|safe }} 39 | {% if field.errors %} 40 |

    41 | {% for error in field.errors %} 42 |
  • {{ error }}
  • 43 | {% endfor %} 44 |
45 | {% endif %} 46 |

47 | {%- endmacro %} 48 | 49 | {% macro render_field(field) -%} 50 |

{{ field(**kwargs)|safe }}

51 | {%- endmacro %} 52 | 53 | {% macro render_field_errors(field) -%} 54 |

55 | {% if field and field.errors %} 56 |

    57 | {% for error in field.errors %} 58 |
  • {{ error }}
  • 59 | {% endfor %} 60 |
61 | {% endif %} 62 |

63 | {%- endmacro %} 64 | 65 | {% macro logo() -%} 66 | 67 | {%- endmacro %} -------------------------------------------------------------------------------- /templates/master.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | PurpleOps 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | {% include 'master_modals.html' %} 75 | 76 | {% import "macros.html" as macros %} 77 | 78 | {% if current_user.initpwd and request.path not in ["/login", "/mfa/register", "/mfa/verify", "/password/change"] %} 79 | 82 | {% endif %} 83 | 84 | 107 | 108 | {% block content %}{% endblock %} 109 | 110 | -------------------------------------------------------------------------------- /templates/master_modals.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /templates/mfa_register.html: -------------------------------------------------------------------------------- 1 | {% extends "master.html" %} 2 | {% block content %} 3 | 4 | 25 |
26 | {{ macros.logo() }} 27 | 28 | {%- with messages = get_flashed_messages(with_categories=true) -%} 29 | {% if messages %} 30 | {% for category, message in messages %} 31 | 34 | {% endfor %} 35 | {% endif %} 36 | {%- endwith %} 37 | 38 |

Setup Multi-Factor Authentication (MFA)

39 |
40 | {{ two_factor_setup_form.hidden_tag() }} 41 | 42 | {{ macros.render_field_errors(two_factor_setup_form.setup) }} 43 | 44 | {% if chosen_method=="authenticator" and chosen_method in choices %} 45 |
46 |
47 |
48 | {{ _fsdomain("Open an authenticator app on your device and scan the following QRcode (or enter the code below manually) to start receiving codes:") }} 49 |
50 |
51 | {{ _fsdomain( 52 |
53 |
54 | {{ authr_key }} 55 |
56 |
57 | {% endif %} 58 |
59 | {% if chosen_method=="authenticator" and chosen_method in choices %} 60 |
61 |
63 | {{ two_factor_verify_code_form.hidden_tag() }} 64 |
65 | 66 | 67 |
68 | 69 | {{ macros.render_field_errors(two_factor_verify_code_form.code) }} 70 |
71 | {% endif %} 72 | 73 |
74 | {% endblock %} -------------------------------------------------------------------------------- /templates/mfa_verify.html: -------------------------------------------------------------------------------- 1 | {% extends "master.html" %} 2 | {% block content %} 3 | 4 | 20 |
21 | {{ macros.logo() }} 22 | 23 | {%- with messages = get_flashed_messages(with_categories=true) -%} 24 | {% if messages %} 25 | {% for category, message in messages %} 26 | 29 | {% endfor %} 30 | {% endif %} 31 | {%- endwith %} 32 | 33 |

Multi-Factor Authentication

34 |
35 | {{ two_factor_verify_code_form.hidden_tag() }} 36 | {{ macros.render_field_errors(two_factor_verify_code_form.code) }} 37 |
38 | 39 | 40 |
41 | 42 |
43 |
44 | {% endblock %} -------------------------------------------------------------------------------- /templates/password_change.html: -------------------------------------------------------------------------------- 1 | {% extends "master.html" %} 2 | {% block content %} 3 | 4 | 20 |
21 | {{ macros.logo() }} 22 | {%- with messages = get_flashed_messages(with_categories=true) -%} 23 | {% if messages %} 24 | {% for category, message in messages %} 25 | 28 | {% endfor %} 29 | {% endif %} 30 | {%- endwith %} 31 |

Change Password

32 |
33 | {{ change_password_form.hidden_tag() }} 34 |
35 | 36 | 37 |
38 |
39 | 40 | 41 |
42 |
43 | 44 | 45 |
46 | {{ macros.render_field_errors(change_password_form.password) }} 47 | {{ macros.render_field_errors(change_password_form.new_password) }} 48 | {{ macros.render_field_errors(change_password_form.new_password_confirm) }} 49 | 50 |
51 |
52 | 53 | {% endblock %} -------------------------------------------------------------------------------- /templates/testcase.html: -------------------------------------------------------------------------------- 1 | {% extends "master.html" %} 2 | 3 | {% block navpill %} 4 | 5 |   6 | {{ assessment.name }} 7 | 8 | {% endblock %} 9 | 10 | {% block content %} 11 | {% include 'testcase_modals.html' %} 12 |
13 |
14 |
15 |
16 |
17 | TTP 18 | 23 | 24 | 27 |
28 |
29 |
30 |
31 | Tactic 32 | 39 |
40 |
41 |
42 |
43 | Status 44 | 45 |
46 |
47 | {% if not current_user.has_role("Spectator") %} 48 |
49 |
50 | 51 | 52 | 53 | 56 |
57 |
58 | {% endif %} 59 |
60 |
61 |
62 | 63 | {% include 'testcase_red.html' %} 64 | 65 | {% include 'testcase_blue.html' %} 66 |
67 |
68 |
69 | 70 | {% endblock %} -------------------------------------------------------------------------------- /templates/testcase_blue.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Prevented 5 |
6 | 7 |
8 |
9 | 11 | 12 |
13 |
14 | 16 | 17 |
18 |
19 | 21 | 22 |
23 |
24 | 26 | 27 |
28 | 29 |
30 |
31 | 32 |
33 | 40 |
41 |
42 |
43 | Priority 44 |
45 | 46 |
47 |
48 | 50 | 51 |
52 |
53 | 55 | 56 |
57 |
58 | 60 | 61 |
62 | 63 |
64 |
65 |
66 | 67 |
68 | 75 |
76 |
77 |
78 |
79 |
80 | Detected 81 |
82 | 83 |
84 |
85 | 87 | 88 |
89 |
90 | 92 | 93 |
94 | 95 |
96 |
97 | 98 |
99 | 105 |
106 |
107 |
108 | 109 |
110 |
111 | 113 | 114 |
115 |
116 | 118 | 119 |
120 | 121 |
122 |
123 |
124 | 125 |
126 | 132 |
133 |
134 |
135 | 136 | 137 | 138 |
139 |
140 |
141 | 143 | 144 | 145 | 155 |
156 |
157 | 159 | 160 | 161 | 171 |
172 |
173 |
174 |
175 |
176 | Notes 178 | 180 |
181 |
182 |
183 |
    184 |
  • 185 |
    186 | 187 | 188 |
    189 |
  • 190 | {% for file in testcase.bluefiles %} 191 |
  • 192 | 195 | 196 | 197 | 198 | {% if (file.name|lower).endswith('.png') or (file.name|lower).endswith('.jpg') or (file.name|lower).endswith('.jpeg') %} 199 | 200 | 201 | 202 | 203 | {% else %} 204 | {{ file.name }} 205 | {% endif %} 206 |
  • 207 | {% endfor %} 208 |
209 |
210 |
-------------------------------------------------------------------------------- /templates/testcase_modals.html: -------------------------------------------------------------------------------- 1 | 2 | {{ macros.toast(title="Testcase Saved") }} 3 | 4 | {% macro multiContent(type, description="description") -%} 5 |
6 |
7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% for multi in multi[type] %} 20 | 21 | 22 | 23 | 24 | 25 | 26 | {% endfor %} 27 | 28 |
IDName{{ description | capitalize }}Delete
{{ multi.id }}{{ multi.name }}{{ multi[description] }}
29 |
30 | {%- endmacro %} 31 | 32 | {% for i in ["Source", "Target", "Tool", "Control", "Tag"] %} 33 | {{ macros.modalHead(name="multi" ~ i, title="Manage " ~ i ~ "s", xl=True) }} 34 | {% if i != "Tag" %} 35 | {{ multiContent(type=i | lower ~ "s") }} 36 | {% else %} 37 | {{ multiContent(type=i | lower ~ "s", description="colour") }} 38 | {% endif %} 39 | {{ macros.modalTail(name="multi" ~ i, cancelLabel="Cancel", actionLabel="Save", buttonClass="multiButton") }} 40 | {% endfor %} 41 | 42 | {{ macros.modalHead(name="ttpInfo", title=testcase.mitreid ~ " TTP Information", xl=True) }} 43 |

Description

44 |
{{ kb.overview | safe }}
45 |

Recommendations

46 |
{{ kb.advice }}
47 | {% if sigmas %} 48 |

SIGMA Rules

49 |
    50 | {% for sigma in sigmas %} 51 |
  • {{ sigma.name }}
    {{ sigma.description }}
  • 52 | {% endfor %} 53 |
54 | {% endif %} 55 |

References

56 | 59 | {{ macros.modalTail(name="ttpInfo", cancelLabel="Close") }} -------------------------------------------------------------------------------- /templates/testcase_red.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 7 |
8 |
9 |
10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 | 20 | 21 | 22 | 23 |
24 |
25 |
26 |
27 | 34 | {% if current_user.has_role("Admin") or current_user.has_role("Red") %} 35 | 36 | 37 | {% endif %} 38 |
39 |
40 |
41 |
42 |
43 |
44 | 46 | 47 | 48 | 58 |
59 |
60 |
61 |
62 | 64 | 65 | 66 | 76 |
77 |
78 |
79 |
80 | 82 | 83 | 84 | 94 |
95 |
96 |
97 |
98 |
99 |
100 | Objective 102 | 104 |
105 |
106 | Actions 108 | 110 |
111 |
112 | Notes 114 | 116 |
117 |
118 | UUID 120 | 122 |
123 |
124 | 125 |
126 |
127 |
128 |
    129 | {% if not current_user.has_role("Blue") %} 130 |
  • 131 |
    132 | 134 | 135 |
    136 |
  • 137 | {% endif %} 138 | {% for file in testcase.redfiles %} 139 |
  • 140 | {% if not current_user.has_role("Blue") %} 141 | 144 | {% endif %} 145 | 146 | 147 | 148 | {% if (file.name|lower).endswith('.png') or (file.name|lower).endswith('.jpg') or (file.name|lower).endswith('.jpeg') %} 149 | 150 | 151 | 152 | 153 | {% else %} 154 | {{ file.name }} 155 | {% endif %} 156 |
  • 157 | {% endfor %} 158 |
159 |
160 |
161 |
162 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from model import TestCase 3 | from flask_security import current_user 4 | from functools import wraps 5 | 6 | def applyFormData (obj, form, fields): 7 | for field in fields: 8 | if field in form: # and form[field]: 9 | obj[field] = form[field] 10 | return obj 11 | 12 | def applyFormListData (obj, form, fields): 13 | for field in fields: 14 | if field in form: # and form[field]: 15 | obj[field] = form.getlist(field) 16 | return obj 17 | 18 | def applyFormBoolData (obj, form, fields): 19 | for field in fields: 20 | if field in form: # and form[field]: 21 | obj[field] = form[field].lower() in ["true", "yes", "on"] 22 | return obj 23 | 24 | def applyFormTimeData (obj, form, fields): 25 | for field in fields: 26 | if field in form: # and form[field]: 27 | if form[field] and form[field] != "None": 28 | localTime = datetime.strptime(form[field], "%Y-%m-%dT%H:%M") 29 | utcTime = localTime + timedelta(minutes=int(form["timezone"])) 30 | obj[field] = utcTime 31 | else: 32 | obj[field] = None 33 | return obj 34 | 35 | def user_assigned_assessment(f): 36 | @wraps(f) 37 | def inner(*args, **kwargs): 38 | if current_user.has_role("Admin"): 39 | return f(*args, **kwargs) 40 | id = kwargs.get("id") 41 | if not id: 42 | id = args[0] 43 | if TestCase.objects(id=id).count(): 44 | id = TestCase.objects(id=id).first().assessmentid 45 | if (id in [str(a.id) for a in current_user.assessments]): 46 | return f(*args, **kwargs) 47 | else: 48 | return ("", 403) 49 | return inner --------------------------------------------------------------------------------