Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions app/controllers/crackme.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
)
from app.models.solve import (
solve_by_user_and_crackme, solve_create, count_solves_by_crackme,
claim_first_blood_notification,
solves_by_crackme
)
from app.models.flag_submission import (
Expand All @@ -38,7 +39,7 @@
is_unsupported_archive, is_zip_archive,
)
from app.services.discord import (
notify_flag_solved, notify_flag_submission, notify_new_crackme
notify_first_blood, notify_new_crackme
)
from app.services.flag import (
FLAG_FORMAT_HINT, flags_match, is_valid_flag_format, normalize_flag
Expand Down Expand Up @@ -489,13 +490,6 @@ def log_result(result, user_hexid=None):
except Exception as e:
# Audit logging must not turn a validation response into a 500.
print(f"Error logging flag submission: {e}")
try:
notify_flag_submission(
username, crackme.get('name', ''), hexid, flag, result
)
except Exception as e:
# Discord must never affect validation or its database audit trail.
print(f"Discord flag-audit notification error: {e}")

try:
crackme = crackme_by_hexid(hexid)
Expand Down Expand Up @@ -544,27 +538,33 @@ def log_result(result, user_hexid=None):

points = points_for_solve(crackme)
try:
solve_create(user_hexid, hexid, points, solve_difficulty(crackme))
solve = solve_create(user_hexid, hexid, points, solve_difficulty(crackme))
except Exception as e:
print(f"Error recording solve: {e}")
abort(500)

log_result('correct', user_hexid)

try:
notify_flag_solved(
username, crackme.get('name', ''), hexid, points
)
first_blood = claim_first_blood_notification(hexid, solve['hexid'])
except Exception as e:
print(f"Discord solve notification error: {e}")
print(f"First-blood claim error: {e}")
first_blood = False

try:
notification_add(
username,
f"Correct flag for '<a href=\"/crackme/{hexid}\">{html_escape(crackme.get('name', ''))}</a>' - {points} points earned!"
)
except Exception as e:
print(f"Notification error: {e}")
if first_blood:
try:
notification_add(
crackme['author'],
f"Your crackme '<a href=\"/crackme/{hexid}\">{html_escape(crackme.get('name', ''))}</a>' "
f"was solved for the first time by {html_escape(username)}!"
)
except Exception as e:
print(f"First-blood author notification error: {e}")

try:
notify_first_blood(username, crackme.get('name', ''), hexid, points)
except Exception as e:
print(f"Discord first-blood notification error: {e}")

flash(f'Correct! You earned {points} points.', FLASH_SUCCESS)
return redirect(f'/crackme/{hexid}')
Expand Down
17 changes: 17 additions & 0 deletions app/models/solve.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,23 @@ def count_solves_by_crackme(crackme_hexid):
)


def claim_first_blood_notification(crackme_hexid, solve_hexid):
"""Claim the announcement once, and only for the earliest recorded solve."""
if not check_connection():
raise ErrUnavailable("Database is unavailable")

first = get_collection('solve').find_one(
{'crackme_hexid': crackme_hexid}, sort=[('_id', 1)]
)
if not first or first['hexid'] != solve_hexid:
return False

return get_collection('crackme').find_one_and_update(
{'hexid': crackme_hexid, 'first_blood_notified': {'$ne': True}},
{'$set': {'first_blood_notified': True}},
) is not None


def solves_by_crackme(crackme_hexid):
"""Return a crackme's solves, oldest first."""
if not check_connection():
Expand Down
46 changes: 4 additions & 42 deletions app/services/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,9 @@ def notify_new_solution(username: str, crackme_name: str) -> bool:
return send_private_notification(embed=embed)


