diff --git a/app/controllers/crackme.py b/app/controllers/crackme.py index 3d447eb..bd5c774 100644 --- a/app/controllers/crackme.py +++ b/app/controllers/crackme.py @@ -17,10 +17,14 @@ from app.models.comment import comments_by_crackme from app.models.rating import rating_difficulty_create, rating_quality_create, rating_difficulty_delete_by_crackme from app.models.notification import notification_add +from app.models.tag_request import ( + tag_request_create, pending_tag_requests_by_user_and_crackme +) from app.models.errors import ErrNoResult from app.services.recaptcha import verify as verify_recaptcha from app.services.limiter import limit from app.services.view import FLASH_ERROR, FLASH_SUCCESS, validate_required +from app.services.tags import get_tag_groups, get_dataset_url, normalize_tags from app.services.archive import is_archive_password_protected, is_single_file_archive, is_unsupported_archive from app.services.discord import notify_new_crackme from app.controllers.decorators import login_required @@ -80,6 +84,9 @@ def crackme_view(hexid): difficulty=f"{crackme.get('difficulty', 0):.1f}", quality=f"{crackme.get('quality', 0):.1f}", size=crackme.get('size', 0), + tags=crackme.get('tags', []), + tag_groups=get_tag_groups(), + tags_dataset_url=get_dataset_url(), usersess=usersess) @@ -134,7 +141,7 @@ def download_crackme(hexid): @login_required def upload_crackme_get(): """Display the crackme upload form.""" - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) @crackme_bp.route('/upload/crackme', methods=['POST']) @@ -149,7 +156,7 @@ def upload_crackme_post(): is_valid, missing = validate_required(request.form, required) if not is_valid: flash(f'Field missing: {missing}', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) name = bleach.clean(request.form.get('name', '')) info = bleach.clean(request.form.get('info', '')) @@ -157,6 +164,9 @@ def upload_crackme_post(): arch = bleach.clean(request.form.get('arch', '')) platform = request.form.get('platform', '') difficulty = request.form.get('difficulty', '') + # Keep only values from the controlled vocabulary. Tags are not mandatory + # (a crackme with no matching technique may legitimately have none). + tags = normalize_tags(request.form.getlist('tags')) # Validate difficulty try: @@ -165,22 +175,22 @@ def upload_crackme_post(): raise ValueError() except (ValueError, TypeError): flash('Wrong difficulty', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) # Validate reCAPTCHA if not verify_recaptcha(request): flash('reCAPTCHA invalid!', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) # Check for file if 'file' not in request.files: flash('Field missing: file', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) file = request.files['file'] if file.filename == '': flash('Field missing: file', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) # Read file data file_data = file.read() @@ -188,22 +198,22 @@ def upload_crackme_post(): # Check file size if len(file_data) > MAX_FILE_SIZE: flash('This file is too large!', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) # Check for unsupported archive formats (RAR, tar, etc.) if is_unsupported_archive(file_data): flash('RAR and tar archives are not supported. Please upload a ZIP file for multiple files, or upload single files directly.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) # Check for password protection if is_archive_password_protected(file_data): flash('Password-protected archives are not allowed. Do NOT add a password yourself - the server handles this automatically.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) # Check for single-file archives if is_single_file_archive(file_data): flash('Archives containing only one file are not allowed. Please upload the file directly without wrapping it in an archive.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) # Store the uploaded file size size = len(file_data) @@ -212,7 +222,7 @@ def upload_crackme_post(): try: crackme_by_user_and_name(username, name, visible=False) flash('You already have a pending crackme with this name. Please wait for review or choose a different name.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) except ErrNoResult: pass # No duplicate, continue @@ -221,7 +231,7 @@ def upload_crackme_post(): # Prepare crackme try: - crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename) + crackme = crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, tags=tags) except Exception as e: print(f"Error preparing crackme: {e}") abort(500) @@ -239,7 +249,7 @@ def upload_crackme_post(): except Exception as e: print(f"File write error: {e}") flash('Failed to save file. Please try again.', FLASH_ERROR) - return render_template('crackme/create.html') + return render_template('crackme/create.html', tag_groups=get_tag_groups()) # Insert crackme into database try: @@ -361,3 +371,53 @@ def edit_crackme_post(hexid): flash('No changes were made.', FLASH_SUCCESS) return redirect(f'/crackme/{hexid}') + + +@crackme_bp.route('/crackme//tags/request', methods=['POST']) +@login_required +@limit("20 per day", key_func=lambda: session.get('name')) +def request_tag_change(hexid): + """Submit a request to add and/or remove tags on a crackme. + + Requests are queued for reviewers; nothing changes on the crackme until a + reviewer approves. + """ + username = session.get('name') + + try: + crackme = crackme_by_hexid(hexid) + except ErrNoResult: + abort(404) + except Exception as e: + print(f"Error getting crackme: {e}") + abort(500) + + current = crackme.get('tags', []) + # The form submits the full desired set of applied tags; derive the add/remove + # sets by diffing against what the crackme currently carries. + desired = normalize_tags(request.form.getlist('applied')) + add = [t for t in desired if t not in current] + remove = [t for t in current if t not in desired] + note = bleach.clean(request.form.get('note', ''))[:500] + + if not add and not remove: + flash('No tag changes were selected.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + + # Prevent a user from piling up duplicate pending requests on one crackme. + try: + if pending_tag_requests_by_user_and_crackme(username, hexid) > 0: + flash('You already have a pending tag change request for this crackme.', FLASH_ERROR) + return redirect(f'/crackme/{hexid}') + except Exception as e: + print(f"Error checking pending tag requests: {e}") + + try: + tag_request_create(hexid, crackme.get('name', ''), username, + add=add, remove=remove, note=note) + except Exception as e: + print(f"Error creating tag request: {e}") + abort(500) + + flash('Tag change request submitted for review. Thank you!', FLASH_SUCCESS) + return redirect(f'/crackme/{hexid}') diff --git a/app/controllers/search.py b/app/controllers/search.py index 14f4f8a..eccf324 100644 --- a/app/controllers/search.py +++ b/app/controllers/search.py @@ -2,60 +2,79 @@ Search controller - Searching crackmes. """ -from flask import Blueprint, render_template, request, redirect, url_for +from flask import Blueprint, render_template, request from app.models.crackme import search_crackme, random_crackmes from app.models.errors import ErrUnavailable +from app.services.tags import get_tag_groups, normalize_tags search_bp = Blueprint('search', __name__) @search_bp.route('/search', methods=['GET']) def search_get(): - """Display the search page.""" - return render_template('search/search.html', crackmes=[], page=1, has_more=False, show_all=False, search_params={}) + """Display the search page. + + Searches are driven by URL query parameters so results are bookmarkable and + shareable (issue #162). A bare ``/search`` with no parameters shows the empty + form; any parameter (e.g. ``/search?tags=Packer``) runs a search. + """ + if not request.args: + return render_template('search/search.html', crackmes=[], page=1, has_more=False, + show_all=False, search_params={}, tag_groups=get_tag_groups()) + return _render_search(request.args) @search_bp.route('/search', methods=['POST']) def search_post(): - """Handle search form submission.""" - name = request.form.get('name', '') - author = request.form.get('author', '') + """Handle a POST search submission (kept for backward compatibility).""" + return _render_search(request.form) + + +def _render_search(source): + """Run a crackme search from a request MultiDict (``request.args`` or form). + + ``source`` provides ``.get`` and ``.getlist``, so the same logic serves both + bookmarkable GET URLs and legacy POST submissions. + """ + name = source.get('name', '') + author = source.get('author', '') # Use getlist() for multi-select fields - lang = request.form.getlist('lang') - arch = request.form.getlist('arch') - platform = request.form.getlist('platform') + lang = source.getlist('lang') + arch = source.getlist('arch') + platform = source.getlist('platform') + tags = normalize_tags(source.getlist('tags')) # Get difficulty range try: - difficulty_min = int(request.form.get('difficulty-min', 1)) + difficulty_min = int(source.get('difficulty-min', 1)) except (ValueError, TypeError): difficulty_min = 1 try: - difficulty_max = int(request.form.get('difficulty-max', 6)) + difficulty_max = int(source.get('difficulty-max', 6)) except (ValueError, TypeError): difficulty_max = 6 # Get quality range try: - quality_min = int(request.form.get('quality-min', 1)) + quality_min = int(source.get('quality-min', 1)) except (ValueError, TypeError): quality_min = 1 try: - quality_max = int(request.form.get('quality-max', 6)) + quality_max = int(source.get('quality-max', 6)) except (ValueError, TypeError): quality_max = 6 # Get downloads range try: - downloads_min = int(request.form.get('downloads-min', 0)) + downloads_min = int(source.get('downloads-min', 0)) if downloads_min < 0: downloads_min = 0 except (ValueError, TypeError): downloads_min = 0 - downloads_max_str = request.form.get('downloads-max', '') + downloads_max_str = source.get('downloads-max', '') if downloads_max_str == '' or downloads_max_str.lower() == 'any': downloads_max = None else: @@ -68,13 +87,13 @@ def search_post(): # Get solutions range try: - solutions_min = int(request.form.get('solutions-min', 0)) + solutions_min = int(source.get('solutions-min', 0)) if solutions_min < 0: solutions_min = 0 except (ValueError, TypeError): solutions_min = 0 - solutions_max_str = request.form.get('solutions-max', '') + solutions_max_str = source.get('solutions-max', '') if solutions_max_str == '' or solutions_max_str.lower() == 'any': solutions_max = None else: @@ -87,13 +106,13 @@ def search_post(): # Get comments range try: - comments_min = int(request.form.get('comments-min', 0)) + comments_min = int(source.get('comments-min', 0)) if comments_min < 0: comments_min = 0 except (ValueError, TypeError): comments_min = 0 - comments_max_str = request.form.get('comments-max', '') + comments_max_str = source.get('comments-max', '') if comments_max_str == '' or comments_max_str.lower() == 'any': comments_max = None else: @@ -125,31 +144,31 @@ def parse_size_with_unit(value_str, unit_str): except (ValueError, TypeError): return None - size_min_str = request.form.get('size-min', '') - size_min_unit = request.form.get('size-min-unit', 'KB') + size_min_str = source.get('size-min', '') + size_min_unit = source.get('size-min-unit', 'KB') size_min = parse_size_with_unit(size_min_str, size_min_unit) if size_min is None: size_min = 0 - size_max_str = request.form.get('size-max', '') - size_max_unit = request.form.get('size-max-unit', 'MB') + size_max_str = source.get('size-max', '') + size_max_unit = source.get('size-max-unit', 'MB') size_max = parse_size_with_unit(size_max_str, size_max_unit) # Get page number and show_all flag try: - page = int(request.form.get('page', 1)) + page = int(source.get('page', 1)) if page < 1: page = 1 except (ValueError, TypeError): page = 1 - show_all = request.form.get('show_all') == '1' + show_all = source.get('show_all') == '1' # Get sort options - sort_by = request.form.get('sort_by', 'date') + sort_by = source.get('sort_by', 'date') if sort_by not in ('date', 'size', 'downloads', 'solutions', 'comments', 'quality', 'difficulty'): sort_by = 'date' - sort_order = request.form.get('sort_order', 'desc') + sort_order = source.get('sort_order', 'desc') if sort_order not in ('asc', 'desc'): sort_order = 'desc' @@ -161,6 +180,7 @@ def parse_size_with_unit(value_str, unit_str): 'lang': lang, # list 'arch': arch, # list 'platform': platform, # list + 'tags': tags, # list 'difficulty-min': difficulty_min, 'difficulty-max': difficulty_max, 'quality-min': quality_min, @@ -187,6 +207,7 @@ def parse_size_with_unit(value_str, unit_str): lang=lang, arch=arch, platform=platform, + tags=tags, difficulty_min=difficulty_min, difficulty_max=difficulty_max, quality_min=quality_min, @@ -212,6 +233,7 @@ def parse_size_with_unit(value_str, unit_str): lang=lang, arch=arch, platform=platform, + tags=tags, difficulty_min=difficulty_min, difficulty_max=difficulty_max, quality_min=quality_min, @@ -238,7 +260,8 @@ def parse_size_with_unit(value_str, unit_str): page=page, has_more=has_more, show_all=show_all, - search_params=search_params) + search_params=search_params, + tag_groups=get_tag_groups()) @search_bp.route('/random', methods=['GET']) @@ -276,4 +299,5 @@ def random_get(): page=1, has_more=False, show_all=True, - search_params=search_params) + search_params=search_params, + tag_groups=get_tag_groups()) diff --git a/app/models/crackme.py b/app/models/crackme.py index b15fa31..51b6b5a 100644 --- a/app/models/crackme.py +++ b/app/models/crackme.py @@ -123,6 +123,7 @@ def crackme_increment_downloads(crackme_hexid): def search_crackme(name='', author='', lang='', arch='', platform='', + tags=None, difficulty_min=0, difficulty_max=6, quality_min=0, quality_max=6, downloads_min=0, downloads_max=None, @@ -190,6 +191,12 @@ def search_crackme(name='', author='', lang='', arch='', platform='', query['platform'] = {'$in': platform} else: query['platform'] = platform + # tags: a crackme must carry *all* selected tags ($all) + if tags: + if isinstance(tags, str): + tags = [tags] + if tags: + query['tags'] = {'$all': tags} skip = (page - 1) * per_page # Determine sort field and direction @@ -274,7 +281,7 @@ def crackme_by_user_and_name(username, name, visible=True): return result -def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename): +def crackme_create_prepare(name, info, username, lang, arch, platform, size, original_filename, tags=None): """Prepare a crackme object without inserting it.""" if not check_connection(): raise ErrUnavailable("Database is unavailable") @@ -299,7 +306,8 @@ def crackme_create_prepare(name, info, username, lang, arch, platform, size, ori 'nbdownloads': 0, 'platform': platform, 'size': size, - 'original_filename': original_filename + 'original_filename': original_filename, + 'tags': tags or [] } @@ -348,7 +356,7 @@ def crackme_update(hexid, updates): raise ErrUnavailable("Database is unavailable") # Only allow updating these fields (name is excluded to avoid database issues) - allowed_fields = {'info', 'lang', 'arch', 'platform'} + allowed_fields = {'info', 'lang', 'arch', 'platform', 'tags'} filtered_updates = {k: v for k, v in updates.items() if k in allowed_fields} if not filtered_updates: @@ -377,6 +385,29 @@ def crackme_update(hexid, updates): return changes +def crackme_set_tags(hexid, tags): + """Overwrite the obfuscation tags on a crackme. + + Args: + hexid: The hex ID of the crackme + tags: The full (already validated) list of tags to store + + Returns: + The previous list of tags, or None if the crackme was not found. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('crackme') + crackme = collection.find_one({'hexid': hexid}, {'tags': 1}) + if not crackme: + return None + + old_tags = crackme.get('tags', []) + collection.update_one({'hexid': hexid}, {'$set': {'tags': list(tags)}}) + return old_tags + + def crackme_by_hexid_any(hexid): """Get crackme by hex ID regardless of visibility status. diff --git a/app/models/tag_request.py b/app/models/tag_request.py new file mode 100644 index 0000000..62cc39c --- /dev/null +++ b/app/models/tag_request.py @@ -0,0 +1,128 @@ +""" +Tag change request model. + +Any logged-in user can request that tags be added to or removed from a crackme. +Requests land in a pending queue that reviewers approve or reject; approving a +request applies the add/remove sets to the crackme's tags. +""" + +from datetime import datetime +from bson import ObjectId +from pymongo import DESCENDING +from app.services.database import get_collection, check_connection +from app.models.errors import ErrNoResult, ErrUnavailable + +# Request lifecycle states. +STATUS_PENDING = 'pending' +STATUS_APPROVED = 'approved' +STATUS_REJECTED = 'rejected' + + +def tag_request_create(hexid, crackme_name, requester, add=None, remove=None, note=''): + """Create a pending tag change request. + + Args: + hexid: Hex ID of the target crackme + crackme_name: Name of the crackme (denormalized for the review queue) + requester: Username of the requesting user + add: List of (already validated) tags to add + remove: List of (already validated) tags to remove + note: Optional free-text justification from the requester + + Returns: + The inserted request document. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + obj_id = ObjectId() + + request = { + '_id': obj_id, + 'hexid': str(obj_id), + 'crackme_hexid': hexid, + 'crackme_name': crackme_name, + 'requester': requester, + 'add': list(add or []), + 'remove': list(remove or []), + 'note': note or '', + 'status': STATUS_PENDING, + 'created_at': datetime.utcnow(), + 'reviewed_by': None, + 'reviewed_at': None, + } + + collection.insert_one(request) + return request + + +def tag_requests_pending(): + """Return all pending tag change requests, newest first.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + return list(collection.find({'status': STATUS_PENDING}) + .sort('created_at', DESCENDING)) + + +def count_pending_tag_requests(): + """Count pending tag change requests.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + return collection.count_documents({'status': STATUS_PENDING}) + + +def tag_request_by_hexid(hexid): + """Get a tag change request by its hex ID. + + Raises: + ErrNoResult: If no request matches. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + request = collection.find_one({'hexid': hexid}) + if not request: + raise ErrNoResult("Tag request not found") + return request + + +def tag_request_set_status(hexid, status, reviewer): + """Mark a request approved/rejected and stamp the reviewer. + + Returns: + The updated request document, or None if not found. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + from pymongo import ReturnDocument + + collection = get_collection('tag_request') + return collection.find_one_and_update( + {'hexid': hexid}, + {'$set': { + 'status': status, + 'reviewed_by': reviewer, + 'reviewed_at': datetime.utcnow(), + }}, + return_document=ReturnDocument.AFTER + ) + + +def pending_tag_requests_by_user_and_crackme(requester, crackme_hexid): + """Count a user's already-pending requests for one crackme (anti-spam).""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('tag_request') + return collection.count_documents({ + 'requester': requester, + 'crackme_hexid': crackme_hexid, + 'status': STATUS_PENDING, + }) diff --git a/app/services/tags.py b/app/services/tags.py new file mode 100644 index 0000000..5aaab1a --- /dev/null +++ b/app/services/tags.py @@ -0,0 +1,297 @@ +""" +Obfuscation tag vocabulary and helpers. + +Crackmes can be labeled with high-level anti-analysis / obfuscation tags +(anti-debugging, string encryption, packers, ...) plus finer **sub-labels** +nested under some of those classes. The labels come from the crackmes-RE dataset +(AI-generated, so **not guaranteed accurate**). + +The controlled vocabulary is **stored in the ``tag_vocabulary`` MongoDB +collection** (a single document) so it can be updated without code changes when +the dataset changes -- regenerate it (and re-tag crackmes) from the dataset +with ``script/sync_tags.py``. The ``DEFAULT_*`` values below are the +built-in baseline: they seed that collection and act as a fallback when it is +empty (fresh DB, tests, DB unavailable). + +Callers should use the accessor functions (``get_tag_groups()``, +``normalize_tags()``, ...) rather than reading module-level constants, because +the active vocabulary is resolved from the database at runtime. +""" + +from app.services.database import get_collection, check_connection + +# Collection + document id that hold the live vocabulary. +VOCAB_COLLECTION = "tag_vocabulary" +VOCAB_ID = "current" + +# Where the "?" next to the tags links to (the AI-generated source dataset). +DATASET_URL = "https://github.com/crackmesone/crackmes-re-dataset" + +# --------------------------------------------------------------------------- +# Built-in baseline (used to seed the DB collection and as a fallback) +# --------------------------------------------------------------------------- + +# High-level obfuscation classes, ordered by how common they are. +DEFAULT_CLASSES = [ + "Anti-debugging", + "Packer", + "String / data encryption", + "Self-modifying / runtime decrypt", + "Code virtualization / VM", + "Crypto / hash algorithm", + "Control-flow obfuscation", + "Anti-tamper / integrity", + "Anti-disassembly", + "Import / API obfuscation", + "Custom / generic obfuscation", + "Encoding (base64/hex)", + "Binary hardening (ASLR/PIE/canary)", + "Anti-VM / sandbox", + "Nag / trial", + "Anti-static analysis", +] + +# Finer sub-labels nested under their parent class. +DEFAULT_SUBLABELS = { + "Anti-debugging": [ + "IsDebuggerPresent", + "Debugger/tool window detection", + "Timing (rdtsc/GetTickCount)", + "PEB BeingDebugged / NtGlobalFlag", + "INT3 / 0xCC breakpoint scan", + "ptrace (Linux)", + "Exception-based (SEH/VEH/INT2D)", + "Hardware breakpoints (DRx)", + "NtQueryInformationProcess", + "OutputDebugString", + "CheckRemoteDebuggerPresent", + "Self-debug / block debugger", + "TLS callback", + "Anti-dump", + "Parent-process check", + "Anti-attach / thread suspension", + "DbgBreakPoint/DbgUiRemoteBreakin patch", + "CloseHandle invalid-handle", + ], + "Packer": [ + "UPX", + "FSG", + "ASPack", + "MPRESS", + "tElock", + "VMProtect", + "Petite", + "Yoda", + "ASProtect", + ".NET Reactor", + "ConfuserEx", + "PECompact", + "exepack", + "Enigma", + "Themida", + "ExeCryptor", + "WinLicense", + "SmartAssembly", + "Other named (Morphine/Neolite/PEtite…)", + "PELock", + "CodeVirtualizer", + "PKLite", + "MEW", + "Dotfuscator", + ], + "Control-flow obfuscation": [ + "Spaghetti / junk-branch", + "Exception / interrupt-based", + "Indirect / computed jumps & calls", + "State machine / dispatcher", + "Control-flow flattening (CFF)", + "Return-address / stack-based", + ], + "Anti-disassembly": [ + "Junk / garbage bytes", + "Malformed PE / bad bytes (UD2)", + "Opaque predicates", + "Overlapping / misaligned instructions", + "Jump-based desync", + ], + # AES / Base64 / RC4 / TEA-XTEA also appear as string-encryption ciphers, so + # they are qualified with "(crypto)" here to keep each tag under one parent. + "Crypto / hash algorithm": [ + "MD5", + "CRC32", + "Base64 (crypto)", + "RSA", + "AES (crypto)", + "SHA-256", + "Other / custom hash", + "RC4 (crypto)", + "TEA / XTEA (crypto)", + "SHA-1", + "Blowfish", + "DES / 3DES", + ], + # ...and the same four ciphers are qualified with "(encryption)" here. + "String / data encryption": [ + "XOR", + "Base64 (encryption)", + "AES (encryption)", + "RC4 (encryption)", + "TEA / XTEA (encryption)", + "Substitution / table", + ], +} + +# Dataset field name -> parent class (which sub-label list each field feeds). +DEFAULT_FIELD_PARENTS = { + "antidebug_methods": "Anti-debugging", + "packers": "Packer", + "controlflow_methods": "Control-flow obfuscation", + "antidisasm_methods": "Anti-disassembly", + "crypto_methods": "Crypto / hash algorithm", + "encryption_methods": "String / data encryption", +} + +# Some algorithm names appear under more than one field; they are qualified with +# the source context so each maps to exactly one parent (e.g. dataset "AES" -> +# "AES (crypto)" or "AES (encryption)"). +DEFAULT_QUALIFY_SUFFIX = {"crypto_methods": "crypto", "encryption_methods": "encryption"} +DEFAULT_QUALIFY_VALUES = ["AES", "Base64", "RC4", "TEA / XTEA"] + + +def default_vocabulary_doc(): + """The built-in vocabulary as a plain dict (also used to seed the DB).""" + return { + "classes": list(DEFAULT_CLASSES), + "sublabels": {k: list(v) for k, v in DEFAULT_SUBLABELS.items()}, + "field_parents": dict(DEFAULT_FIELD_PARENTS), + "qualify_suffix": dict(DEFAULT_QUALIFY_SUFFIX), + "qualify_values": list(DEFAULT_QUALIFY_VALUES), + "dataset_url": DATASET_URL, + } + + +# --------------------------------------------------------------------------- +# Vocabulary object + DB loading (cached) +# --------------------------------------------------------------------------- + +class Vocabulary: + """An immutable view of the controlled vocabulary with derived lookups.""" + + def __init__(self, doc): + self.classes = list(doc.get("classes") or []) + self.sublabels = {k: list(v) for k, v in (doc.get("sublabels") or {}).items()} + self.field_parents = dict(doc.get("field_parents") or {}) + self.qualify_suffix = dict(doc.get("qualify_suffix") or {}) + self.qualify_values = set(doc.get("qualify_values") or []) + self.dataset_url = doc.get("dataset_url") or DATASET_URL + + # Grouped view for templates: class then its (possibly empty) sub-labels. + self.tag_groups = [ + {"tag": c, "sublabels": self.sublabels.get(c, [])} + for c in self.classes + ] + # Flat canonical order: each class immediately followed by its sub-labels. + self.all_tags = [] + for group in self.tag_groups: + self.all_tags.append(group["tag"]) + self.all_tags.extend(group["sublabels"]) + self.tag_set = set(self.all_tags) + self._order = {tag: i for i, tag in enumerate(self.all_tags)} + + def normalize(self, raw_tags): + if not raw_tags: + return [] + seen = set() + for tag in raw_tags: + if isinstance(tag, str): + tag = tag.strip() + if tag in self.tag_set: + seen.add(tag) + return sorted(seen, key=lambda t: self._order[t]) + + def is_valid(self, tag): + return tag in self.tag_set + + def sublabel_tag(self, field, value): + suffix = self.qualify_suffix.get(field) + if suffix and value in self.qualify_values: + return "{} ({})".format(value, suffix) + return value + + +_cache = None + + +def _load_vocabulary_doc(): + """Return the vocabulary document from the DB, or the default if absent.""" + try: + if check_connection(): + doc = get_collection(VOCAB_COLLECTION).find_one({"_id": VOCAB_ID}) + if doc: + return doc + except Exception as e: # pragma: no cover - defensive; fall back to default + print(f"Tag vocabulary load error, using default: {e}") + return default_vocabulary_doc() + + +def get_vocabulary(): + """Return the active :class:`Vocabulary`, loading (and caching) it lazily.""" + global _cache + if _cache is None: + _cache = Vocabulary(_load_vocabulary_doc()) + return _cache + + +def reload_vocabulary(): + """Drop the cache so the next access re-reads the DB (after a sync/edit).""" + global _cache + _cache = None + + +# --------------------------------------------------------------------------- +# Public API (stable for controllers, templates, scripts) +# --------------------------------------------------------------------------- + +def normalize_tags(raw_tags): + """Validate, de-duplicate, and canonically order a list of tags.""" + return get_vocabulary().normalize(raw_tags) + + +def is_valid_tag(tag): + """Return True if ``tag`` is part of the active controlled vocabulary.""" + return get_vocabulary().is_valid(tag) + + +def dataset_sublabel_tag(field, value): + """Map a raw dataset sub-label ``(field, value)`` to its canonical tag.""" + return get_vocabulary().sublabel_tag(field, value) + + +def get_tag_groups(): + """Ordered ``[{"tag", "sublabels"}]`` for rendering the tag picker.""" + return get_vocabulary().tag_groups + + +def get_all_tags(): + """Flat list of every valid tag in canonical order.""" + return get_vocabulary().all_tags + + +def get_classes(): + """The high-level obfuscation classes, ordered.""" + return get_vocabulary().classes + + +def get_sublabels(): + """Mapping of parent class -> ordered sub-labels.""" + return get_vocabulary().sublabels + + +def get_sublabel_fields(): + """Mapping of dataset field name -> parent class.""" + return get_vocabulary().field_parents + + +def get_dataset_url(): + """URL the tags "?" help link points at.""" + return get_vocabulary().dataset_url diff --git a/review/routes.py b/review/routes.py index f189040..a9b9cb4 100644 --- a/review/routes.py +++ b/review/routes.py @@ -44,6 +44,12 @@ ) from app.services.crypto import get_obfuscation_salt from app.services.view import is_valid_hexid +from app.services.tags import get_tag_groups, normalize_tags +from app.models.tag_request import ( + tag_requests_pending, count_pending_tag_requests, tag_request_by_hexid, + tag_request_set_status, STATUS_APPROVED, STATUS_REJECTED, +) +from app.models.errors import ErrNoResult # ============================================================================= @@ -593,7 +599,8 @@ def get_crackme_details(uuid): "author": crackme_obj["author"], "lang": crackme_obj["lang"], "arch": crackme_obj["arch"], - "platform": crackme_obj["platform"] + "platform": crackme_obj["platform"], + "tags": crackme_obj.get("tags", []) }, None @@ -1364,12 +1371,19 @@ def logout(): @token_required def dashboard(current_user): """Display dashboard with pending submission counts.""" + try: + tagreq_cnt = count_pending_tag_requests() + except Exception as e: + print(f"Error counting tag requests: {e}") + tagreq_cnt = 0 + return render_template( 'reviewer/dashboard.html', user=current_user['username'], is_admin=current_user['is_admin'], solution_cnt=count_pending_solutions(), - crackme_cnt=count_pending_items('crackme') + crackme_cnt=count_pending_items('crackme'), + tagreq_cnt=tagreq_cnt ) @@ -1443,7 +1457,8 @@ def viewcrackme(current_user): 'reviewer/viewcrackme.html', user=current_user['username'], is_admin=current_user['is_admin'], - crackme=crackme + crackme=crackme, + tag_groups=get_tag_groups() ) @@ -1650,6 +1665,171 @@ def approvecrackme(current_user): return redirect(url_for('reviewer.reviewcrackme', message=message)) +# ============================================================================= +# Route Handlers - Tags +# ============================================================================= + +@reviewer_bp.route('/settags', methods=['POST']) +@token_required +def settags(current_user): + """Override the obfuscation tags on a crackme (reviewer). + + Used both when reviewing a pending crackme and from the edit page. The + posted ``tags`` values are filtered against the controlled vocabulary. + """ + validate_csrf_token() + crackme_uuid = request.form.get('uuid') + redirect_to = request.form.get('redirect_to') or 'view' + + if not is_valid_hexid(crackme_uuid): + return redirect(url_for('reviewer.reviewcrackme', message="Invalid crackme id")) + + tags = normalize_tags(request.form.getlist('tags')) + + crackme = g_crackmesone_db.crackme.find_one({'hexid': crackme_uuid.lower()}) + if not crackme: + return redirect(url_for('reviewer.reviewcrackme', message="Crackme not found")) + + old_tags = crackme.get('tags', []) + g_crackmesone_db.crackme.update_one( + {'hexid': crackme_uuid.lower()}, + {'$set': {'tags': tags}} + ) + + log_reviewer_operation( + "set_crackme_tags", current_user['username'], + {"crackme_uuid": crackme_uuid, "old_tags": old_tags, "new_tags": tags}, + True + ) + + if redirect_to == 'edit': + return redirect(url_for('reviewer.editcrackme', crackme_uuid=crackme_uuid)) + return redirect(url_for('reviewer.viewcrackme', crackme_uuid=crackme_uuid)) + + +def _apply_tag_request(tag_request): + """Apply an approved tag request's add/remove sets to the crackme. + + Returns the crackme's new tag list, or None if the crackme is gone. + """ + crackme = g_crackmesone_db.crackme.find_one( + {'hexid': tag_request['crackme_hexid']} + ) + if not crackme: + return None + + current = list(crackme.get('tags', [])) + for tag in tag_request.get('add', []): + if tag not in current: + current.append(tag) + for tag in tag_request.get('remove', []): + if tag in current: + current.remove(tag) + + new_tags = normalize_tags(current) + g_crackmesone_db.crackme.update_one( + {'hexid': tag_request['crackme_hexid']}, + {'$set': {'tags': new_tags}} + ) + return new_tags + + +@reviewer_bp.route('/tagrequests') +@token_required +def tagrequests(current_user): + """List pending tag change requests for review.""" + try: + requests_list = tag_requests_pending() + except Exception as e: + print(f"Error loading tag requests: {e}") + requests_list = [] + + return render_template( + 'reviewer/tagrequests.html', + user=current_user['username'], + is_admin=current_user['is_admin'], + requests=requests_list, + message=request.args.get('message') + ) + + +@reviewer_bp.route('/approvetagrequest', methods=['POST']) +@token_required +def approvetagrequest(current_user): + """Approve a tag change request and apply it to the crackme.""" + validate_csrf_token() + req_hexid = request.form.get('uuid') + + try: + tag_request = tag_request_by_hexid(req_hexid) + except ErrNoResult: + return redirect(url_for('reviewer.tagrequests', message="Request not found")) + + if tag_request.get('status') != 'pending': + return redirect(url_for('reviewer.tagrequests', message="Request already handled")) + + new_tags = _apply_tag_request(tag_request) + if new_tags is None: + # Crackme no longer exists; close the request as rejected. + tag_request_set_status(req_hexid, STATUS_REJECTED, current_user['username']) + return redirect(url_for('reviewer.tagrequests', message="Crackme not found; request closed")) + + tag_request_set_status(req_hexid, STATUS_APPROVED, current_user['username']) + + log_reviewer_operation( + "approve_tag_request", current_user['username'], + {"request": req_hexid, "crackme": tag_request['crackme_hexid'], "new_tags": new_tags}, + True + ) + + try: + send_user_notification( + tag_request['requester'], + f"Your tag change request for '" + f"{html_escape(tag_request['crackme_name'])}' has been approved." + ) + except Exception as e: + print(f"Notification error: {e}") + + return redirect(url_for('reviewer.tagrequests', message="Request approved")) + + +@reviewer_bp.route('/rejecttagrequest', methods=['POST']) +@token_required +def rejecttagrequest(current_user): + """Reject a tag change request.""" + validate_csrf_token() + req_hexid = request.form.get('uuid') + reject_reason = request.form.get('reject_reason') + + try: + tag_request = tag_request_by_hexid(req_hexid) + except ErrNoResult: + return redirect(url_for('reviewer.tagrequests', message="Request not found")) + + if tag_request.get('status') != 'pending': + return redirect(url_for('reviewer.tagrequests', message="Request already handled")) + + tag_request_set_status(req_hexid, STATUS_REJECTED, current_user['username']) + + log_reviewer_operation( + "reject_tag_request", current_user['username'], + {"request": req_hexid, "crackme": tag_request['crackme_hexid'], "reason": reject_reason}, + True + ) + + try: + notif = (f"Your tag change request for '" + f"{html_escape(tag_request['crackme_name'])}' has been rejected.") + if reject_reason: + notif += f" Reason: {html_escape(reject_reason)}" + send_user_notification(tag_request['requester'], notif) + except Exception as e: + print(f"Notification error: {e}") + + return redirect(url_for('reviewer.tagrequests', message="Request rejected")) + + # ============================================================================= # Route Handlers - Admin: Delete Approved Content # ============================================================================= @@ -1723,6 +1903,7 @@ def editcrackme(current_user): lang = request.form.get('lang', '') arch = request.form.get('arch', '') platform = request.form.get('platform', '') + tags = normalize_tags(request.form.getlist('tags')) notify_author = request.form.get('notify_author') == 'on' if True: @@ -1744,6 +1925,8 @@ def editcrackme(current_user): changes.append(f"arch: '{crackme_obj.get('arch')}' -> '{arch}'") if crackme_obj.get('platform') != platform: changes.append(f"platform: '{crackme_obj.get('platform')}' -> '{platform}'") + if sorted(crackme_obj.get('tags', [])) != sorted(tags): + changes.append(f"tags: {crackme_obj.get('tags', [])} -> {tags}") # Update the crackme (name excluded) g_crackmesone_db.crackme.update_one( @@ -1752,7 +1935,8 @@ def editcrackme(current_user): "info": info, "lang": lang, "arch": arch, - "platform": platform + "platform": platform, + "tags": tags }} ) @@ -1837,7 +2021,8 @@ def editcrackme(current_user): "lang": crackme_obj.get('lang', ''), "arch": crackme_obj.get('arch', ''), "platform": crackme_obj.get('platform', ''), - "author": crackme_obj.get('author', '') + "author": crackme_obj.get('author', ''), + "tags": crackme_obj.get('tags', []) } else: error = "Crackme not found" @@ -1848,7 +2033,8 @@ def editcrackme(current_user): is_admin=current_user['is_admin'], crackme=crackme, message=message, - error=error + error=error, + tag_groups=get_tag_groups() ) diff --git a/review/templates/reviewer/dashboard.html b/review/templates/reviewer/dashboard.html index bdaa3e2..fa2a4a6 100644 --- a/review/templates/reviewer/dashboard.html +++ b/review/templates/reviewer/dashboard.html @@ -27,6 +27,7 @@

