Skip to content
This repository was archived by the owner on May 4, 2026. It is now read-only.

Commit 23b39b2

Browse files
authored
Merge pull request #33 from CompassSecurity/improvements
Improvements
2 parents fd149df + 1079ea3 commit 23b39b2

65 files changed

Lines changed: 1833 additions & 2584 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ dmypy.json
135135
*.key
136136
files/
137137
custom/testcases
138+
custom/testcaseskb
138139

139140
supervisord.pid
140141
sampledata/sigma/*
141142
external/*
142-
INITIAL_ADMIN_PASSWORD.TXT
143+
INITIAL_ADMIN_PASSWORD.TXT

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# pull official base image
2-
FROM python:3.11.3-slim-buster
2+
FROM python:3.11.13-slim
33

44
# set work directory
55
WORKDIR /usr/src/app
@@ -9,7 +9,7 @@ ENV PYTHONDONTWRITEBYTECODE 1
99
ENV PYTHONUNBUFFERED 1
1010

1111
# install system dependencies
12-
RUN apt-get update && apt-get install -y netcat git
12+
RUN apt-get update && apt-get install -y netcat-openbsd git
1313

1414
# install dependencies
1515
RUN pip install --upgrade pip

README.md

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,6 @@
1313
<a href="https://docs.purpleops.app"><img src="https://img.shields.io/badge/Docs-blue?logo=readthedocs&logoColor=white">
1414
</p>
1515

16-
<p align="center">
17-
<a href="#key-features">Key Features</a> •
18-
<a href="#installation">Installation</a> •
19-
<a href="#contact-us">Contact Us</a> •
20-
<a href="#credits">Credit</a> •
21-
<a href="#license">License</a>
22-
</p>
23-
2416
<p align="center">
2517
<img src="static/images/demo.gif">
2618
</p>
@@ -133,15 +125,80 @@ $ sudo docker compose up
133125
```
134126
</details>
135127

136-
## Contact Us
128+
## Compass Security Fork
129+
The Compass Security fork includes fixes and new features!
137130

138-
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__`
131+
### Updated Dependencies
132+
The Python dependencies (e.g. Flask) were updated to the latest versions.
139133

140-
## Credits
134+
### Restructured Test Case Form and Flow-Based Approach
135+
We have redesigned the test case form to prioritise the elements that we believe are important during a purple team engagement.
136+
<br> Is there anything missing? Please let us know — we are eager to hear how other analysts approach Purple Teaming engagements.
137+
138+
<br>Moreover, we have implemented a flow-based approach to facilitate collaboration with the Blue Team.
139+
<img width="2061" height="612" alt="image" src="https://github.com/user-attachments/assets/60e1d78d-cb73-4c10-ae16-9b8f296e59a1" />
140+
141+
#### Waiting Blue:
142+
This signals to the blue team that input is expected from their side. Once the required information has been added, the Blue team can set the state to 'Waiting Red'.
143+
Users with the 'Blue' role can only edit a test case if it is in the 'Waiting Blue' or 'Waiting Red' state.
144+
145+
#### Waiting Red:
146+
This signals to the red team that the blue team has finished adding their details to the test case. The red team can then check that all the required information is present. If so, the state can be changed to 'Complete'.
147+
148+
#### Complete:
149+
The blue team cannot make any more changes to the test case.
150+
151+
152+
### Pytests
153+
We have created pytests for each route. This makes it easy to check whether the application has been affected by any changes made to it.
154+
<br><br>Note: We are still missing security checks (e.g. RBAC) and application logic checks, so if you would like to contribute, we would be glad to merge your pull request!
155+
156+
### Dark Mode
157+
Enjoy PurpleOps in dark mode. To enable this, go to the settings menu.
158+
<img width="2063" height="1041" alt="darkmode_overview" src="https://github.com/user-attachments/assets/1b2870a6-319a-4ca6-8366-f5a8e638842e" />
159+
<img width="2066" height="1120" alt="darkmode_testcase" src="https://github.com/user-attachments/assets/056c721a-2a00-473f-8cbe-39537a843491" />
160+
161+
### Test Case History
162+
The Test Case History allows you to view previous saved versions of the test case. This feature is only available after an initial save, not after an import. Please note that evidence files are not stored.
163+
![test_case_history](https://github.com/user-attachments/assets/b15c3799-a73b-4845-9ec3-fe9f04f3b7a4)
164+
165+
### Restore Deleted Test Cases
166+
You can now restore deleted test cases (requires page reload).
167+
![test_case_restore](https://github.com/user-attachments/assets/a66ba8b0-854f-436b-9602-9c2a244e3ded)
168+
169+
### Test Case Knowlege Base and Variables File
170+
We added the option to add an knowledge base MD file for each TPP. You can find an example here:
171+
https://github.com/CompassSecurity/PurpleOps/blob/main/custom/testcaseskb/T1087_002.md
141172

173+
To view the KB click on the "compass" icon in the test case:
174+
![test_case_kb](https://github.com/user-attachments/assets/f06ed191-2812-4259-9317-ed6d865a729e)
175+
176+
The KB also enables you to set placeholders for frequently used strings. For instance, you could define {{TARGET_DOMAIN_USER}} as a placeholder in an MD file for a command.
177+
```
178+
net user {{TARGET_DOMAIN_USER}} /domain
179+
```
180+
Define a JSON file which contains all your placeholders and the coresponding text:
181+
```
182+
{
183+
"DOMAIN_NAME" : "testlab.local",
184+
"LOWPRIVILEGED_DOMAIN_USER" : "tmassie",
185+
"TARGET_DOMAIN_USER" : "administrator",
186+
"DC_IP" : "10.0.1.10"
187+
}
188+
```
189+
Upload the JSON file to PurpleOps using your browser. The values will be stored in your session storage (cleared after browser is closed). Use the toggle in the test case KB to replace the placeholders with real data.
190+
![test_case_kb_variables](https://github.com/user-attachments/assets/547f4a21-a043-47a9-8be4-c9a176a23029)
191+
192+
193+
## Credits
194+
- PurpleOps https://github.com/CyberCX-STA/PurpleOps
142195
- Atomic Red Team [(LICENSE)](https://github.com/redcanaryco/atomic-red-team/blob/master/LICENSE.txt) for sample commands
143196
- [CyberCX](https://cybercx.com.au/) for foundational support
144197

145198
## License
146-
147199
Apache
200+
201+
202+
203+
204+

blueprints/access.py

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,18 @@ def users():
2727
@auth_required()
2828
@roles_accepted('Admin')
2929
def createuser():
30+
31+
email = request.form['email']
32+
username = request.form['username']
33+
34+
if user_datastore.find_user(email=email):
35+
return jsonify({'error': 'Email already exists'}), 400
36+
if user_datastore.find_user(username=username):
37+
return jsonify({'error': 'Username already exists'}), 400
38+
3039
user = user_datastore.create_user(
31-
email = request.form['email'],
32-
username = request.form['username'],
40+
email = email,
41+
username = username,
3342
password = utils.hash_password(request.form['password']),
3443
roles = [Role.objects(name=role).first() for role in request.form.getlist('roles')],
3544
assessments = [Assessment.objects(name=assessment).first() for assessment in request.form.getlist('assessments')]
@@ -40,37 +49,55 @@ def createuser():
4049
@auth_required()
4150
@roles_accepted('Admin')
4251
def edituser(id):
43-
origUser = User.objects(id=id).first()
4452
user = User.objects(id=id).first()
53+
if not user:
54+
return jsonify({'error': 'User not found'}), 404
55+
4556
if request.method == 'POST':
57+
new_email = request.form.get('email')
58+
new_username = request.form.get('username')
59+
60+
# Check for email conflict
61+
if new_email and User.objects(email=new_email, id__ne=id).first():
62+
return jsonify({'error': 'Email already in use'}), 400
63+
64+
# Check for username conflict
65+
if new_username and User.objects(username=new_username, id__ne=id).first():
66+
return jsonify({'error': 'Username already in use'}), 400
67+
4668
if "password" in request.form and request.form['password'].strip():
4769
user.password = utils.hash_password(request.form['password'])
4870

4971
user = applyFormData(user, request.form, ["username", "email"])
50-
# You cannot rename the inbuilt admin account
51-
if origUser.username == "admin" and user.username != "admin":
72+
73+
# Prevent renaming built-in admin
74+
if user.username != "admin" and User.objects(id=id).first().username == "admin":
5275
user.username = "admin"
5376

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]:
77+
# Update roles
78+
user.roles = [
79+
Role.objects(name=role).first()
80+
for role in request.form.getlist('roles')
81+
]
82+
83+
# Ensure admin keeps the Admin role
84+
if user.username == "admin" and "Admin" not in [r.name for r in user.roles]:
5985
user.roles.append(Role.objects(name="Admin").first())
6086

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]:
87+
# Update assessments
88+
user.assessments = [
89+
Assessment.objects(name=assessment).first()
90+
for assessment in request.form.getlist('assessments')
91+
]
92+
93+
# Admins get implicit access to all assessments
94+
if "Admin" in [r.name for r in user.roles]:
6695
user.assessments = []
6796

6897
user.save()
6998
return jsonify(user.to_json()), 200
70-
99+
71100
if request.method == 'DELETE':
72-
# Prevent inbuilt admin deletion
73101
if user.username != "admin":
74102
user.delete()
75-
user.save()
76103
return "", 200

blueprints/assessment.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ def deleteassessment(id):
5151
def loadassessment(id):
5252
return render_template(
5353
'assessment.html',
54-
testcases = TestCase.objects(assessmentid=id).all(),
54+
testcases = TestCase.objects(assessmentid=id, deleted=False).all(),
55+
deleted_testcases = TestCase.objects(assessmentid=id, deleted=True).all(),
5556
assessment = Assessment.objects(id=id).first(),
5657
templates = TestCaseTemplate.objects(),
5758
mitres = sorted(

blueprints/assessment_export.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717
@user_assigned_assessment
1818
def exportassessment(id, filetype):
1919
if filetype not in ["json", 'csv']:
20-
return 401
20+
return "", 401
2121

2222
assessment = Assessment.objects(id=id).first()
2323
if current_user.has_role("Blue"):
24-
testcases = TestCase.objects(assessmentid=str(assessment.id), visible=True).all()
24+
testcases = TestCase.objects(assessmentid=str(assessment.id), visible=True, deleted=False).all()
2525
else:
26-
testcases = TestCase.objects(assessmentid=str(assessment.id)).all()
26+
testcases = TestCase.objects(assessmentid=str(assessment.id), deleted=False).all()
2727

2828
jsonDict = []
2929
for testcase in testcases:
@@ -57,9 +57,9 @@ def exportassessment(id, filetype):
5757
def exportcampaign(id):
5858
assessment = Assessment.objects(id=id).first()
5959
if current_user.has_role("Blue"):
60-
testcases = TestCase.objects(assessmentid=str(assessment.id), visible=True).all()
60+
testcases = TestCase.objects(assessmentid=str(assessment.id), visible=True, deleted=False).all()
6161
else:
62-
testcases = TestCase.objects(assessmentid=str(assessment.id)).all()
62+
testcases = TestCase.objects(assessmentid=str(assessment.id), deleted=False).all()
6363

6464
jsonDict = []
6565
for testcase in testcases:
@@ -156,9 +156,9 @@ def exportnavigator(id):
156156
}
157157

158158
if current_user.has_role("Blue"):
159-
testcases = TestCase.objects(assessmentid=id, visible=True).all()
159+
testcases = TestCase.objects(assessmentid=id, visible=True, deleted=False).all()
160160
else:
161-
testcases = TestCase.objects(assessmentid=id).all()
161+
testcases = TestCase.objects(assessmentid=id, deleted=False).all()
162162

163163
results = {}
164164
for testcase in testcases:
@@ -214,7 +214,7 @@ def exportentire(id):
214214
else:
215215
# If they're blue then they can only export the evidence files of visible testcases
216216
shutil.copytree(f"files/{id}", f"files/tmp{id}")
217-
testcases = TestCase.objects(assessmentid=str(assessment.id)).all()
217+
testcases = TestCase.objects(assessmentid=str(assessment.id), deleted=False).all()
218218
for testcase in testcases:
219219
if not testcase.visible and os.path.isdir(f"files/tmp{id}/{str(testcase.id)}"):
220220
shutil.rmtree(f"files/tmp{id}/{str(testcase.id)}")

blueprints/assessment_utils.py

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,7 @@ def assessmentmulti(id, field):
5656
def assessmentnavigator(id):
5757
assessment = Assessment.objects(id=id).first()
5858

59-
# Create and store one-time secret; timestamp and ip for later comparison in
60-
# the unauthed `thisurl`.json endpoint
59+
# Create and store one-time secret; timestamp and ip for later comparison in the unauthed navigator.json endpoint
6160
secret = secrets.token_urlsafe()
6261
assessment.navigatorexport = f"{int(time())}|{request.remote_addr}|{secret}"
6362
assessment.save()
@@ -69,22 +68,33 @@ def assessmentnavigator(id):
6968
@blueprint_assessment_utils.route('/assessment/<id>/navigator.json', methods = ['GET'])
7069
def assessmentnavigatorjson(id):
7170
assessment = Assessment.objects(id=id).first()
72-
timestamp, ip, secret = assessment.navigatorexport.split("|")
73-
74-
# This endpoint is unauthed so that we can embed the ATT&CK Navigator and
75-
# allow it to fetch a layer.json on behalf of the user. To mitigate security issues
76-
# the endpoint needs to be hit
77-
# 1. Within 10 seconds of hitting the authed endpoint /assessment/<id>/navigator
78-
# 2. With the same IP used to his the above authed endpoint
79-
# 3. With a one-time secret key returned in the above authed endpoint
80-
# 4. From the mitre-attack origin (yes this is spoofable, but why not)
81-
# if (int(time()) - int(timestamp) <= 30 and
82-
#request.remote_addr == ip and
83-
# request.args.get("secret") == secret): # and
84-
#request.origin == "https://mitre-attack.github.io"):
85-
response = make_response(send_from_directory('files', f"{id}/navigator.json"))
86-
response.headers.add('Access-Control-Allow-Origin', '*')
87-
return response
71+
if not assessment or not assessment.navigatorexport:
72+
return "", 401
73+
74+
try:
75+
timestamp, ip, secret = assessment.navigatorexport.split("|")
76+
except ValueError:
77+
return "", 401
78+
79+
request_ip = request.remote_addr
80+
request_secret = request.args.get("secret", type=str)
81+
request_origin = request.headers.get("Origin")
82+
83+
# Validate conditions
84+
if (
85+
int(time()) - int(timestamp) <= 10 and
86+
request_ip == ip and
87+
request_secret == secret and
88+
request_origin == "https://mitre-attack.github.io"
89+
):
90+
response = make_response(send_from_directory('files', f"{id}/navigator.json"))
91+
response.headers.add('Access-Control-Allow-Origin', '*')
92+
93+
# Clear the one-time secret to prevent reuse
94+
assessment.navigatorexport = None
95+
assessment.save()
96+
97+
return response
8898

8999
return "", 401
90100

@@ -94,9 +104,9 @@ def assessmentnavigatorjson(id):
94104
def assessmentstats(id):
95105
assessment = Assessment.objects(id=id).first()
96106
if current_user.has_role("Blue"):
97-
testcases = TestCase.objects(assessmentid=str(assessment.id), visible=True).all()
107+
testcases = TestCase.objects(assessmentid=str(assessment.id), visible=True, deleted=False).all()
98108
else:
99-
testcases = TestCase.objects(assessmentid=str(assessment.id)).all()
109+
testcases = TestCase.objects(assessmentid=str(assessment.id), deleted=False).all()
100110

101111
# Initalise metrics that are captured
102112
stats = {
@@ -169,20 +179,20 @@ def assessmenthexagons(id):
169179
shownHexs = []
170180
hiddenHexs = []
171181
for i in range(len(tactics)):
172-
if not TestCase.objects(assessmentid=id, tactic=tactics[i], state="Complete").count():
182+
if not TestCase.objects(assessmentid=id, tactic=tactics[i], state="Complete", deleted=False).count():
173183
hiddenHexs.append({
174184
"display": "none",
175-
"stroke": "#ffffff",
176-
"fill": "#ffffff",
177-
"arrow": "rgba(0, 0, 0, 0)",
185+
"stroke": "none",
186+
"fill": "none",
187+
"arrow": "none",
178188
"text": ""
179189
})
180190
continue
181191

182192
cumulatedscore = 0
183193
count = 0
184194

185-
for testcase in TestCase.objects(assessmentid=id, tactic=tactics[i]):
195+
for testcase in TestCase.objects(assessmentid=id, tactic=tactics[i], deleted=False):
186196
if testcase.testcasescore is not None:
187197
cumulatedscore += testcase.testcasescore
188198
count += 1
@@ -201,15 +211,15 @@ def assessmenthexagons(id):
201211
"display": "block",
202212
"stroke": color,
203213
"fill": "#eeeeee",
204-
"arrow": "rgb(0, 0, 0)",
214+
"arrow": "#593196",
205215
"text": tactics[i]
206216
})
207217
else:
208218
hiddenHexs.append({
209219
"display": "none",
210-
"stroke": "#ffffff",
211-
"fill": "#ffffff",
212-
"arrow": "rgba(0, 0, 0, 0)",
220+
"stroke": "none",
221+
"fill": "none",
222+
"arrow": "none",
213223
"text": ""
214224
})
215225

0 commit comments

Comments
 (0)