def notify_flag_solved(username: str, crackme_name: str,
def notify_first_blood(username: str, crackme_name: str,
crackme_hexid: str, points: int) -> bool:
"""Announce a successful auto-validated solve in the public channel."""
"""Announce the first successful solve in the public channel."""
timestamp = (
datetime.datetime.utcnow()
.replace(tzinfo=timezone.utc)
Expand All @@ -203,8 +203,8 @@ def notify_flag_solved(username: str, crackme_name: str,
)
base_url = get_base_url()
embed = {
"title": "Crackme Solved",
"description": "A player submitted the correct flag",
"title": "First Blood",
"description": "The first player solved this challenge",
"color": 65280,
"fields": [
{
Expand All @@ -227,44 +227,6 @@ def notify_flag_solved(username: str, crackme_name: str,
return send_to_webhook(get_public_webhook(), embed=embed)


def notify_flag_submission(username: str, crackme_name: str,
crackme_hexid: str, submitted_flag: str,
result: str) -> bool:
"""Send every flag attempt, including its value, to the audit channel."""
timestamp = (
datetime.datetime.utcnow()
.replace(tzinfo=timezone.utc)
.isoformat(timespec='milliseconds')
.replace('+00:00', 'Z')
)
base_url = get_base_url()
# Discord limits an embed field to 1,024 characters. The database remains
# the canonical, untruncated audit record.
displayed_flag = submitted_flag[:1021] + "..." if len(submitted_flag) > 1024 else submitted_flag
embed = {
"title": "Flag Submitted",
"description": "Auto-validation attempt recorded",
"color": 65280 if result == 'correct' else 16744448,
"fields": [
{
"name": "Challenge",
"value": f"[{crackme_name}]({base_url}/crackme/{crackme_hexid})",
"inline": True,
},
{
"name": "User",
"value": f"[{username}]({base_url}/user/{username})",
"inline": True,
},
{"name": "Result", "value": result, "inline": True},
{"name": "Submitted flag", "value": displayed_flag or "(empty)", "inline": False},
],
"footer": {"text": "CrackMes.One Internal Audit"},
"timestamp": timestamp,
}
return send_private_notification(embed=embed)


def send_moderation_notification(embed: dict) -> bool:
"""Send a notification to the moderation Discord channel.

Expand Down
16 changes: 3 additions & 13 deletions tests/test_services_and_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def test_pending_discord_notifications_use_private_channel(app):
discord.init_discord(app, {'Enabled': False})


def test_flag_discord_notifications_use_public_and_audit_channels(app):
def test_first_blood_discord_notification_uses_public_channel(app):
from app.services import discord

discord.init_discord(app, {
Expand All @@ -71,23 +71,13 @@ def test_flag_discord_notifications_use_public_and_audit_channels(app):
'WebhookPrivate': 'https://discord.test/private',
})
with patch.object(discord, 'send_to_webhook', return_value=True) as send:
assert discord.notify_flag_solved('bob', 'Challenge', 'abc', 300) is True
assert discord.notify_first_blood('bob', 'Challenge', 'abc', 300) is True
assert send.call_args.args[0].endswith('/public')
public_embed = send.call_args.kwargs['embed']
assert public_embed['title'] == 'Crackme Solved'
assert public_embed['title'] == 'First Blood'
assert public_embed['fields'][0]['value'] == '[Challenge](http://localhost/crackme/abc)'
assert public_embed['fields'][1]['value'] == '[bob](http://localhost/user/bob)'

with patch.object(discord, 'send_private_notification', return_value=True) as send:
assert discord.notify_flag_submission(
'bob', 'Challenge', 'abc', 'CMO{attempt}', 'incorrect'
) is True
audit_embed = send.call_args.kwargs['embed']
assert audit_embed['title'] == 'Flag Submitted'
assert audit_embed['fields'][0]['value'] == '[Challenge](http://localhost/crackme/abc)'
assert audit_embed['fields'][1]['value'] == '[bob](http://localhost/user/bob)'
assert audit_embed['fields'][2]['value'] == 'incorrect'
assert audit_embed['fields'][3]['value'] == 'CMO{attempt}'
discord.init_discord(app, {'Enabled': False})


Expand Down
56 changes: 36 additions & 20 deletions tests/test_solves.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,9 @@ def test_auto_validation_points_default_to_selected_difficulty(
def test_correct_flag_records_a_solve_and_awards_points(
bob_client, db, bob, flagged_crackme, monkeypatch):
from app.controllers import crackme as crackme_controller
audit_notifications = []
solve_notifications = []
monkeypatch.setattr(
crackme_controller, 'notify_flag_submission',
lambda *args: audit_notifications.append(args),
)
monkeypatch.setattr(
crackme_controller, 'notify_flag_solved',
crackme_controller, 'notify_first_blood',
lambda *args: solve_notifications.append(args),
)
response = bob_client.post(f"/crackme/{flagged_crackme['hexid']}/solve",
Expand All @@ -223,21 +218,51 @@ def test_correct_flag_records_a_solve_and_awards_points(
assert solve['crackme_hexid'] == flagged_crackme['hexid']
assert solve['points'] == 300
assert solve['difficulty'] == 3
assert db.notifications.count_documents({'user': 'bob'}) == 1
assert db.notifications.count_documents({'user': 'bob'}) == 0
author_notification = db.notifications.find_one({'user': 'alice'})
assert author_notification['seen'] is False
assert 'was solved for the first time by bob' in author_notification['text']
submission = db.flag_submission.find_one({})
assert submission['user_hexid'] == _hexid(bob)
assert submission['username'] == 'bob'
assert submission['crackme_hexid'] == flagged_crackme['hexid']
assert submission['submitted_flag'] == FLAG
assert submission['result'] == 'correct'
assert audit_notifications == [(
'bob', flagged_crackme['name'], flagged_crackme['hexid'], FLAG, 'correct'
)]
assert solve_notifications == [(
'bob', flagged_crackme['name'], flagged_crackme['hexid'], 300
)]


def test_only_first_solver_gets_public_announcement(
app, bob_client, db, bob, flagged_crackme, monkeypatch):
from app.controllers import crackme as crackme_controller

announcements = []
monkeypatch.setattr(
crackme_controller, 'notify_first_blood',
lambda *args: announcements.append(args),
)
path = f"/crackme/{flagged_crackme['hexid']}/solve"
bob_client.post(path, data={'flag': FLAG})

db.user.insert_one({'name': 'charlie', 'email': 'charlie@example.test'})
charlie_client = app.test_client()
with charlie_client.session_transaction() as session:
session['name'] = 'charlie'
session['email'] = 'charlie@example.test'
charlie_client.post(path, data={'flag': FLAG})
bob_client.post(path, data={'flag': FLAG})

assert db.solve.count_documents({'crackme_hexid': flagged_crackme['hexid']}) == 2
assert len(announcements) == 1
assert announcements[0][0] == 'bob'
assert db.notifications.count_documents({'user': 'alice'}) == 1
assert db.user.find_one({'name': 'alice'})['unread_notifications'] == 1
assert db.crackme.find_one({'hexid': flagged_crackme['hexid']})[
'first_blood_notified'
] is True


def test_solve_snapshots_fractional_difficulty(
bob_client, db, bob, flagged_crackme):
db.crackme.update_one(
Expand All @@ -257,14 +282,9 @@ def test_solve_snapshots_fractional_difficulty(
def test_wrong_flag_records_nothing(
bob_client, db, bob, flagged_crackme, monkeypatch):
from app.controllers import crackme as crackme_controller
audit_notifications = []
solve_notifications = []
monkeypatch.setattr(
crackme_controller, 'notify_flag_submission',
lambda *args: audit_notifications.append(args),
)
monkeypatch.setattr(
crackme_controller, 'notify_flag_solved',
crackme_controller, 'notify_first_blood',
lambda *args: solve_notifications.append(args),
)
response = bob_client.post(f"/crackme/{flagged_crackme['hexid']}/solve",
Expand All @@ -276,10 +296,6 @@ def test_wrong_flag_records_nothing(
submission = db.flag_submission.find_one({})
assert submission['submitted_flag'] == 'CMO{nope}'
assert submission['result'] == 'incorrect'
assert audit_notifications == [(
'bob', flagged_crackme['name'], flagged_crackme['hexid'],
'CMO{nope}', 'incorrect'
)]
assert solve_notifications == []


Expand Down
Loading