Welcome, {{ user }}!{% if is_admin %} (Admin){% endif %}

Review solutions Review crackmes + Review tag requests {% if is_admin %} Delete solution Delete crackme @@ -56,6 +57,15 @@

{{ crackme_cnt }}


+ +
+
+
+

The number of tag change requests to review:

+

{{ tagreq_cnt }}


+
+
+
diff --git a/review/templates/reviewer/editcrackme.html b/review/templates/reviewer/editcrackme.html index 8f9ad7f..3787012 100644 --- a/review/templates/reviewer/editcrackme.html +++ b/review/templates/reviewer/editcrackme.html @@ -36,7 +36,7 @@

Edit crackme: "{{ crackme.name }}" by {{ crackme.author }}

-
+ @@ -93,6 +93,13 @@

Edit crackme: "{{ crackme.name }}" by {{ crackme.author }}

+
+ + {% set tag_input_name = 'tags' %} + {% set checked_tags = crackme.tags %} + {% include 'partial/tags_checkboxes.html' %} +
+
@@ -130,4 +137,17 @@

Edit crackme (Admin)

{% endif %}
+ {% if crackme %} + + + {% endif %} diff --git a/review/templates/reviewer/tagrequests.html b/review/templates/reviewer/tagrequests.html new file mode 100644 index 0000000..7cabd3a --- /dev/null +++ b/review/templates/reviewer/tagrequests.html @@ -0,0 +1,74 @@ + + + + + Tag change requests + + + + + + + + + +
+ {% if message %} +
{{ message }}
+ {% endif %} + +

Pending tag change requests

+ + {% if not requests %} +

No pending tag change requests.

+ {% else %} + {% for req in requests %} +
+
+

+ "{{ req.crackme_name }}" + — view crackme +
+ Requested by {{ req.requester }} + on {{ req.created_at }} +

+ {% if req.add %} +

Add: {{ req.add|join(', ') }}

+ {% endif %} + {% if req.remove %} +

Remove: {{ req.remove|join(', ') }}

+ {% endif %} + {% if req.note %} +

Note: {{ req.note }}

+ {% endif %} + + + + + + + +
+ + + + +
+
+
+ {% endfor %} + {% endif %} +
+ + + diff --git a/review/templates/reviewer/viewcrackme.html b/review/templates/reviewer/viewcrackme.html index 5df6468..5c6344d 100644 --- a/review/templates/reviewer/viewcrackme.html +++ b/review/templates/reviewer/viewcrackme.html @@ -47,6 +47,25 @@

Download
+

+

Tags

+

+

Author-suggested / current tags. Adjust and save before accepting.

+
+ + + +
+ {% set tag_input_name = 'tags' %} + {% set checked_tags = crackme.tags %} + {% include 'partial/tags_checkboxes.html' %} +
+ +
+ + +
+

Review

diff --git a/script/sync_tags.py b/script/sync_tags.py new file mode 100644 index 0000000..dd31f2a --- /dev/null +++ b/script/sync_tags.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +Sync obfuscation tags from the crackmes-RE dataset into the database. + +Runs two phases, in order: + + 1. Vocabulary -> rebuild the ``tag_vocabulary`` document (the valid classes, + sub-labels, and qualification rules) so the site knows what tags exist. + 2. Crackme tags -> set each crackme's ``tags`` to the dataset-derived set, + validated against that vocabulary. + +The phases must run in this order -- crackme tags are validated against the +vocabulary, so re-tagging with a stale vocabulary would silently drop any newly +added tags. Keeping them in one command makes that ordering automatic. + +Usage: + cd /path/to/crackmesone_python/script + python sync_tags.py --dataset /path/to/crackmes_dataset.jsonl # dry run (both phases) + python sync_tags.py --dataset /path/to/crackmes_dataset.jsonl --apply # write both + + --vocab-only Only rebuild the vocabulary document + --tags-only Only re-tag crackmes (using the vocabulary already in the DB) + --seed-default Vocabulary phase writes the built-in default vocabulary + instead of deriving it from the dataset + +After applying, restart the app workers so they pick up the new vocabulary. +""" + +import argparse +import json +import os +import sys +from collections import Counter, defaultdict + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from pymongo import MongoClient + +from app.services.tags import ( + Vocabulary, default_vocabulary_doc, get_vocabulary, reload_vocabulary, + VOCAB_COLLECTION, VOCAB_ID, DATASET_URL, + DEFAULT_FIELD_PARENTS, DEFAULT_QUALIFY_SUFFIX, +) + + +# --------------------------------------------------------------------------- +# Phase 1: build the vocabulary document from the dataset +# --------------------------------------------------------------------------- + +def build_vocabulary_from_dataset(dataset_path): + """Derive a vocabulary document (classes + sub-labels) from a JSONL dataset.""" + field_parents = dict(DEFAULT_FIELD_PARENTS) + qualify_suffix = dict(DEFAULT_QUALIFY_SUFFIX) + + class_counts = Counter() + field_value_counts = {f: Counter() for f in field_parents} + value_fields = defaultdict(set) # raw value -> set of fields it appears in + + for record in _iter_records(dataset_path): + for c in (record.get("obfuscation_classes") or []): + class_counts[c] += 1 + for field in field_parents: + for v in (record.get(field) or []): + field_value_counts[field][v] += 1 + value_fields[v].add(field) + + # Values that appear under more than one field must be qualified per source. + qualify_values = sorted(v for v, fields in value_fields.items() if len(fields) > 1) + for v in qualify_values: + for field in value_fields[v]: + if field not in qualify_suffix: + print(f" WARNING: shared value {v!r} in field {field!r} has no " + f"qualify suffix; it may collide across parents.") + + def canonical(field, value): + suffix = qualify_suffix.get(field) + if suffix and value in qualify_values: + return "{} ({})".format(value, suffix) + return value + + classes = [c for c, _ in class_counts.most_common()] + + sublabels = defaultdict(list) + for field, parent in field_parents.items(): + for value, _ in field_value_counts[field].most_common(): + tag = canonical(field, value) + if tag not in sublabels[parent]: + sublabels[parent].append(tag) + + for parent in sublabels: + if parent not in classes: + print(f" NOTE: parent class {parent!r} has sub-labels but no " + f"obfuscation_classes occurrences; appending it.") + classes.append(parent) + + return { + "classes": classes, + "sublabels": {k: v for k, v in sublabels.items()}, + "field_parents": field_parents, + "qualify_suffix": qualify_suffix, + "qualify_values": qualify_values, + "dataset_url": DATASET_URL, + } + + +# --------------------------------------------------------------------------- +# Phase 2: derive each crackme's tags from the dataset +# --------------------------------------------------------------------------- + +def tags_from_dataset(dataset_path, voc): + """Return ``{hexid: [normalized tags]}`` using the given vocabulary.""" + mapping = {} + for record in _iter_records(dataset_path): + hexid = record.get("hexid") + if not hexid: + continue + raw = list(record.get("obfuscation_classes") or []) + for field in voc.field_parents: + for value in (record.get(field) or []): + raw.append(voc.sublabel_tag(field, value)) + mapping[hexid] = voc.normalize(raw) + return mapping + + +def _iter_records(dataset_path): + with open(dataset_path, "r") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + yield json.loads(line) + except json.JSONDecodeError as e: + print(f" Skipping malformed line: {e}") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def _db(config_dir): + with open(os.path.join(config_dir, "config", "config.json")) as f: + config = json.load(f) + mongo_url = config["Database"].get("URL", "mongodb://127.0.0.1:27017") + db_name = config["Database"].get("Name", "crackmesone") + return MongoClient(mongo_url)[db_name], db_name + + +def main(): + parser = argparse.ArgumentParser(description="Sync tag vocabulary + crackme tags from the dataset") + parser.add_argument("--dataset", help="Path to crackmes_dataset.jsonl") + parser.add_argument("--apply", action="store_true", help="Actually write changes") + parser.add_argument("--vocab-only", action="store_true", help="Only rebuild the vocabulary") + parser.add_argument("--tags-only", action="store_true", help="Only re-tag crackmes") + parser.add_argument("--seed-default", action="store_true", + help="Vocabulary phase writes the built-in default instead of deriving it") + args = parser.parse_args() + + if args.vocab_only and args.tags_only: + print("Error: --vocab-only and --tags-only are mutually exclusive") + return 2 + + do_vocab = not args.tags_only + do_tags = not args.vocab_only + needs_dataset = do_tags or (do_vocab and not args.seed_default) + if needs_dataset: + if not args.dataset: + print("Error: --dataset is required") + return 2 + if not os.path.exists(args.dataset): + print(f"Error: dataset file not found: {args.dataset}") + return 1 + + dry_run = not args.apply + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + db, db_name = _db(base_dir) + print(f"Connected to MongoDB: {db_name}") + if dry_run: + print("[DRY RUN - no changes written; re-run with --apply]\n") + + # ---- Phase 1: vocabulary ---- + if do_vocab: + print("== Phase 1: vocabulary ==") + if args.seed_default: + vocab_doc = default_vocabulary_doc() + else: + vocab_doc = build_vocabulary_from_dataset(args.dataset) + print(f" {len(vocab_doc['classes'])} classes, " + f"{sum(len(v) for v in vocab_doc['sublabels'].values())} sub-labels, " + f"qualified values: {vocab_doc['qualify_values']}") + if not dry_run: + doc = dict(vocab_doc, _id=VOCAB_ID) + db[VOCAB_COLLECTION].replace_one({"_id": VOCAB_ID}, doc, upsert=True) + reload_vocabulary() + print(f" wrote {db_name}.{VOCAB_COLLECTION} (_id={VOCAB_ID})") + voc = Vocabulary(vocab_doc) + else: + reload_vocabulary() + voc = get_vocabulary() + print("== Phase 1: skipped (--tags-only); using vocabulary already in the DB ==") + + # ---- Phase 2: crackme tags ---- + if do_tags: + print("\n== Phase 2: crackme tags ==") + mapping = tags_from_dataset(args.dataset, voc) + crackme = db["crackme"] + + # Authoritative: clear everyone, then set tags for dataset crackmes. + if not dry_run: + crackme.update_many({}, {"$set": {"tags": []}}) + + tagged = not_in_db = no_tags = 0 + for hexid, tags in mapping.items(): + if not tags: + no_tags += 1 + continue + if not crackme.find_one({"hexid": hexid}, {"_id": 1}): + not_in_db += 1 + continue + if not dry_run: + crackme.update_one({"hexid": hexid}, {"$set": {"tags": tags}}) + tagged += 1 + + print(f" dataset records: {len(mapping)}") + print(f" {'would tag' if dry_run else 'tagged'} crackmes: {tagged}") + print(f" dataset records with no valid tags: {no_tags}") + print(f" dataset records not in DB: {not_in_db}") + else: + print("\n== Phase 2: skipped (--vocab-only) ==") + + if dry_run: + print("\nRe-run with --apply to write these changes.") + else: + print("\nDone. Restart the app workers to pick up the new vocabulary.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/static/css/custom.css b/static/css/custom.css index 0923f14..b6cbb16 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -490,3 +490,28 @@ h3:hover .anchor-link { color: #000; border-color: #9acc14; } + +/* Larger tags modal so the grouped tag checkboxes have room */ +#modal-tags .modal-container { + max-width: 900px; + width: 94vw; +} +/* Let only the checkbox list scroll, so the reason box + submit stay visible. */ +#modal-tags .modal-body { + max-height: 84vh; + padding-bottom: 1.2rem; +} +#modal-tags .tags-scroll { + max-height: 50vh; + overflow-y: auto; + border: .05rem solid #444; + border-radius: .1rem; + padding: 6px 8px; + margin-bottom: 12px; +} +/* Contain the floated submit button and leave space beneath it. */ +#modal-tags .tag-request-actions { + overflow: hidden; + margin-top: 10px; + margin-bottom: 8px; +} diff --git a/static/js/tag_checkboxes.js b/static/js/tag_checkboxes.js new file mode 100644 index 0000000..89d3b2e --- /dev/null +++ b/static/js/tag_checkboxes.js @@ -0,0 +1,57 @@ +/* + * TagCheckboxes - hierarchy behaviour for the grouped tag checkbox layout + * (templates/partial/tags_checkboxes.html). + * + * Within the given scope (usually a
): + * - ticking a technique auto-ticks its parent category + * - un-ticking a technique un-ticks the category when no sibling technique + * remains ticked + * - un-ticking a category clears all its techniques + * so a technique is never selected without its category, and a category is + * never left selected on its own once its last technique is removed. + * + * Usage: TagCheckboxes.init(document.getElementById('my-form')); + */ +window.TagCheckboxes = (function () { + function cssEscape(v) { + return v.replace(/"/g, '\\"'); + } + + function init(scope) { + if (!scope) return; + + function parentInput(value) { + return scope.querySelector('input[data-tag-class][value="' + cssEscape(value) + '"]'); + } + + function siblings(parentValue) { + return scope.querySelectorAll('input[data-tag-parent="' + cssEscape(parentValue) + '"]'); + } + + scope.querySelectorAll('input[data-tag-parent]').forEach(function (sub) { + sub.addEventListener('change', function () { + var parentValue = sub.getAttribute('data-tag-parent'); + var parent = parentInput(parentValue); + if (!parent) return; + if (sub.checked) { + parent.checked = true; // selecting a technique selects its category + } else { + // deselecting the last remaining technique deselects the category + var anyChecked = Array.prototype.some.call( + siblings(parentValue), function (s) { return s.checked; }); + if (!anyChecked) parent.checked = false; + } + }); + }); + + scope.querySelectorAll('input[data-tag-class]').forEach(function (cls) { + cls.addEventListener('change', function () { + if (!cls.checked) { + siblings(cls.value).forEach(function (s) { s.checked = false; }); + } + }); + }); + } + + return { init: init }; +})(); diff --git a/templates/crackme/create.html b/templates/crackme/create.html index 7afdbbf..4674ab1 100644 --- a/templates/crackme/create.html +++ b/templates/crackme/create.html @@ -23,7 +23,7 @@

Quick Rules

Read the full crackme submission rules for detailed guidelines.

- +
@@ -100,6 +100,22 @@

Quick Rules

+
+
+ +
+
+

+ Select the anti-analysis / obfuscation techniques your crackme uses. Please tag every technique + that applies; if none apply, it's fine to leave these empty. Pick a broad category and, where it + applies, the specific technique underneath — ticking a technique ticks its category + automatically. Reviewers may adjust these later. +

+ {% set tag_input_name = 'tags' %} + {% set checked_tags = [] %} + {% include 'partial/tags_checkboxes.html' %} +
+
@@ -124,6 +140,9 @@

Quick Rules

+ + + {% include 'partial/footer.html' %} {% endblock %} {% block foot %}{% endblock %} diff --git a/templates/crackme/read.html b/templates/crackme/read.html index f505743..8440c46 100644 --- a/templates/crackme/read.html +++ b/templates/crackme/read.html @@ -35,6 +35,9 @@ var modal = document.getElementById(id); if (modal) { modal.classList.add('active'); + // The tag request dialog keeps no draft between opens: always start + // from the crackme's current tags (discard any browser-restored state). + if (id === 'modal-tags' && window.resetTagRequestForm) window.resetTagRequestForm(); var input = modal.querySelector('textarea, input:not([type="hidden"])'); if (input) setTimeout(function() { input.focus(); }, 100); } @@ -103,6 +106,34 @@ .modal.active { display: flex; } + .tag-help { + display: inline-block; + width: 1.1rem; + height: 1.1rem; + line-height: 1.1rem; + text-align: center; + border: 1px solid currentColor; + border-radius: 50%; + font-size: 0.7rem; + font-weight: bold; + margin-left: 4px; + text-decoration: none; + } + .chip.tag-chip { + background: #9acc14; + color: #fff; + cursor: pointer; + font-size: 95%; + height: auto; + padding: .25rem .5rem; + margin: .15rem .15rem; + } + .chip.tag-chip:hover, + .chip.tag-chip:focus { + background: #86b310; + color: #fff; + text-decoration: none; + }

{{ username }}'s {{ name }}

@@ -166,6 +197,39 @@

{{ username }}'s {{ name }}

+
+

+ Tags + ? +

+ +
+ {% for tag in tags %} + {{ tag }} + {% else %} + No tags yet. + {% endfor %} +
+
+
+
+{% if AuthLevel == "auth" %} + + + + +{% endif %} + {% include 'partial/footer.html' %}