Compare commits

...
22 Commits
Author SHA1 Message Date
venus 75614f6a21 refactored the cli and forensics codebases as classes and into seperate
folders for readability and consistent API access
2026-07-19 03:50:06 -05:00
venus 058b5c1eb5 [GEMINI] Organize test suite to mirror new ctf.cli and ctf.forensics package structures 2026-07-19 03:46:26 -05:00
venus c61bfa11e5 [GEMINI] Update Project Intent in GEMINI.md to indicate user is a student 2026-07-19 03:44:55 -05:00
venus 3c7fedbb13 [GEMINI] Update test imports and mock paths to point to ctf.cli package 2026-07-19 03:39:15 -05:00
venus 0cd24740c8 [GEMINI] Add unit tests for concrete BaseDecoder subclasses and DecodingRegistry 2026-07-19 03:27:18 -05:00
venus 87b5d71a64 [GEMINI] Add unit tests for FormatParser subclasses and ParserFactory 2026-07-19 03:26:45 -05:00
venus 6d92d16dc3 [GEMINI] Add unit tests for OOP RoutingEngine, BaseRoute, and MetadataRoute 2026-07-19 03:19:15 -05:00
venus eae3286da4 [GEMINI] Update workflow rules in GEMINI.md to require full diffs and sequential planning 2026-07-19 03:17:16 -05:00
venus fac6ada1db [GEMINI] Fix exception mock equality assertion in test_main.py 2026-07-19 03:09:33 -05:00
venus eb06206e32 [GEMINI] Fix test_flag_detector_stream_persistence test case 2026-07-19 03:07:27 -05:00
venus 7bd450abed [GEMINI] Refactor tests to use Config class and autouse test isolation fixture 2026-07-19 02:55:11 -05:00
venus 7691b366f2 [GEMINI] Add unit test for debug_exception_handler 2026-07-19 02:30:35 -05:00
venus fe08e0b280 [GEMINI] Add test cases for ctf flag -l/--list and -s/--set options 2026-07-19 01:51:22 -05:00
venus dd69a4c723 [GEMINI] Add integration tests for artifacts files 2026-07-19 01:43:42 -05:00
venus 185482f3d5 cleaning things up by refactoring and combining files 2026-07-19 01:31:09 -05:00
venus 7f00bdb385 [GEMINI] Reorganize tests for FlagDetectorStream and cli_helpers commands 2026-07-19 01:15:29 -05:00
venus b03df0af6f [GEMINI] Add unit tests for is_valid_flag helper 2026-07-19 01:02:33 -05:00
venus 4bddd4a49b [GEMINI] Refactor test suite structure for new helpers and config modules 2026-07-19 00:53:00 -05:00
venus 8b82058cc6 [GEMINI] Add test case for check_for_flag function 2026-07-19 00:40:06 -05:00
venus 993bc2e934 [GEMINI] Write tests for challenge analyse command and metadata route 2026-07-19 00:29:20 -05:00
venus 41b6612320 [GEMINI] Update forensics tests to metadata command and remove ctf inspect 2026-07-19 00:24:21 -05:00
venus 46c317fca1 Update README.md 2026-07-19 00:15:32 -05:00
37 changed files with 2154 additions and 1345 deletions
+4
View File
@@ -5,6 +5,8 @@ This project serves as a centralized workspace for Capture The Flag (CTF) challe
## Project Intent
The primary goal is to use the context of CTF challenges (forensics, crypto, web, etc.) to explore and explain Python concepts, automation, and tooling.
The user is a student learning about software architecture, clean code practices, and managing larger Python projects. All code cleanups and explanations should prioritize clear educational value and explain architectural decisions.
## Core Mandates for Gemini CLI
1. **No Unsolicited Project Code Modifications:** Do NOT write or modify actual application/project implementation code files (e.g. within `src/` or core scripts) unless explicitly directed to do so. Only update test cases (e.g., in `tests/`) and markdown documentation files.
2. **Focus on Explanation:** Prioritize high-signal explanations of Python mechanics, libraries, and documentation.
@@ -18,6 +20,8 @@ The primary goal is to use the context of CTF challenges (forensics, crypto, web
When discussing new implementations, features, or additions:
1. **Discussion**: Discuss implementation details and potential approaches.
2. **The Pitch**: Provide a clear, technical pitch detailing the planned code modifications.
* **Full Code Diff**: The pitch/planning phase must include a full code diff of all proposed modifications to project files.
* **Step-by-Step Execution**: For lists of tasks or multiple changes, the agent must plan and execute each element separately one by one.
3. **Implementation Cycle**: Once the user approves the pitch:
* **Add Test Cases**: Write or update the corresponding test cases in the test files (e.g. `tests/test_forensics.py`) following the SDET instructions.
* **Git Commit Current State**: Create a git commit of the current workspace state (including the new tests) *before* modifying any implementation files. The commit message MUST start with `[GEMINI]` as the author prefix (e.g., `git commit -m "[GEMINI] Write tests for forensics"`).
+4 -2
View File
@@ -1,9 +1,11 @@
# AI-Enhanced CTF Toolchain & Learning Platform
# CTF Toolchain & Learning Platform
A modern, lightweight Capture The Flag (CTF) utility and context manager written in Python. This tool standardizes solving methodologies, captures solver context, inspects file structures, and automatically intercepts flags in the console stream to facilitate agentic solving workflows.
Managed using `uv` as the package manager and built with a modular, pure-Python architecture.
Eventually will implement MCP architecture to agentic solving. Focusing on static tooling for now. The more static tools I can add the less the agents have to do eventually, and the quicker I can solve the challenges by hand.
---
## 🚀 Key Features
@@ -38,7 +40,7 @@ Pure-Python, zero-dependency parsers extract metadata without using heavy extern
* `ctf flag`: Output the last captured flag from the configuration.
### Forensics Commands
* `ctf inspect <file>`: Detailed metadata inspection.
* `ctf forensics metadata <file>`: Detailed metadata inspection.
* `-i, --inode`: Output POSIX file attributes only.
* `-e, --exif`: Output EXIF/XMP/IPTC data and decoded hints.
* `-p, --physical`: Output physical parameters of the target media.
+155
View File
@@ -0,0 +1,155 @@
# src/ctf/analysis.py
# {{{ imports
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
# }}}
# {{{ RouteResult
@dataclass
class RouteResult:
route_name: str
executed: bool
success: bool
message: str
details: dict | None = None
extracted_flags: list[str] | None = None
# }}}
# {{{ AnalysisResult
@dataclass
class AnalysisResult:
success: bool
routes: list[RouteResult]
# }}}
# {{{ BaseRoute
class BaseRoute(ABC):
"""Abstract base class representing an analysis/solver route."""
def __init__(self, route_name: str):
self.route_name = route_name
@abstractmethod
def is_applicable(self, file_path: Path) -> bool:
"""Determines if this route is applicable to the given file."""
pass
@abstractmethod
def execute(self, file_path: Path) -> RouteResult:
"""Executes the analysis route on the target file."""
pass
# }}}
# {{{ MetadataRoute
class MetadataRoute(BaseRoute):
"""Route that extracts metadata and parses comments/EXIF for flags."""
def __init__(self):
super().__init__("File Metadata Extraction")
def is_applicable(self, file_path: Path) -> bool:
# Metadata parsing is applicable to any file
return True
def execute(self, file_path: Path) -> RouteResult:
from ctf.forensics import get_metadata
from ctf.decoding import check_for_flag
file_path = Path(file_path).resolve()
if not file_path.exists():
return RouteResult(
route_name=self.route_name,
executed=True,
success=False,
message=f"File not found: {file_path}"
)
try:
meta = get_metadata(file_path)
extracted_flags = []
# Scan comment, EXIF attributes, and decoded hints
if meta.comment:
extracted_flags.extend(check_for_flag(meta.comment))
for val in meta.exif_data.values():
if isinstance(val, str):
extracted_flags.extend(check_for_flag(val))
for dec_dict in meta.decoded_hints.values():
for dec_val in dec_dict.values():
extracted_flags.extend(check_for_flag(dec_val))
extracted_flags = list(set(extracted_flags))
success = True
if extracted_flags:
msg = f"Metadata inspected. Found {len(extracted_flags)} potential flag(s)."
else:
msg = "Metadata inspected. No obvious flags or decoded hints found."
details = {
"Filename": meta.filename,
"Size": f"{meta.size} bytes",
"Detected Type": meta.detected_type,
"Magic": meta.magic,
"Has EXIF": "Yes" if meta.exif_data else "No",
"Has Comment": "Yes" if meta.comment else "No",
"Has Hints": "Yes" if meta.decoded_hints else "No",
}
return RouteResult(
route_name=self.route_name,
executed=True,
success=success,
message=msg,
details=details,
extracted_flags=extracted_flags
)
except Exception as e:
return RouteResult(
route_name=self.route_name,
executed=True,
success=False,
message=f"Error extracting metadata: {str(e)}"
)
# }}}
# {{{ RoutingEngine
class RoutingEngine:
"""Manages CTF challenge solvers and dynamic route execution."""
def __init__(self):
self.routes: list[BaseRoute] = []
def register_route(self, route: BaseRoute):
self.routes.append(route)
def run_all(self, file_path: Path | None) -> AnalysisResult:
executed_results = []
if not file_path:
# Add a non-executed default result for the metadata route
executed_results.append(RouteResult(
route_name="File Metadata Extraction",
executed=False,
success=False,
message="No file attached to analyze."
))
return AnalysisResult(success=False, routes=executed_results)
resolved_path = Path(file_path).resolve()
for route in self.routes:
if route.is_applicable(resolved_path):
executed_results.append(route.execute(resolved_path))
success = any(r.success and r.extracted_flags for r in executed_results)
return AnalysisResult(success=success, routes=executed_results)
# }}}
# {{{ run_analysis
def run_analysis(file_path: Path | None = None) -> AnalysisResult:
"""Compatibility function that instantiates the RoutingEngine and runs all routes."""
engine = RoutingEngine()
engine.register_route(MetadataRoute())
return engine.run_all(file_path)
# }}}
+34
View File
@@ -0,0 +1,34 @@
# src/ctf/cli/__init__.py
# {{{ imports
import click
import sys
from ctf.cli.basic import basic_group
from ctf.cli.forensics import forensics_group
from ctf.cli.steg import steg_group
from ctf.cli.analyse import analyse_cmd
from ctf.cli.flag import flag_cmd
# }}}
# {{{ debug_exception_handler
def debug_exception_handler(type, value, tb):
"""Unhandled exception hook to start post-mortem debugging in pdb."""
import traceback
import pdb
traceback.print_exception(type, value, tb)
print("\n[!] Unhandled exception. Entering post-mortem debugger...")
pdb.post_mortem(tb)
# }}}
# {{{ cli
@click.group()
@click.option("-d", "--debug", is_flag=True, help="Enable post-mortem debugging on errors.")
def cli(debug):
if debug:
sys.excepthook = debug_exception_handler
cli.add_command(forensics_group)
cli.add_command(basic_group)
cli.add_command(flag_cmd)
cli.add_command(steg_group)
cli.add_command(analyse_cmd)
# }}}
+55
View File
@@ -0,0 +1,55 @@
# src/ctf/cli/analyse.py
# {{{ imports
import click
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from ctf.analysis import run_analysis
# }}}
# {{{ analyse_cmd
@click.command(name="analyse")
@click.option('-f', '--file', type=click.Path(exists=True), help="Path to the challenge file to analyze.")
def analyse_cmd(file):
"""Dynamically analyze and try to solve the active challenge using static routes."""
console = Console()
console.print("[bold blue][*] Starting dynamic challenge analysis...[/bold blue]\n")
file_path = Path(file) if file else None
result = run_analysis(file_path)
for route in result.routes:
title = f"Route: {route.route_name}"
if not route.executed:
console.print(Panel(
f"[yellow]{route.message}[/yellow]",
title=title,
border_style="yellow"
))
elif route.success:
content = f"[green]{route.message}[/green]\n"
if route.details:
content += "\n[bold cyan]Details:[/bold cyan]\n"
for k, v in route.details.items():
content += f" • {k}: {v}\n"
if route.extracted_flags:
content += "\n[bold green]✓ Extracted Flags:[/bold green]\n"
for flag in route.extracted_flags:
content += f" [bold green]{flag}[/bold green]\n"
console.print(Panel(
content.strip(),
title=title,
border_style="green"
))
else:
console.print(Panel(
f"[red]✗ {route.message}[/red]",
title=title,
border_style="red"
))
if result.success:
console.print("\n[bold green]✓ Analysis complete. Challenge solved successfully![/bold green]")
else:
console.print("\n[bold yellow]! Analysis complete. No clear flag found yet.[/bold yellow]")
# }}}
+17 -21
View File
@@ -1,9 +1,8 @@
# functions for commands needed
# src/commands.py
# vim foldmethod=marker
# src/ctf/cli/basic.py
# {{{ imports
import click
from pathlib import Path
# }}}
# {{{ basic_group
# This defines a group with name basic which will nest other comands to be imported in the main loop
@@ -37,20 +36,20 @@ def set_flag_format(pattern: str, original: str):
You can either specify the regex PATTERN directly, or provide an example flag
via the -o/--original option to generate and select from a list of suggested patterns.
"""
from ctf.utils import load_config, write_config, suggest_patterns
from ctf.config import Config
from ctf.utils import suggest_patterns
if pattern is None and original is None:
raise click.UsageError("Either PATTERN positional argument or --original/-o option must be specified.")
if pattern is not None and original is not None:
raise click.UsageError("Cannot specify both PATTERN and --original/-o option.")
config_path = "/home/venus/code/ctf/config.toml"
config = load_config(config_path)
cfg = Config()
if "Competition" not in config:
config["Competition"] = {}
if "Competition" not in cfg.data:
cfg.data["Competition"] = {}
comp_name = config["Competition"].get("competition", "")
comp_name = cfg.data["Competition"].get("competition", "")
if original:
if not original.strip():
@@ -66,8 +65,8 @@ def set_flag_format(pattern: str, original: str):
val = click.prompt("Select a pattern index", type=click.IntRange(1, len(patterns)))
pattern = patterns[val - 1]
config["Competition"]["flag_format"] = pattern
write_config(config, config_path)
cfg.data["Competition"]["flag_format"] = pattern
cfg.save(cfg.data)
click.echo(f"Flag format set to: {pattern}")
# }}}
@@ -77,16 +76,13 @@ def set_flag_format(pattern: str, original: str):
@click.argument("name")
def set_competition(name: str):
"""Set the name of the active competition."""
from ctf.utils import load_config, write_config
from ctf.config import Config
config_path = "/home/venus/code/ctf/config.toml"
config = load_config(config_path)
cfg = Config()
if "Competition" not in config:
config["Competition"] = {}
config["Competition"]["competition"] = name
write_config(config, config_path)
if "Competition" not in cfg.data:
cfg.data["Competition"] = {}
cfg.data["Competition"]["competition"] = name
cfg.save(cfg.data)
click.echo(f"Competition name set to: {name}")
# }}}
+47
View File
@@ -0,0 +1,47 @@
# src/ctf/cli/flag.py
# {{{ imports
import click
from ctf.helpers import detector_state
# }}}
# {{{ flag_cmd
@click.command(name="flag")
@click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.")
@click.option("-l", "--list", "list_format", is_flag=True, help="List the current flag format.")
@click.option("-s", "--set", "set_format", type=str, help="Set the current flag format.")
@click.pass_context
def flag_cmd(ctx, plain, list_format, set_format):
"""Retrieve the last detected flag from config, or list/set flag format options."""
from ctf.config import Config
if list_format:
cfg = Config()
flag_format = cfg.data.get("Competition", {}).get("flag_format", "")
if plain:
click.echo(flag_format)
else:
click.echo(f"Current flag format: {flag_format}" if flag_format else "No flag format configured.")
return
if set_format is not None:
from ctf.commands import set_flag_format
ctx.invoke(set_flag_format, pattern=set_format, original=None)
return
# TODO add support for multiple flags and flag selection
if detector_state["active"] is not None:
detector_state["active"].detecting = False
try:
cfg = Config()
last_flag = cfg.data.get("Competition", {}).get("last_flag", "")
if not last_flag:
if not plain:
click.echo("No flag has been detected yet.")
return
if plain:
click.echo(last_flag)
else:
click.echo(f"Last detected flag: {last_flag}")
finally:
if detector_state["active"] is not None:
detector_state["active"].detecting = True
# }}}
@@ -1,10 +1,9 @@
# src/ctf/cli_forensics.py
# CLI wrapper and rendering layer for forensics commands
# vim foldmethod=marker
# src/ctf/cli/forensics.py
# {{{ imports
import click
from pathlib import Path
from ctf.forensics import get_metadata, COMMON_SIGNATURES
# }}}
# {{{ forensics_group
@click.group(name="forensics")
@@ -13,13 +12,13 @@ def forensics_group():
pass
# }}}
# {{{ inspect
@forensics_group.command()
# {{{ metadata
@forensics_group.command(name="metadata")
@click.argument('path', type=click.Path(exists=True))
@click.option('-i', '--inode', is_flag=True, help="Print only POSIX inode metadata (without EXIF data).")
@click.option('-e', '--exif', is_flag=True, help="Print only EXIF metadata (without POSIX inode metadata).")
@click.option('-p', '--physical', is_flag=True, help="Print only physical image parameters.")
def inspect(path, inode, exif, physical):
def metadata(path, inode, exif, physical):
"""Lists all basic inode metadata and EXIF data about a file"""
flags = [inode, exif, physical]
if sum(flags) > 1:
@@ -140,4 +139,3 @@ def list_signatures():
console.print(table)
# }}}
+1 -1
View File
@@ -1,4 +1,4 @@
# src/ctf/cli_steg.py
# src/ctf/cli/steg.py
# {{{ imports
import click
from pathlib import Path
+38
View File
@@ -0,0 +1,38 @@
# src/ctf/config.py
# {{{ imports
import toml
import os
from pathlib import Path
# }}}
# {{{ Config
class Config:
"""Manages CTF challenge configuration loading and persistence."""
def __init__(self, path: str | Path | None = None):
if path is None:
# Check environment variable first, then fallback to hardcoded path
path = os.environ.get("CTF_CONFIG_PATH", "/home/venus/code/ctf/config.toml")
self.path = Path(path)
self.data = self._load()
def _load(self) -> dict:
if self.path.exists():
try:
return toml.load(self.path)
except Exception:
return {}
return {}
def save(self, data: dict):
self.data = data
self.path.parent.mkdir(parents=True, exist_ok=True)
with open(self.path, "w") as f:
toml.dump(self.data, f)
# }}}
# {{{ exports
# Load config instance to expose default values
_cfg = Config()
competition = _cfg.data.get("Competition", {})
enviroment = _cfg.data.get("Enviroment", {})
# }}}
+98 -65
View File
@@ -1,61 +1,38 @@
# src/ctf/decoding.py
# {{{ imports
from abc import ABC, abstractmethod
from chepy import Chepy
import re
from typing import Dict, Set
from typing import Dict, Set, List
from ctf.helpers import check_for_flag, is_valid_flag
# }}}
# {{{ is_valid_flag
def is_valid_flag(s: str, original: str = "") -> bool:
"""
Checks if the string contains a flag matching the active pattern or a generic pattern.
If original is provided, ensures that the decoded string contains a new unique flag
that was not already present in the original string.
"""
try:
from ctf.utils import load_config
config = load_config()
flag_format = config.get("Competition", {}).get("flag_format", "")
except Exception:
flag_format = ""
patterns = []
if flag_format:
patterns.append(flag_format.strip("^$"))
patterns.append(r"(?i)[a-z0-9_-]+{[a-z0-9_!@#$%^&*()\-+=]+}")
decoded_flags = set()
for pattern in patterns:
for match in re.finditer(pattern, s):
decoded_flags.add(match.group(0))
if not decoded_flags:
return False
if original:
original_flags = set()
for pattern in patterns:
for match in re.finditer(pattern, original):
original_flags.add(match.group(0))
# If there are no new flags in decoded_flags, return False
if not (decoded_flags - original_flags):
return False
# {{{ BaseDecoder
class BaseDecoder(ABC):
"""Abstract base class representing a Chepy-based metadata decoder."""
def __init__(self, name: str, pattern: str, method_name: str):
self.name = name
self.pattern = pattern
self.method_name = method_name
def is_applicable(self, matched_val: str) -> bool:
"""Determines if the matched substring is applicable for decoding (e.g. length checks)."""
return True
# }}}
# {{{ attempt_decode
def attempt_decode(val: str, name: str, pattern: str, method_name: str, len_check=None, validator=None) -> str | None:
"""Helper to run regex matching, dynamic Chepy decoding, and string printability checks."""
# Find all matches sequentially and try decoding
for match in re.finditer(pattern, val):
def validate(self, decoded_str: str, original_str: str) -> bool:
"""Validates if the decoded string is expected/correct (e.g. flag regex checks)."""
return True
def attempt_single(self, val: str) -> str | None:
"""Searches val for matches and returns the first successfully decoded printable string."""
for match in re.finditer(self.pattern, val):
matched_val = match.group(0)
if len_check and not len_check(matched_val):
if not self.is_applicable(matched_val):
continue
try:
chepy_inst = Chepy(matched_val)
method = getattr(chepy_inst, method_name)
method = getattr(chepy_inst, self.method_name)
decoded = method().state
if isinstance(decoded, bytes):
dec_str = decoded.decode("utf-8")
@@ -65,7 +42,7 @@ def attempt_decode(val: str, name: str, pattern: str, method_name: str, len_chec
dec_str = ""
if dec_str.strip() and all(32 <= ord(c) < 127 or c in "\r\n\t" for c in dec_str):
if validator and not validator(dec_str, val):
if not self.validate(dec_str, val):
continue
return dec_str
except Exception:
@@ -73,9 +50,58 @@ def attempt_decode(val: str, name: str, pattern: str, method_name: str, len_chec
return None
# }}}
# {{{ try_decode_metadata
def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10, seen: Set[str] = None) -> Dict[str, str]:
"""Attempts to decode a metadata value using defined formats recursively."""
# {{{ Concrete Decoders
class HexDecoder(BaseDecoder):
def __init__(self):
super().__init__("hex", r"[0-9a-fA-F]{4,}", "from_hex")
def is_applicable(self, matched_val: str) -> bool:
return len(matched_val) % 2 == 0
class Base64Decoder(BaseDecoder):
def __init__(self):
super().__init__("base64", r"[A-Za-z0-9+/=]{4,}", "from_base64")
def is_applicable(self, matched_val: str) -> bool:
return len(matched_val) % 4 == 0
class Base32Decoder(BaseDecoder):
def __init__(self):
super().__init__("base32", r"[A-Za-z2-7=]{4,}", "from_base32")
def is_applicable(self, matched_val: str) -> bool:
return len(matched_val) % 8 == 0
class BinaryDecoder(BaseDecoder):
def __init__(self):
super().__init__("binary", r"[01]{8,}", "from_binary")
def is_applicable(self, matched_val: str) -> bool:
return len(matched_val) % 8 == 0
class UrlDecoder(BaseDecoder):
def __init__(self):
super().__init__("url", r"(?:%[0-9a-fA-F]{2})+", "from_url_encoding")
class Rot13Decoder(BaseDecoder):
def __init__(self):
super().__init__("rot13", r"[\x20-\x7E\s]{4,}", "rot_13")
def validate(self, decoded_str: str, original_str: str) -> bool:
return is_valid_flag(decoded_str, original_str)
class ReversedDecoder(BaseDecoder):
def __init__(self):
super().__init__("reversed", r"[\x20-\x7E\s]{4,}", "reverse")
def validate(self, decoded_str: str, original_str: str) -> bool:
return is_valid_flag(decoded_str, original_str)
# }}}
# {{{ DecodingRegistry
class DecodingRegistry:
"""Orchestrates decoding metadata using registered BaseDecoder classes."""
def __init__(self):
self.decoders: List[BaseDecoder] = []
def register_decoder(self, decoder: BaseDecoder):
self.decoders.append(decoder)
def try_decode(self, val: str, current_chain: str = "", max_depth: int = 10, seen: Set[str] = None) -> Dict[str, str]:
if seen is None:
seen = set()
@@ -88,26 +114,14 @@ def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10,
return results
seen.add(cleaned_val)
# We define our decoders here using unanchored patterns. Note: for rot13 and reversed, we only allow them
# if they produce a valid flag to avoid spamming everyday metadata fields.
formats = [
("hex", r"[0-9a-fA-F]{4,}", "from_hex", lambda s: len(s) % 2 == 0, None),
("base64", r"[A-Za-z0-9+/=]{4,}", "from_base64", lambda s: len(s) % 4 == 0, None),
("base32", r"[A-Za-z2-7=]{4,}", "from_base32", lambda s: len(s) % 8 == 0, None),
("binary", r"[01]{8,}", "from_binary", lambda s: len(s) % 8 == 0, None),
("url", r"(?:%[0-9a-fA-F]{2})+", "from_url_encoding", None, None),
("rot13", r"[\x20-\x7E\s]{4,}", "rot_13", None, is_valid_flag),
("reversed", r"[\x20-\x7E\s]{4,}", "reverse", None, is_valid_flag),
]
for name, pattern, method, len_check, validator in formats:
decoded_val = attempt_decode(cleaned_val, name, pattern, method, len_check, validator)
for decoder in self.decoders:
decoded_val = decoder.attempt_single(cleaned_val)
if decoded_val is not None:
chain_name = f"{current_chain}->{name}" if current_chain else name
chain_name = f"{current_chain}->{decoder.name}" if current_chain else decoder.name
results[chain_name] = decoded_val
# Recurse with copy of seen to allow different branching paths to process same strings
nested_results = try_decode_metadata(
# Recurse
nested_results = self.try_decode(
decoded_val,
current_chain=chain_name,
max_depth=max_depth - 1,
@@ -116,4 +130,23 @@ def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10,
results.update(nested_results)
return results
@classmethod
def get_default_registry(cls) -> "DecodingRegistry":
registry = cls()
registry.register_decoder(HexDecoder())
registry.register_decoder(Base64Decoder())
registry.register_decoder(Base32Decoder())
registry.register_decoder(BinaryDecoder())
registry.register_decoder(UrlDecoder())
registry.register_decoder(Rot13Decoder())
registry.register_decoder(ReversedDecoder())
return registry
# }}}
# {{{ try_decode_metadata
def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10, seen: Set[str] = None) -> Dict[str, str]:
"""Compatibility wrapper that routes decoding requests to the default registry."""
registry = DecodingRegistry.get_default_registry()
return registry.try_decode(val, current_chain, max_depth, seen)
# }}}
+32
View File
@@ -0,0 +1,32 @@
# src/ctf/forensics/__init__.py
# {{{ imports
from ctf.forensics.base import FileMetadata, FormatParser, ParserFactory, FallbackParser, COMMON_SIGNATURES
from ctf.forensics.jpeg import JpegParser
from ctf.forensics.png import PngParser
from ctf.forensics.gif import GifParser
from ctf.forensics.metadata import get_metadata
from pathlib import Path
from typing import Dict, Any
# }}}
# {{{ get_exif
def get_exif(path: Path) -> Dict[str, Any]:
"""Compatibility wrapper for extracting EXIF tags."""
try:
with open(path, "rb") as f:
data = f.read()
return ParserFactory.get_parser(data).get_exif_tags(data)
except Exception:
return {}
# }}}
# {{{ get_comment
def get_comment(path: Path) -> str:
"""Compatibility wrapper for extracting comments."""
try:
with open(path, "rb") as f:
data = f.read()
return ParserFactory.get_parser(data).get_comment(data)
except Exception:
return ""
# }}}
+114
View File
@@ -0,0 +1,114 @@
# src/ctf/forensics/base.py
# {{{ imports
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, Any
# }}}
# {{{ Common Signatures
COMMON_SIGNATURES = {
b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]),
b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]),
b"%PDF": ("PDF Document", [".pdf"]),
b"PK\x03\x04": ("ZIP Archive", [".zip"]),
b"\x7fELF": ("ELF Executable", [".elf"]),
b"MZ": ("PE Executable", [".exe", ".dll"]),
b"GIF87a": ("GIF Image", [".gif"]),
b"GIF89a": ("GIF Image", [".gif"]),
b"7z\xbc\xaf\x27\x1c": ("7-Zip Archive", [".7z"]),
b"\x1f\x8b": ("GZIP Archive", [".gz"]),
b"Rar!\x1a\x07\x00": ("RAR Archive", [".rar"]),
b"Rar!\x1a\x07\x01\x00": ("RAR Archive", [".rar"]),
b"BZh": ("BZIP2 Archive", [".bz2"]),
b"BM": ("BMP Image", [".bmp"]),
b"ID3": ("MP3 Audio", [".mp3"]),
}
# }}}
# {{{ FileMetadata
@dataclass
class FileMetadata:
filename: str
size: int
magic: str
extension: str
detected_type: str
# POSIX Permissions
permissions_octal: str
permissions_symbolic: str
# Ownership Identity
owner_uid: int
owner_username: str
owner_gid: int
owner_groupname: str
# Allocation Metrics
allocated_size: int
# Hard Links
hard_links: int
# Inode & Device Identifiers
inode: int
device: int
# Extended Attributes
extended_attributes: Dict[str, str] = field(default_factory=dict)
# EXIF Data
exif_data: Dict[str, Any] = field(default_factory=dict)
# Physical Image Data
physical_data: Dict[str, Any] = field(default_factory=dict)
# Decoded metadata hints
decoded_hints: Dict[str, Dict[str, str]] = field(default_factory=dict)
# JPEG/PNG Comments
comment: str = ""
# }}}
# {{{ FormatParser
class FormatParser(ABC):
"""Abstract base class representing a file format metadata parser."""
@abstractmethod
def is_matching(self, data: bytes) -> bool:
pass
@abstractmethod
def parse_physical(self, data: bytes) -> Dict[str, Any]:
pass
@abstractmethod
def get_comment(self, data: bytes) -> str:
pass
@abstractmethod
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
pass
# }}}
# {{{ FallbackParser
class FallbackParser(FormatParser):
def is_matching(self, data: bytes) -> bool:
return True
def parse_physical(self, data: bytes) -> Dict[str, Any]:
return {}
def get_comment(self, data: bytes) -> str:
return ""
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
return {}
# }}}
# {{{ ParserFactory
class ParserFactory:
@staticmethod
def get_parser(data: bytes) -> FormatParser:
from ctf.forensics.jpeg import JpegParser
from ctf.forensics.png import PngParser
from ctf.forensics.gif import GifParser
parsers = [JpegParser(), PngParser(), GifParser()]
for parser in parsers:
if parser.is_matching(data):
return parser
return FallbackParser()
# }}}
+98
View File
@@ -0,0 +1,98 @@
# src/ctf/forensics/gif.py
# {{{ imports
import struct
from typing import Dict, Any
from ctf.forensics.base import FormatParser
# }}}
# {{{ GifParser
class GifParser(FormatParser):
def is_matching(self, data: bytes) -> bool:
return data.startswith(b"GIF87a") or data.startswith(b"GIF89a")
def parse_physical(self, data: bytes) -> Dict[str, Any]:
return get_gif_physical(data)
def get_comment(self, data: bytes) -> str:
return extract_gif_comments(data)
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
return {}
# }}}
# {{{ get_gif_physical
def get_gif_physical(data: bytes) -> Dict[str, Any]:
"""Parses GIF logical screen descriptor for physical size."""
physical = {}
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return physical
if len(data) >= 10:
width = struct.unpack("<H", data[6:8])[0]
height = struct.unpack("<H", data[8:10])[0]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Encoding Process"] = "LZW"
return physical
# }}}
# {{{ extract_gif_comments
def extract_gif_comments(data: bytes) -> str:
"""Extracts comments from GIF comment extensions (\x21\xfe) sequentially."""
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return ""
comments = []
idx = 6
if idx + 7 > len(data):
return ""
packed_byte = data[idx+4]
global_color_table_present = bool(packed_byte & 0x80)
global_color_table_size = 2 ** ((packed_byte & 0x07) + 1)
idx += 7
if global_color_table_present:
idx += 3 * global_color_table_size
while idx < len(data) - 2:
intro = data[idx]
if intro == 0x21: # Extension Introducer
ext_label = data[idx+1]
idx += 2
if ext_label == 0xfe: # Comment Extension
comment_parts = []
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
if idx + block_len <= len(data):
comment_parts.append(data[idx:idx+block_len].decode("utf-8", errors="ignore"))
idx += block_len
else:
break
comments.append("".join(comment_parts))
else:
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x2c: # Image Descriptor
if idx + 10 > len(data):
break
packed = data[idx+9]
local_table = bool(packed & 0x80)
local_table_size = 2 ** ((packed & 0x07) + 1)
idx += 10
if local_table:
idx += 3 * local_table_size
idx += 1
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x3b: # Trailer
break
else:
idx += 1
return "\n".join(comments)
# }}}
@@ -1,225 +1,35 @@
# src/ctf/forensics.py
# Library for forensic analysis (pure functions only)
# vim foldmethod=marker
from dataclasses import dataclass, field
from pathlib import Path
import stat
import os
import sys
# src/ctf/forensics/jpeg.py
# {{{ imports
import struct
import xml.etree.ElementTree as ET
from typing import List, Dict, Any
try:
import pwd
import grp
except ImportError:
pwd = None
grp = None
# {{{ Common Signatures
COMMON_SIGNATURES = {
b"\x89PNG\r\n\x1a\n": ("PNG Image", [".png"]),
b"\xff\xd8\xff": ("JPEG Image", [".jpg", ".jpeg"]),
b"%PDF": ("PDF Document", [".pdf"]),
b"PK\x03\x04": ("ZIP Archive", [".zip"]),
b"\x7fELF": ("ELF Executable", [".elf"]),
b"MZ": ("PE Executable", [".exe", ".dll"]),
b"GIF87a": ("GIF Image", [".gif"]),
b"GIF89a": ("GIF Image", [".gif"]),
b"7z\xbc\xaf\x27\x1c": ("7-Zip Archive", [".7z"]),
b"\x1f\x8b": ("GZIP Archive", [".gz"]),
b"Rar!\x1a\x07\x00": ("RAR Archive", [".rar"]),
b"Rar!\x1a\x07\x01\x00": ("RAR Archive", [".rar"]),
b"BZh": ("BZIP2 Archive", [".bz2"]),
b"BM": ("BMP Image", [".bmp"]),
b"ID3": ("MP3 Audio", [".mp3"]),
}
from typing import Dict, Any
from ctf.forensics.base import FormatParser
# }}}
# {{{ FileMetadata
@dataclass
class FileMetadata:
filename: str
size: int
magic: str
extension: str
detected_type: str
# Task 1: POSIX Permissions
permissions_octal: str
permissions_symbolic: str
# Task 2: Ownership Identity
owner_uid: int
owner_username: str
owner_gid: int
owner_groupname: str
# Task 3: Allocation Metrics
allocated_size: int
# Task 4: Hard Links
hard_links: int
# Task 5: Inode & Device Identifiers
inode: int
device: int
# Task 6: Extended Attributes
extended_attributes: Dict[str, str] = field(default_factory=dict)
# EXIF Data
exif_data: Dict[str, Any] = field(default_factory=dict)
# Physical Image Data
physical_data: Dict[str, Any] = field(default_factory=dict)
# Decoded metadata hints
decoded_hints: Dict[str, Dict[str, str]] = field(default_factory=dict)
# JPEG/PNG Comments
comment: str = ""
# }}}
# {{{ get_metadata
def get_metadata(path: Path) -> FileMetadata:
"""Extracts metadata attributes from a file without any console rendering."""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"File not found: {p}")
stat_info = p.stat()
# Apparent size & extension
size = stat_info.st_size
extension = p.suffix
# Magic bytes
# {{{ JpegParser
class JpegParser(FormatParser):
def is_matching(self, data: bytes) -> bool:
return data.startswith(b"\xff\xd8\xff")
def parse_physical(self, data: bytes) -> Dict[str, Any]:
return get_jpeg_physical(data)
def get_comment(self, data: bytes) -> str:
return extract_jpeg_comment(data)
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
tags = {}
exif_data = extract_jpeg_exif(data)
if exif_data:
try:
with open(p, 'rb') as f:
magic = f.read(8).hex().upper()
except Exception:
magic = ""
detected_type = "Unknown"
tags.update(parse_tiff(exif_data))
except Exception: pass
xmp_str = extract_jpeg_xmp(data)
if xmp_str:
try:
magic_bytes = bytes.fromhex(magic)
for signature, (type_name, exts) in COMMON_SIGNATURES.items():
if magic_bytes.startswith(signature):
detected_type = type_name
break
except Exception:
pass
# POSIX Permissions
mode = stat_info.st_mode
permissions_octal = oct(stat.S_IMODE(mode))
permissions_symbolic = stat.filemode(mode)
# Ownership Identity
owner_uid = stat_info.st_uid
owner_gid = stat_info.st_gid
owner_username = str(owner_uid)
owner_groupname = str(owner_gid)
if pwd is not None:
tags.update(parse_xmp(xmp_str))
except Exception: pass
try:
owner_username = pwd.getpwuid(owner_uid).pw_name
except KeyError:
pass
if grp is not None:
try:
owner_groupname = grp.getgrgid(owner_gid).gr_name
except KeyError:
pass
# Allocation Metrics
if hasattr(stat_info, "st_blocks"):
allocated_size = stat_info.st_blocks * 512
else:
allocated_size = size
# Hard Links
hard_links = stat_info.st_nlink
# Inode & Device Identifiers
inode = stat_info.st_ino
device = stat_info.st_dev
# Extended Attributes
extended_attributes = {}
if hasattr(os, "listxattr") and hasattr(os, "getxattr"):
try:
attrs = os.listxattr(p)
for attr in attrs:
try:
val = os.getxattr(p, attr)
extended_attributes[attr] = val.decode("utf-8", errors="ignore")
except OSError:
pass
except OSError:
pass
exif_data = get_exif(p)
comment = get_comment(p)
physical_data = {}
try:
with open(p, "rb") as f:
file_bytes = f.read()
if file_bytes.startswith(b"\xff\xd8\xff"):
physical_data = get_jpeg_physical(file_bytes)
elif file_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
physical_data = get_png_physical(file_bytes)
elif file_bytes.startswith(b"GIF87a") or file_bytes.startswith(b"GIF89a"):
physical_data = get_gif_physical(file_bytes)
except Exception:
pass
decoded_hints = {}
try:
from ctf.decoding import try_decode_metadata
if comment:
dec = try_decode_metadata(comment)
if dec:
decoded_hints["Comment"] = dec
for tag, val in exif_data.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"EXIF:{tag}"] = dec
for attr, val in extended_attributes.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"xattr:{attr}"] = dec
except Exception:
pass
return FileMetadata(
filename=p.name,
size=size,
magic=magic,
extension=extension,
detected_type=detected_type,
permissions_octal=permissions_octal,
permissions_symbolic=permissions_symbolic,
owner_uid=owner_uid,
owner_username=owner_username,
owner_gid=owner_gid,
owner_groupname=owner_groupname,
allocated_size=allocated_size,
hard_links=hard_links,
inode=inode,
device=device,
extended_attributes=extended_attributes,
exif_data=exif_data,
physical_data=physical_data,
decoded_hints=decoded_hints,
comment=comment
)
tags.update(extract_jpeg_iptc(data))
except Exception: pass
return tags
# }}}
# {{{ EXIF tags
@@ -260,7 +70,8 @@ EXIF_TAGS = {
0xa403: "WhiteBalance",
0xa405: "FocalLengthIn35mmFilm",
0xa406: "SceneCaptureType",
}# }}}
}
# }}}
# {{{ parse_tiff
def parse_tiff(data: bytes) -> Dict[str, Any]:
@@ -371,7 +182,6 @@ def extract_jpeg_exif(data: bytes) -> bytes:
marker = data[idx+1]
if marker == 0xd9: # EOI
break
# Markers without length parameters
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
idx += 2
continue
@@ -386,75 +196,6 @@ def extract_jpeg_exif(data: bytes) -> bytes:
return b""
# }}}
# {{{ extract_png_exif
def extract_png_exif(data: bytes) -> bytes:
"""Extracts raw Exif/TIFF payload from PNG eXIf chunks."""
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return b""
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
if chunk_type == b"eXIf":
return data[idx+8:idx+8+length]
elif chunk_type == b"IEND":
break
idx += 12 + length
return b""
# }}}
# {{{ get_exif
def get_exif(path: Path) -> Dict[str, Any]:
"""Reads file, extracts Exif and XMP segments, and parses them to a tag dictionary."""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return {}
tags = {}
# 1. Parse standard EXIF
exif_data = b""
if data.startswith(b"\xff\xd8\xff"):
exif_data = extract_jpeg_exif(data)
elif data.startswith(b"\x89PNG\r\n\x1a\n"):
exif_data = extract_png_exif(data)
if exif_data:
try:
tags.update(parse_tiff(exif_data))
except Exception:
pass
# 2. Parse XMP (Adobe)
xmp_str = ""
if data.startswith(b"\xff\xd8\xff"):
xmp_str = extract_jpeg_xmp(data)
if xmp_str:
try:
tags.update(parse_xmp(xmp_str))
except Exception:
pass
# 3. Parse IPTC (Photoshop APP13) if JPEG
if data.startswith(b"\xff\xd8\xff"):
try:
tags.update(extract_jpeg_iptc(data))
except Exception:
pass
# 4. Parse PNG text chunks if PNG
if data.startswith(b"\x89PNG\r\n\x1a\n"):
try:
tags.update(parse_png_text_chunks(data))
except Exception:
pass
return tags
# }}}
# {{{ extract_jpeg_comment
def extract_jpeg_comment(data: bytes) -> str:
"""Extracts raw comment string from JPEG COM (0xfe) segments."""
@@ -479,22 +220,6 @@ def extract_jpeg_comment(data: bytes) -> str:
return ""
# }}}
# {{{ get_comment
def get_comment(path: Path) -> str:
"""Reads file, checks headers, and extracts JPEG COM / GIF comments."""
try:
with open(path, "rb") as f:
data = f.read()
except Exception:
return ""
if data.startswith(b"\xff\xd8\xff"):
return extract_jpeg_comment(data)
elif data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
return extract_gif_comments(data)
return ""
# }}}
# {{{ NS_map
NS_MAP = {
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf",
@@ -654,84 +379,6 @@ def get_jpeg_physical(data: bytes) -> Dict[str, Any]:
return physical
# }}}
# {{{ get_png_physical
def get_png_physical(data: bytes) -> Dict[str, Any]:
"""Parses PNG IHDR and pHYs chunks for physical properties."""
physical = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return physical
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"IHDR":
if len(chunk_data) >= 13:
width = struct.unpack(">I", chunk_data[0:4])[0]
height = struct.unpack(">I", chunk_data[4:8])[0]
bit_depth = chunk_data[8]
color_type = chunk_data[9]
compression = chunk_data[10]
filter_method = chunk_data[11]
interlace = chunk_data[12]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Bit Depth"] = f"{bit_depth} bits/sample"
color_types = {
0: "Grayscale",
2: "Truecolor",
3: "Indexed-color",
4: "Grayscale with Alpha",
6: "Truecolor with Alpha"
}
physical["Color Type"] = color_types.get(color_type, f"Unknown ({color_type})")
if compression == 0:
physical["Encoding Process"] = "Deflate/Inflate"
else:
physical["Encoding Process"] = f"Unknown compression ({compression})"
interlace_methods = {
0: "Noninterlaced",
1: "Adam7 Interlace"
}
physical["Interlace Method"] = interlace_methods.get(interlace, f"Unknown ({interlace})")
elif chunk_type == b"pHYs":
if len(chunk_data) >= 9:
x_res = struct.unpack(">I", chunk_data[0:4])[0]
y_res = struct.unpack(">I", chunk_data[4:8])[0]
unit = chunk_data[8]
unit_str = " meters" if unit == 1 else " (unknown unit)"
physical["Pixels Per Unit X"] = f"{x_res}{unit_str}"
physical["Pixels Per Unit Y"] = f"{y_res}{unit_str}"
elif chunk_type == b"IEND":
break
idx += 12 + length
return physical
# }}}
# {{{ get_gif_physical
def get_gif_physical(data: bytes) -> Dict[str, Any]:
"""Parses GIF logical screen descriptor for physical size."""
physical = {}
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return physical
if len(data) >= 10:
width = struct.unpack("<H", data[6:8])[0]
height = struct.unpack("<H", data[8:10])[0]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Encoding Process"] = "LZW"
return physical
# }}}
# {{{ extract_jpeg_iptc
def extract_jpeg_iptc(data: bytes) -> Dict[str, str]:
"""Extracts IPTC/NAA metadata (Record 2) from Photoshop APP13 segments."""
@@ -819,132 +466,3 @@ def extract_jpeg_iptc(data: bytes) -> Dict[str, str]:
return iptc_metadata
# }}}
# {{{ parse_png_text_chunks
def parse_png_text_chunks(data: bytes) -> Dict[str, str]:
"""Decompresses and extracts standard tEXt/zTXt/iTXt PNG text metadata."""
import zlib
text_metadata = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return text_metadata
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"tEXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
val = parts[1].decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"zTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) > 1:
comp_method = remaining[0]
comp_text = remaining[1:]
if comp_method == 0:
try:
val = zlib.decompress(comp_text).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
elif chunk_type == b"iTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) >= 2:
comp_flag = remaining[0]
comp_method = remaining[1]
rem = remaining[2:]
parts2 = rem.split(b"\x00", 1)
if len(parts2) == 2:
lang_tag = parts2[0].decode("utf-8", errors="ignore")
rem = parts2[1]
parts3 = rem.split(b"\x00", 1)
if len(parts3) == 2:
trans_key = parts3[0].decode("utf-8", errors="ignore")
text_bytes = parts3[1]
if comp_flag == 1 and comp_method == 0:
try:
val = zlib.decompress(text_bytes).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
else:
val = text_bytes.decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"IEND":
break
idx += 12 + length
return text_metadata
# }}}
# {{{ extract_gif_comments
def extract_gif_comments(data: bytes) -> str:
"""Extracts comments from GIF comment extensions (\x21\xfe) sequentially."""
if not (data.startswith(b"GIF87a") or data.startswith(b"GIF89a")):
return ""
comments = []
idx = 6
if idx + 7 > len(data):
return ""
packed_byte = data[idx+4]
global_color_table_present = bool(packed_byte & 0x80)
global_color_table_size = 2 ** ((packed_byte & 0x07) + 1)
idx += 7
if global_color_table_present:
idx += 3 * global_color_table_size
while idx < len(data) - 2:
intro = data[idx]
if intro == 0x21: # Extension Introducer
ext_label = data[idx+1]
idx += 2
if ext_label == 0xfe: # Comment Extension
comment_parts = []
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
if idx + block_len <= len(data):
comment_parts.append(data[idx:idx+block_len].decode("utf-8", errors="ignore"))
idx += block_len
else:
break
comments.append("".join(comment_parts))
else:
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x2c: # Image Descriptor
if idx + 10 > len(data):
break
packed = data[idx+9]
local_table = bool(packed & 0x80)
local_table_size = 2 ** ((packed & 0x07) + 1)
idx += 10
if local_table:
idx += 3 * local_table_size
idx += 1
while idx < len(data):
block_len = data[idx]
idx += 1
if block_len == 0:
break
idx += block_len
elif intro == 0x3b: # Trailer
break
else:
idx += 1
return "\n".join(comments)
# }}}
+139
View File
@@ -0,0 +1,139 @@
# src/ctf/forensics/metadata.py
# {{{ imports
from pathlib import Path
import os
import stat
from ctf.forensics.base import FileMetadata, COMMON_SIGNATURES, ParserFactory
try:
import pwd
import grp
except ImportError:
pwd = None
grp = None
# }}}
# {{{ get_metadata
def get_metadata(path: Path) -> FileMetadata:
"""Extracts metadata attributes from a file without any console rendering."""
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"File not found: {p}")
stat_info = p.stat()
size = stat_info.st_size
extension = p.suffix
try:
with open(p, 'rb') as f:
magic = f.read(8).hex().upper()
except Exception:
magic = ""
detected_type = "Unknown"
try:
magic_bytes = bytes.fromhex(magic)
for signature, (type_name, exts) in COMMON_SIGNATURES.items():
if magic_bytes.startswith(signature):
detected_type = type_name
break
except Exception:
pass
mode = stat_info.st_mode
permissions_octal = oct(stat.S_IMODE(mode))
permissions_symbolic = stat.filemode(mode)
owner_uid = stat_info.st_uid
owner_gid = stat_info.st_gid
owner_username = str(owner_uid)
owner_groupname = str(owner_gid)
if pwd is not None:
try:
owner_username = pwd.getpwuid(owner_uid).pw_name
except KeyError:
pass
if grp is not None:
try:
owner_groupname = grp.getgrgid(owner_gid).gr_name
except KeyError:
pass
if hasattr(stat_info, "st_blocks"):
allocated_size = stat_info.st_blocks * 512
else:
allocated_size = size
hard_links = stat_info.st_nlink
inode = stat_info.st_ino
device = stat_info.st_dev
extended_attributes = {}
if hasattr(os, "listxattr") and hasattr(os, "getxattr"):
try:
attrs = os.listxattr(p)
for attr in attrs:
try:
val = os.getxattr(p, attr)
extended_attributes[attr] = val.decode("utf-8", errors="ignore")
except OSError:
pass
except OSError:
pass
try:
with open(p, "rb") as f:
file_bytes = f.read()
parser = ParserFactory.get_parser(file_bytes)
exif_data = parser.get_exif_tags(file_bytes)
comment = parser.get_comment(file_bytes)
physical_data = parser.parse_physical(file_bytes)
except Exception:
exif_data = {}
comment = ""
physical_data = {}
decoded_hints = {}
try:
from ctf.decoding import try_decode_metadata
if comment:
dec = try_decode_metadata(comment)
if dec:
decoded_hints["Comment"] = dec
for tag, val in exif_data.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"EXIF:{tag}"] = dec
for attr, val in extended_attributes.items():
if isinstance(val, str):
dec = try_decode_metadata(val)
if dec:
decoded_hints[f"xattr:{attr}"] = dec
except Exception:
pass
return FileMetadata(
filename=p.name,
size=size,
magic=magic,
extension=extension,
detected_type=detected_type,
permissions_octal=permissions_octal,
permissions_symbolic=permissions_symbolic,
owner_uid=owner_uid,
owner_username=owner_username,
owner_gid=owner_gid,
owner_groupname=owner_groupname,
allocated_size=allocated_size,
hard_links=hard_links,
inode=inode,
device=device,
extended_attributes=extended_attributes,
exif_data=exif_data,
physical_data=physical_data,
decoded_hints=decoded_hints,
comment=comment
)
# }}}
+171
View File
@@ -0,0 +1,171 @@
# src/ctf/forensics/png.py
# {{{ imports
import struct
from typing import Dict, Any
from ctf.forensics.base import FormatParser
# }}}
# {{{ PngParser
class PngParser(FormatParser):
def is_matching(self, data: bytes) -> bool:
return data.startswith(b"\x89PNG\r\n\x1a\n")
def parse_physical(self, data: bytes) -> Dict[str, Any]:
return get_png_physical(data)
def get_comment(self, data: bytes) -> str:
return ""
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
tags = {}
exif_data = extract_png_exif(data)
if exif_data:
try:
from ctf.forensics.jpeg import parse_tiff
tags.update(parse_tiff(exif_data))
except Exception: pass
try:
tags.update(parse_png_text_chunks(data))
except Exception: pass
return tags
# }}}
# {{{ extract_png_exif
def extract_png_exif(data: bytes) -> bytes:
"""Extracts raw Exif/TIFF payload from PNG eXIf chunks."""
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return b""
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
if chunk_type == b"eXIf":
return data[idx+8:idx+8+length]
elif chunk_type == b"IEND":
break
idx += 12 + length
return b""
# }}}
# {{{ get_png_physical
def get_png_physical(data: bytes) -> Dict[str, Any]:
"""Parses PNG IHDR and pHYs chunks for physical properties."""
physical = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return physical
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"IHDR":
if len(chunk_data) >= 13:
width = struct.unpack(">I", chunk_data[0:4])[0]
height = struct.unpack(">I", chunk_data[4:8])[0]
bit_depth = chunk_data[8]
color_type = chunk_data[9]
compression = chunk_data[10]
filter_method = chunk_data[11]
interlace = chunk_data[12]
physical["Image Size"] = f"{width}x{height}"
mp = (width * height) / 1000000.0
physical["Megapixels"] = f"{mp:.1f}"
physical["Bit Depth"] = f"{bit_depth} bits/sample"
color_types = {
0: "Grayscale",
2: "Truecolor",
3: "Indexed-color",
4: "Grayscale with Alpha",
6: "Truecolor with Alpha"
}
physical["Color Type"] = color_types.get(color_type, f"Unknown ({color_type})")
if compression == 0:
physical["Encoding Process"] = "Deflate/Inflate"
else:
physical["Encoding Process"] = f"Unknown compression ({compression})"
interlace_methods = {
0: "Noninterlaced",
1: "Adam7 Interlace"
}
physical["Interlace Method"] = interlace_methods.get(interlace, f"Unknown ({interlace})")
elif chunk_type == b"pHYs":
if len(chunk_data) >= 9:
x_res = struct.unpack(">I", chunk_data[0:4])[0]
y_res = struct.unpack(">I", chunk_data[4:8])[0]
unit = chunk_data[8]
unit_str = " meters" if unit == 1 else " (unknown unit)"
physical["Pixels Per Unit X"] = f"{x_res}{unit_str}"
physical["Pixels Per Unit Y"] = f"{y_res}{unit_str}"
elif chunk_type == b"IEND":
break
idx += 12 + length
return physical
# }}}
# {{{ parse_png_text_chunks
def parse_png_text_chunks(data: bytes) -> Dict[str, str]:
"""Decompresses and extracts standard tEXt/zTXt/iTXt PNG text metadata."""
import zlib
text_metadata = {}
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
return text_metadata
idx = 8
while idx < len(data) - 8:
length = struct.unpack(">I", data[idx:idx+4])[0]
chunk_type = data[idx+4:idx+8]
chunk_data = data[idx+8:idx+8+length]
if chunk_type == b"tEXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
val = parts[1].decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"zTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) > 1:
comp_method = remaining[0]
comp_text = remaining[1:]
if comp_method == 0:
try:
val = zlib.decompress(comp_text).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
elif chunk_type == b"iTXt":
parts = chunk_data.split(b"\x00", 1)
if len(parts) == 2:
key = parts[0].decode("utf-8", errors="ignore")
remaining = parts[1]
if len(remaining) >= 2:
comp_flag = remaining[0]
comp_method = remaining[1]
rem = remaining[2:]
parts2 = rem.split(b"\x00", 1)
if len(parts2) == 2:
lang_tag = parts2[0].decode("utf-8", errors="ignore")
rem = parts2[1]
parts3 = rem.split(b"\x00", 1)
if len(parts3) == 2:
trans_key = parts3[0].decode("utf-8", errors="ignore")
text_bytes = parts3[1]
if comp_flag == 1 and comp_method == 0:
try:
val = zlib.decompress(text_bytes).decode("utf-8", errors="ignore")
text_metadata[key] = val
except Exception:
pass
else:
val = text_bytes.decode("utf-8", errors="ignore")
text_metadata[key] = val
elif chunk_type == b"IEND":
break
idx += 12 + length
return text_metadata
# }}}
+95
View File
@@ -0,0 +1,95 @@
# {{{ imports
import re
# }}}
detector_state = {"active": None}
# {{{ check_for_flag
def check_for_flag(text: str, pattern: str | None = None) -> list[str]:
"""Scans the text for all flags matching the provided pattern, the active format, or a generic pattern.
Returns a list of matching flags found.
"""
if not text:
return []
patterns = []
if pattern:
patterns.append(pattern.strip("^$"))
else:
try:
from ctf.config import Config
cfg = Config()
flag_format = cfg.data.get("Competition", {}).get("flag_format", "")
except Exception:
flag_format = ""
if flag_format:
patterns.append(flag_format.strip("^$"))
patterns.append(r"(?i)[a-z0-9_-]+{[a-z0-9_!@#$%^&*()\-+=]+}")
found = []
for pat in patterns:
for match in re.finditer(pat, text):
found.append(match.group(0))
return list(set(found))
# }}}
# {{{ is_valid_flag
def is_valid_flag(s: str, original: str = "") -> bool:
"""
Checks if the string contains a flag matching the active pattern or a generic pattern.
If original is provided, ensures that the decoded string contains a new unique flag
that was not already present in the original string.
"""
flags = check_for_flag(s)
if not flags:
return False
if original:
original_flags = set(check_for_flag(original))
# If there are no new flags in decoded flags, return False
if not (set(flags) - original_flags):
return False
return True
# }}}
# {{{ FlagDetectorStream
class FlagDetectorStream:
"""Intercepts stdout to scan output for matching flag formats."""
def __init__(self, original_stream, pattern_str):
self.original_stream = original_stream
self._flag_buffer = ""
self.detecting = True
self.pattern_str = pattern_str
def write(self, data):
self.original_stream.write(data)
if self.detecting and self.pattern_str:
self._flag_buffer += data
matches = check_for_flag(self._flag_buffer, self.pattern_str)
if matches:
self.detecting = False
self.original_stream.write("\n\033[1;32m[!] Potential flag(s) detected in command output:\033[0m\n")
for match in matches:
self.original_stream.write(f" \033[1;32m✓ {match}\033[0m\n")
try:
from ctf.config import Config
cfg = Config()
if "Competition" not in cfg.data:
cfg.data["Competition"] = {}
cfg.data["Competition"]["last_flag"] = match
cfg.save(cfg.data)
except Exception:
pass
self._flag_buffer = ""
self.detecting = True
def flush(self):
self.original_stream.flush()
def __getattr__(self, name):
return getattr(self.original_stream, name)
# }}}
+7 -100
View File
@@ -1,118 +1,25 @@
# src/main.py
# Parses and calls commands
from ctf.commands import basic_group
from ctf.cli_forensics import forensics_group, inspect
from ctf.cli_steg import steg_group
import click
import sys
import re
# Globally track the detector instance if it is active
_active_detector = None
# {{{ FlagDetectorStream
class FlagDetectorStream:
"""Intercepts stdout to scan output for matching flag formats."""
def __init__(self, original_stream, pattern_str):
self.original_stream = original_stream
self._flag_buffer = ""
self.detecting = True
# Clean pattern from standard anchors ^ and $ for substring matching
pat = pattern_str
if pat.startswith("^") and pat.endswith("$"):
pat = pat[1:-1]
try:
self.pattern = re.compile(pat) if pat else None
except re.error:
self.pattern = None
def write(self, data):
self.original_stream.write(data)
if self.detecting and self.pattern:
self._flag_buffer += data
matches = []
for match in self.pattern.finditer(self._flag_buffer):
matches.append(match.group())
if matches:
self.detecting = False
self.original_stream.write("\n\033[1;32m[!] Potential flag(s) detected in command output:\033[0m\n")
for match in matches:
self.original_stream.write(f" \033[1;32m✓ {match}\033[0m\n")
try:
from ctf.utils import load_config, write_config
config_path = "/home/venus/code/ctf/config.toml"
config = load_config(config_path)
if "Competition" not in config:
config["Competition"] = {}
config["Competition"]["last_flag"] = match
write_config(config, config_path)
except Exception:
pass
self._flag_buffer = ""
self.detecting = True
def flush(self):
self.original_stream.flush()
def __getattr__(self, name):
return getattr(self.original_stream, name)
# }}}
# {{{ flag_cmd
@click.command(name="flag")
@click.option("-p", "--plain", is_flag=True, help="Print raw flag without flavor text.")
def flag_cmd(plain):
"""Retrieve the last detected flag from config."""
# TODO add support for multiple flags and flag selection
global _active_detector
if _active_detector is not None:
_active_detector.detecting = False
try:
from ctf.utils import load_config
config = load_config("/home/venus/code/ctf/config.toml")
last_flag = config.get("Competition", {}).get("last_flag", "")
if not last_flag:
if not plain:
click.echo("No flag has been detected yet.")
return
if plain:
click.echo(last_flag)
else:
click.echo(f"Last detected flag: {last_flag}")
finally:
if _active_detector is not None:
_active_detector.detecting = True
# }}}
# {{{ cli
@click.group()
def cli(): pass
cli.add_command(forensics_group)
cli.add_command(basic_group)
cli.add_command(flag_cmd)
cli.add_command(inspect)
cli.add_command(steg_group)
# }}}
from ctf.cli import cli
from ctf.helpers import FlagDetectorStream, detector_state
# {{{ main
def main():
from ctf.utils import load_config
config = load_config("/home/venus/code/ctf/config.toml")
flag_format = config.get("Competition", {}).get("flag_format", "")
from ctf.config import Config
cfg = Config()
flag_format = cfg.data.get("Competition", {}).get("flag_format", "")
global _active_detector
if flag_format:
detector = FlagDetectorStream(sys.stdout, flag_format)
sys.stdout = detector
_active_detector = detector
detector_state["active"] = detector
try:
cli()
finally:
sys.stdout = detector.original_stream
_active_detector = None
detector_state["active"] = None
else:
cli()
# }}}
+1 -23
View File
@@ -1,23 +1,8 @@
# src/ctf/utils.py
# basic utilities
import toml
from pathlib import Path
from platformdirs import user_config_dir
# Parse the config file, returning a config dictionary with relevant config options
# Load the config from file and parse with TOML
def load_config(config = f"{user_config_dir()}/ctf-config.toml") -> dict:
p = Path(config)
if p.exists():
return toml.load(p)
return{}
# Write a dictionary to the config file
def write_config(data: dict, config = f"{user_config_dir()}/ctf"):
with open(config, "w") as f:
toml.dump(data, f)
from ctf.config import competition, enviroment
# return a list of path objects for each catagory in a competition
def active_categories(p: Path) -> list:
@@ -213,10 +198,3 @@ def suggest_patterns(s: str, comp_name: str = "") -> list[str]:
return result
# }}}
# Load variables to export
config = load_config("/home/venus/code/ctf/config.toml")
competition = config["Competition"]
enviroment = config["Enviroment"]
# base_dir = config["ctf_dir"]
+121
View File
@@ -0,0 +1,121 @@
# tests/cli/test_basic.py
# {{{ imports
from click.testing import CliRunner
from ctf.cli.basic import basic_group
from ctf.config import Config
# }}}
# {{{ test_basic_test_cli
def test_basic_test_cli():
"""
Verifies that 'basic test' runs successfully and outputs 'hello from test'.
"""
runner = CliRunner()
result = runner.invoke(basic_group, ["test"])
assert result.exit_code == 0
assert "hello from test" in result.output
# }}}
# {{{ test_basic_greet_cli
def test_basic_greet_cli():
"""
Verifies that 'basic greet' runs successfully and greets the name argument.
"""
runner = CliRunner()
result = runner.invoke(basic_group, ["greet", "Alice"])
assert result.exit_code == 0
assert "hello Alice" in result.output
# }}}
# {{{ test_basic_set_flag_format_cli
def test_basic_set_flag_format_cli():
"""
Verifies that 'basic set-flag-format' CLI command writes the pattern to config.toml.
"""
runner = CliRunner()
cfg = Config()
assert cfg.data["Competition"]["flag_format"] == r"picoCTF\{.*\}"
result = runner.invoke(basic_group, ["set-flag-format", "TEST_FLAG{[a-z]+}"])
assert result.exit_code == 0
assert "Flag format set to" in result.output
updated_cfg = Config()
assert updated_cfg.data["Competition"]["flag_format"] == "TEST_FLAG{[a-z]+}"
# }}}
# {{{ test_basic_set_flag_format_original_option_cli
def test_basic_set_flag_format_original_option_cli():
"""
Verifies that 'basic set-flag-format' with --original/-o option prompts for selection and writes it.
"""
runner = CliRunner()
result = runner.invoke(basic_group, ["set-flag-format", "-o", "SKY-1111-000"], input="2\n")
assert result.exit_code == 0
assert "Select a pattern index" in result.output
assert "Flag format set to" in result.output
updated_cfg = Config()
selected_pattern = updated_cfg.data["Competition"]["flag_format"]
assert len(selected_pattern) > 0
# }}}
# {{{ test_basic_set_flag_format_validation_cli
def test_basic_set_flag_format_validation_cli():
"""
Verifies validation rules:
- Specifying both PATTERN and --original should fail.
- Specifying neither PATTERN nor --original should fail.
- Specifying empty/whitespace-only original flag should fail.
"""
runner = CliRunner()
# Both specified
result1 = runner.invoke(basic_group, ["set-flag-format", "PAT", "-o", "SKY-1111-000"])
assert result1.exit_code != 0
assert "Cannot specify both PATTERN and --original/-o option." in result1.output
# Neither specified
result2 = runner.invoke(basic_group, ["set-flag-format"])
assert result2.exit_code != 0
assert "Either PATTERN positional argument or --original/-o option must be specified." in result2.output
# Empty original
result3 = runner.invoke(basic_group, ["set-flag-format", "-o", " "])
assert result3.exit_code != 0
assert "Original flag cannot be empty or whitespace only." in result3.output
# }}}
# {{{ test_basic_set_competition_cli
def test_basic_set_competition_cli():
"""
Verifies that 'basic set-competition' updates the competition name in config.toml.
"""
runner = CliRunner()
result = runner.invoke(basic_group, ["set-competition", "CyberCTF2026"])
assert result.exit_code == 0
assert "Competition name set to: CyberCTF2026" in result.output
updated_cfg = Config()
assert updated_cfg.data["Competition"]["competition"] == "CyberCTF2026"
# }}}
# {{{ test_basic_set_flag_format_warning_cli
def test_basic_set_flag_format_warning_cli():
"""
Verifies that if competition name is set and not present in the example flag,
set-flag-format outputs a warning.
"""
runner = CliRunner()
# Set competition name first
cfg = Config()
cfg.data["Competition"]["competition"] = "SECURE"
cfg.save(cfg.data)
result = runner.invoke(basic_group, ["set-flag-format", "-o", "CTF{easy_flag_123}"], input="1\n")
assert result.exit_code == 0
assert "Warning: Current competition name 'SECURE' was not found in the example flag." in result.output
# }}}
+97
View File
@@ -0,0 +1,97 @@
# tests/cli/test_flag.py
# {{{ imports
import sys
import pytest
from pathlib import Path
from click.testing import CliRunner
from unittest.mock import patch
from ctf.config import Config
# }}}
# {{{ test_flag_cmd_cli
def test_flag_cmd_cli():
"""
Verifies the ctf flag command outputs last_flag with and without --plain.
"""
from ctf.cli import cli
cfg = Config()
cfg.data["Competition"]["last_flag"] = "flag{test_cli_flag}"
cfg.save(cfg.data)
runner = CliRunner()
# Test standard flag command output
result_std = runner.invoke(cli, ["flag"])
assert result_std.exit_code == 0
assert "Last detected flag: flag{test_cli_flag}" in result_std.output
# Test plain flag command output
result_plain = runner.invoke(cli, ["flag", "--plain"])
assert result_plain.exit_code == 0
assert result_plain.output.strip() == "flag{test_cli_flag}"
# }}}
# {{{ test_flag_cmd_exemption
def test_flag_cmd_exemption(capsys):
"""
Verifies that running ctf flag is exempted from triggering the stdout flag warning box.
"""
from ctf.main import main
cfg = Config()
cfg.data["Competition"]["flag_format"] = r"flag\{[a-z_]+\}"
cfg.data["Competition"]["last_flag"] = "flag{test_exempt_flag}"
cfg.save(cfg.data)
with patch.object(sys, "argv", ["ctf", "flag"]):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
captured = capsys.readouterr()
# Verify that the warning box is NOT in stdout
assert "Potential flag(s) detected in command output" not in captured.out
# Verify that the actual flag info IS printed
assert "Last detected flag: flag{test_exempt_flag}" in captured.out
# }}}
# {{{ test_flag_cmd_list_format
def test_flag_cmd_list_format():
"""
Verifies that ctf flag --list / -l prints the current flag format.
"""
from ctf.cli import cli
cfg = Config()
cfg.data["Competition"]["flag_format"] = "TEST_FORMAT{[a-z]+}"
cfg.save(cfg.data)
runner = CliRunner()
# Test standard output
result = runner.invoke(cli, ["flag", "-l"])
assert result.exit_code == 0
assert "Current flag format: TEST_FORMAT{[a-z]+}" in result.output
# Test plain output
result_plain = runner.invoke(cli, ["flag", "-l", "-p"])
assert result_plain.exit_code == 0
assert result_plain.output.strip() == "TEST_FORMAT{[a-z]+}"
# }}}
# {{{ test_flag_cmd_set_format
def test_flag_cmd_set_format():
"""
Verifies that ctf flag --set / -s updates the flag format in config.toml.
"""
from ctf.cli import cli
runner = CliRunner()
result = runner.invoke(cli, ["flag", "-s", "NEW_FLAG_FORMAT{[0-9]+}"])
assert result.exit_code == 0
assert "Flag format set to: NEW_FLAG_FORMAT{[0-9]+}" in result.output
updated_cfg = Config()
assert updated_cfg.data["Competition"]["flag_format"] == "NEW_FLAG_FORMAT{[0-9]+}"
# }}}
+204
View File
@@ -0,0 +1,204 @@
# tests/cli/test_forensics.py
# {{{ imports
import struct
from pathlib import Path
from click.testing import CliRunner
from ctf.cli.forensics import forensics_group
# }}}
# Define the persistent test environment path
TEST_ENV = Path("tests/env")
# {{{ test_forensics_metadata_cli_success
def test_forensics_metadata_cli_success():
"""
Verifies that 'forensics metadata' successfully runs via the CLI and prints
the metadata in a formatted table.
"""
runner = CliRunner()
test_file = TEST_ENV / "test_file.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(forensics_group, ["metadata", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file.png" in result.output
assert "Size" in result.output
assert "89504E47" in result.output
assert "Detected Type" in result.output
assert "PNG Image" in result.output
# }}}
# {{{ test_nested_metadata_cli_success
def test_nested_metadata_cli_success():
"""
Verifies that 'ctf forensics metadata' successfully runs via the root CLI.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "test_file_nested.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["forensics", "metadata", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file_nested.png" in result.output
assert "PNG Image" in result.output
# }}}
# {{{ test_forensics_metadata_cli_missing_file
def test_forensics_metadata_cli_missing_file():
"""
Verifies that the CLI fails gracefully when a non-existent file path is specified.
"""
runner = CliRunner()
result = runner.invoke(forensics_group, ["metadata", "non_existent_file.png"])
assert result.exit_code != 0
assert "does not exist" in result.output
# }}}
# {{{ test_forensics_signatures_cli
def test_forensics_signatures_cli():
"""
Verifies that 'forensics signatures' runs successfully and displays
the list of supported file signatures.
"""
runner = CliRunner()
result = runner.invoke(forensics_group, ["signatures"])
assert result.exit_code == 0
assert "Supported Magic Signatures" in result.output
assert "PNG Image" in result.output
assert "89504E47" in result.output
# }}}
# {{{ test_exif_cli_options
def test_exif_cli_options():
"""
Verifies CLI options -i/--inode and -e/--exif work as expected.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_exif_image.png"
# Verify mutual exclusion error
result_err = runner.invoke(cli, ["forensics", "metadata", "-i", "-e", str(test_file)])
assert result_err.exit_code != 0
assert "Cannot specify more than one" in result_err.output
# Verify --inode hides EXIF data table
result_inode = runner.invoke(cli, ["forensics", "metadata", "-i", str(test_file)])
assert result_inode.exit_code == 0
assert "Metadata: mock_exif_image.png" in result_inode.output
assert "EXIF Metadata" not in result_inode.output
# Verify --exif hides POSIX table but displays EXIF data table
result_exif = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)])
assert result_exif.exit_code == 0
assert "Metadata: mock_exif_image.png" not in result_exif.output
assert "EXIF Metadata" in result_exif.output
assert "mock_exif_image" in result_exif.output
assert "Make" in result_exif.output
assert "TEST" in result_exif.output
# }}}
# {{{ test_exif_cli_options_warning
def test_exif_cli_options_warning():
"""
Verifies CLI warns when no EXIF or comment is found and -e option is used.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "clean_no_exif.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)])
assert result.exit_code == 0
assert "No EXIF or comment metadata found" in result.output
# }}}
# {{{ test_exif_cli_comment_only
def test_exif_cli_comment_only():
"""
Verifies CLI displays comment when -e is used and only comment is found.
"""
from ctf.main import cli
runner = CliRunner()
comment_text = b"flag{comment_only}"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text
test_file = TEST_ENV / "comment_only.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)])
assert result.exit_code == 0
assert "Comment" in result.output
assert "flag{comment_only}" in result.output
# }}}
# {{{ test_adobe_xmp_cli
def test_adobe_xmp_cli():
"""
Verifies that the ctf inspect CLI prints the parsed XMP fields.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_xmp.jpg"
result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)])
assert result.exit_code == 0
assert "EXIF Metadata" in result.output
assert "cc:license" in result.output
assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output
# }}}
# {{{ test_cli_physical_option
def test_cli_physical_option():
"""
Verifies the ctf forensics metadata CLI supports -p/--physical and mutual exclusion rules.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_physical.jpg"
# Mutual exclusion check
result_err = runner.invoke(cli, ["forensics", "metadata", "-p", "-e", str(test_file)])
assert result_err.exit_code != 0
assert "Cannot specify more than one" in result_err.output
# Physical view check
result_phys = runner.invoke(cli, ["forensics", "metadata", "-p", str(test_file)])
assert result_phys.exit_code == 0
assert "Physical Metadata" in result_phys.output
assert "Image Size" in result_phys.output
assert "1500x1000" in result_phys.output
assert "Metadata: mock_physical.jpg" not in result_phys.output
assert "EXIF Metadata" not in result_phys.output
# }}}
# {{{ test_metadata_decoded_hints_cli
def test_metadata_decoded_hints_cli():
"""
Verifies that metadata command successfully decodes and prints hex/base64 encoded metadata hints.
"""
from ctf.main import cli
runner = CliRunner()
# Create a mock JPEG with a base64 encoded comment: "ZmxhZ3tiNjRfbWV0YWRhdGF9" -> "flag{b64_metadata}"
comment_text = b"ZmxhZ3tiNjRfbWV0YWRhdGF9"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text + b"\xff\xd9"
test_file = TEST_ENV / "mock_encoded_comment.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = runner.invoke(cli, ["forensics", "metadata", str(test_file)])
assert result.exit_code == 0
assert "Decoded Metadata Hints" in result.output
assert "Comment" in result.output
assert "base64" in result.output
assert "flag{b64_metadata}" in result.output
# }}}
+20 -7
View File
@@ -1,4 +1,5 @@
# tests/test_steg.py
# tests/cli/test_steg.py
# {{{ imports
import shutil
import subprocess
from pathlib import Path
@@ -6,9 +7,10 @@ from unittest.mock import patch, MagicMock
import click
from click.testing import CliRunner
import pytest
from ctf.steg import crack_steghide, SteghideCrackResult
# }}}
# {{{ test_crack_steghide_missing_binary
def test_crack_steghide_missing_binary():
"""
Verifies that crack_steghide returns failure if steghide is not installed.
@@ -17,7 +19,9 @@ def test_crack_steghide_missing_binary():
res = crack_steghide(Path("somefile.jpg"), ["pass1", "pass2"])
assert not res.success
assert "not found on system" in res.error_message
# }}}
# {{{ test_crack_steghide_file_not_found
def test_crack_steghide_file_not_found():
"""
Verifies that crack_steghide returns failure if the input file does not exist.
@@ -26,7 +30,9 @@ def test_crack_steghide_file_not_found():
res = crack_steghide(Path("non_existent_file_12345.jpg"), ["pass1", "pass2"])
assert not res.success
assert "File not found" in res.error_message
# }}}
# {{{ test_crack_steghide_success
def test_crack_steghide_success():
"""
Verifies that crack_steghide returns success, the password, and payload when cracked.
@@ -36,13 +42,11 @@ def test_crack_steghide_success():
# We want to mock subprocess.run to write a payload to the out_file (-xf)
# when the correct password is tried.
def mock_run(cmd, *args, **kwargs):
# cmd: ['steghide', 'extract', '-sf', '...', '-p', password, '-xf', out_file, '-f']
password = cmd[5]
out_file = cmd[7]
mock_res = MagicMock()
if password == "correct_pass":
mock_res.returncode = 0
# Write a mock payload file
Path(out_file).write_bytes(b"flag{steghide_cracked_payload}")
else:
mock_res.returncode = 1
@@ -57,7 +61,9 @@ def test_crack_steghide_success():
assert res.password == "correct_pass"
assert res.payload == b"flag{steghide_cracked_payload}"
assert res.error_message is None
# }}}
# {{{ test_crack_steghide_failure
def test_crack_steghide_failure():
"""
Verifies that crack_steghide returns failure if the wordlist is exhausted.
@@ -78,7 +84,9 @@ def test_crack_steghide_failure():
assert res.password is None
assert res.payload is None
assert "Password not found in wordlist" in res.error_message
# }}}
# {{{ test_cli_crack_steghide_success
def test_cli_crack_steghide_success(tmp_path):
"""
Verifies that the crack-steghide CLI command prints success panel and previews payload.
@@ -99,13 +107,15 @@ def test_cli_crack_steghide_success(tmp_path):
payload=b"flag{cli_steg_preview}"
)
with patch("ctf.cli_steg.crack_steghide", return_value=mock_result):
with patch("ctf.cli.steg.crack_steghide", return_value=mock_result):
res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file)])
assert res.exit_code == 0
assert "Successfully cracked!" in res.output
assert "pass2" in res.output
assert "flag{cli_steg_preview}" in res.output
# }}}
# {{{ test_cli_crack_steghide_success_output_file
def test_cli_crack_steghide_success_output_file(tmp_path):
"""
Verifies that the crack-steghide CLI command writes the payload to a file when specified.
@@ -127,13 +137,15 @@ def test_cli_crack_steghide_success_output_file(tmp_path):
payload=b"written_file_content"
)
with patch("ctf.cli_steg.crack_steghide", return_value=mock_result):
with patch("ctf.cli.steg.crack_steghide", return_value=mock_result):
res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file), "-o", str(out_file)])
assert res.exit_code == 0
assert "Successfully cracked!" in res.output
assert out_file.exists()
assert out_file.read_bytes() == b"written_file_content"
# }}}
# {{{ test_cli_crack_steghide_failure
def test_cli_crack_steghide_failure(tmp_path):
"""
Verifies that the crack-steghide CLI command displays correct error message on failure.
@@ -152,7 +164,8 @@ def test_cli_crack_steghide_failure(tmp_path):
error_message="Password not found in wordlist."
)
with patch("ctf.cli_steg.crack_steghide", return_value=mock_result):
with patch("ctf.cli.steg.crack_steghide", return_value=mock_result):
res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file)])
assert res.exit_code == 0
assert "Cracking failed: Password not found in wordlist" in res.output
# }}}
+27
View File
@@ -0,0 +1,27 @@
# tests/conftest.py
import pytest
import toml
from pathlib import Path
@pytest.fixture(autouse=True)
def setup_test_config(tmp_path, monkeypatch):
"""Autouse fixture to isolate test runs from the real config.toml."""
test_config = tmp_path / "config.toml"
default_data = {
"Competition": {
"competition": "picoctf",
"catagory": "textCat",
"challenge": "testChal",
"flag_format": r"picoCTF\{.*\}",
"last_flag": "flag{my_flag_here}"
},
"Enviroment": {
"ctf_dir": "/home/venus/ctf"
}
}
test_config.parent.mkdir(parents=True, exist_ok=True)
with open(test_config, "w") as f:
toml.dump(default_data, f)
monkeypatch.setenv("CTF_CONFIG_PATH", str(test_config))
return test_config
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 B

+1
View File
@@ -0,0 +1 @@
TestKey = "TestVal"
@@ -1,65 +1,18 @@
from pathlib import Path
import toml
# tests/forensics/test_metadata.py
# {{{ imports
import os
import stat
import sys
from click.testing import CliRunner
from ctf.utils import load_config, active_categories
import stat
from pathlib import Path
import pytest
from ctf.forensics import get_metadata
from ctf.cli_forensics import forensics_group, inspect
from ctf.utils import active_categories
# }}}
# Define the persistent test environment path
TEST_ENV = Path("tests/env")
# =====================================================================
# 1. Test Strategy Summary
# =====================================================================
# Core scenarios tested:
#
# A. Happy Path:
# - Verifying metadata attributes (permissions, ownership, size,
# hard links, inode, device) map exactly to filesystem ground truth.
#
# B. Boundary Conditions:
# - Testing empty files (0 bytes) for correct apparent size and allocation.
# - Testing file permissions changes (e.g. read-only, executable) and
# making sure the parsed octal/symbolic codes match.
# - Testing multiple hard links (count > 1).
#
# C. Edge Cases & OS Portability:
# - Extended attributes (xattr) support, handling platforms where xattr
# is not present or supported on the active file system.
# - Graceful system identity fallbacks for UID/GID names when passwd/group
# databases are unavailable or running on non-Unix systems.
#
# D. Error Handling:
# - Accessing non-existent paths (CLI validation errors).
# =====================================================================
# =====================================================================
# 2. Mocking/Setup Requirements
# =====================================================================
# - Filesystem sandbox: Uses `tests/env/` to dynamically create files
# with specific mode bits, content, and links.
# - No external network/API mocking is required.
# - OS-specific conditional blocks handle platforms (e.g., Windows)
# where POSIX ownership resolution or xattr is not native.
# =====================================================================
def test_load_config_static():
"""Verifies load_config using the persistent test file."""
config_file = TEST_ENV / "config.toml"
test_data = {
"Competition": {"name": "PersistentComp"},
"Enviroment": {"data_dir": str(TEST_ENV.absolute())}
}
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, "w") as f:
toml.dump(test_data, f)
loaded_data = load_config(str(config_file))
assert loaded_data["Competition"]["name"] == "PersistentComp"
# {{{ test_active_categories_static
def test_active_categories_static():
"""Verifies active_categories using the pre-created 'comp1' folder."""
comp_dir = TEST_ENV / "comp1"
@@ -72,10 +25,9 @@ def test_active_categories_static():
assert "web" in cat_names
assert "pwn" in cat_names
# }}}
# --- Task-Specific Universal Metadata Tests ---
# {{{ test_inspect_permissions
def test_inspect_permissions():
"""
Task 1: POSIX Permissions.
@@ -101,8 +53,9 @@ def test_inspect_permissions():
meta_new = get_metadata(test_file)
assert meta_new.permissions_octal == "0o644"
assert meta_new.permissions_symbolic == "-rw-r--r--"
# }}}
# {{{ test_inspect_ownership
def test_inspect_ownership():
"""
Task 2: Ownership Identity.
@@ -130,8 +83,9 @@ def test_inspect_ownership():
# Fallback for Windows
assert isinstance(meta.owner_username, str)
assert isinstance(meta.owner_groupname, str)
# }}}
# {{{ test_inspect_allocation
def test_inspect_allocation():
"""
Task 3: Allocation Metrics.
@@ -153,8 +107,9 @@ def test_inspect_allocation():
assert meta.allocated_size == expected_blocks_size
else:
assert meta.allocated_size >= 1234
# }}}
# {{{ test_inspect_hard_links
def test_inspect_hard_links():
"""
Task 4: Hard Link Count.
@@ -179,8 +134,9 @@ def test_inspect_hard_links():
finally:
if link_file.exists():
link_file.unlink()
# }}}
# {{{ test_inspect_inode_device
def test_inspect_inode_device():
"""
Task 5: Inode & Device Identifiers.
@@ -195,8 +151,9 @@ def test_inspect_inode_device():
assert meta.inode == stat_info.st_ino
assert meta.device == stat_info.st_dev
# }}}
# {{{ test_inspect_extended_attributes
def test_inspect_extended_attributes():
"""
Task 6: Extended Attributes (xattr).
@@ -207,15 +164,12 @@ def test_inspect_extended_attributes():
with open(test_file, "wb") as f:
f.write(b"data")
# Attempt to set an extended attribute (Linux/macOS specific)
has_xattr_support = False
if sys.platform in ("linux", "darwin"):
try:
import xattr
# Use user namespace for custom attribute
os.setxattr(str(test_file), "user.ctf_flag", b"FLAG{filesystem_metadata_ftw}")
has_xattr_support = True
except (ImportError, OSError, AttributeError):
except (OSError, AttributeError):
pass
meta = get_metadata(test_file)
@@ -224,97 +178,23 @@ def test_inspect_extended_attributes():
assert "user.ctf_flag" in meta.extended_attributes
assert meta.extended_attributes["user.ctf_flag"] == "FLAG{filesystem_metadata_ftw}"
else:
# Should gracefully return an empty dict if not supported or none defined
assert isinstance(meta.extended_attributes, dict)
# }}}
# --- CLI Validation Tests ---
def test_forensics_inspect_cli_success():
"""
Verifies that 'forensics inspect' successfully runs via the CLI and prints
the metadata in a formatted table.
"""
runner = CliRunner()
test_file = TEST_ENV / "test_file.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(forensics_group, ["inspect", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file.png" in result.output
assert "Size" in result.output
assert "89504E47" in result.output
assert "Detected Type" in result.output
assert "PNG Image" in result.output
def test_root_inspect_cli_success():
"""
Verifies that 'ctf inspect' successfully runs at the root level and prints
the metadata in a formatted table.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "test_file_root.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["inspect", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file_root.png" in result.output
assert "PNG Image" in result.output
def test_nested_inspect_cli_success():
"""
Verifies that 'ctf forensics inspect' successfully runs via the root CLI.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "test_file_nested.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["forensics", "inspect", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file_nested.png" in result.output
assert "PNG Image" in result.output
def test_forensics_inspect_cli_missing_file():
"""
Verifies that the CLI fails gracefully when a non-existent file path is specified.
"""
runner = CliRunner()
result = runner.invoke(forensics_group, ["inspect", "non_existent_file.png"])
assert result.exit_code != 0
assert "does not exist" in result.output
def test_forensics_signatures_cli():
"""
Verifies that 'forensics signatures' runs successfully and displays
the list of supported file signatures.
"""
runner = CliRunner()
result = runner.invoke(forensics_group, ["signatures"])
assert result.exit_code == 0
assert "Supported Magic Signatures" in result.output
assert "PNG Image" in result.output
assert "89504E47" in result.output
# (Old flag-detect tests removed)
# {{{ test_pure_exif_parsing
def test_pure_exif_parsing():
"""
Verifies that get_metadata successfully parses EXIF fields from a mock PNG.
"""
import struct
tiff_data = (
b"II\x2a\x00\x08\x00\x00\x00" # TIFF Header
b"\x02\x00" # Num entries
b"\x0f\x01\x02\x00\x05\x00\x00\x00\x26\x00\x00\x00" # Entry 1 (Make: offset 38)
b"\x10\x01\x02\x00\x06\x00\x00\x00\x2b\x00\x00\x00" # Entry 2 (Model: offset 43)
b"\x00\x00\x00\x00" # Next IFD offset
b"TEST\x00" # Make value
b"MODEL\x00" # Model value
b"II\x2a\x00\x08\x00\x00\x00"
b"\x02\x00"
b"\x0f\x01\x02\x00\x05\x00\x00\x00\x26\x00\x00\x00"
b"\x10\x01\x02\x00\x06\x00\x00\x00\x2b\x00\x00\x00"
b"\x00\x00\x00\x00"
b"TEST\x00"
b"MODEL\x00"
)
png_header = b"\x89PNG\r\n\x1a\n"
exif_chunk_type = b"eXIf"
@@ -330,35 +210,9 @@ def test_pure_exif_parsing():
meta = get_metadata(test_file)
assert meta.exif_data.get("Make") == "TEST"
assert meta.exif_data.get("Model") == "MODEL"
# }}}
def test_exif_cli_options():
"""
Verifies CLI options -i/--inode and -e/--exif work as expected.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_exif_image.png"
# Verify mutual exclusion error
result_err = runner.invoke(cli, ["inspect", "-i", "-e", str(test_file)])
assert result_err.exit_code != 0
assert "Cannot specify more than one" in result_err.output
# Verify --inode hides EXIF data table
result_inode = runner.invoke(cli, ["inspect", "-i", str(test_file)])
assert result_inode.exit_code == 0
assert "Metadata: mock_exif_image.png" in result_inode.output
assert "EXIF Metadata" not in result_inode.output
# Verify --exif hides POSIX table but displays EXIF data table
result_exif = runner.invoke(cli, ["inspect", "-e", str(test_file)])
assert result_exif.exit_code == 0
assert "Metadata: mock_exif_image.png" not in result_exif.output
assert "EXIF Metadata" in result_exif.output
assert "mock_exif_image" in result_exif.output
assert "Make" in result_exif.output
assert "TEST" in result_exif.output
# {{{ test_jpeg_comment_extraction
def test_jpeg_comment_extraction():
"""
Verifies that get_metadata successfully parses a JPEG comment (COM marker).
@@ -380,41 +234,9 @@ def test_jpeg_comment_extraction():
meta = get_metadata(test_file)
assert meta.comment == "picoCTF{test_comment_flag}"
# }}}
def test_exif_cli_options_warning():
"""
Verifies CLI warns when no EXIF or comment is found and -e option is used.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "clean_no_exif.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["inspect", "-e", str(test_file)])
assert result.exit_code == 0
assert "No EXIF or comment metadata found" in result.output
def test_exif_cli_comment_only():
"""
Verifies CLI displays comment when -e is used and only comment is found.
"""
from ctf.main import cli
import struct
runner = CliRunner()
comment_text = b"flag{comment_only}"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text
test_file = TEST_ENV / "comment_only.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = runner.invoke(cli, ["inspect", "-e", str(test_file)])
assert result.exit_code == 0
assert "Comment" in result.output
assert "flag{comment_only}" in result.output
# {{{ test_adobe_xmp_extraction
def test_adobe_xmp_extraction():
"""
Verifies that get_metadata successfully parses XMP fields (like license resource).
@@ -446,21 +268,9 @@ def test_adobe_xmp_extraction():
meta = get_metadata(test_file)
assert meta.exif_data.get("cc:license") == "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ=="
# }}}
def test_adobe_xmp_cli():
"""
Verifies that the ctf inspect CLI prints the parsed XMP fields.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_xmp.jpg"
result = runner.invoke(cli, ["inspect", "-e", str(test_file)])
assert result.exit_code == 0
assert "EXIF Metadata" in result.output
assert "cc:license" in result.output
assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output
# {{{ test_jpeg_physical_parsing
def test_jpeg_physical_parsing():
"""
Verifies that get_metadata successfully parses JPEG physical parameters (JFIF & SOF).
@@ -483,7 +293,9 @@ def test_jpeg_physical_parsing():
assert meta.physical_data.get("Image Size") == "1500x1000"
assert meta.physical_data.get("Megapixels") == "1.5"
assert meta.physical_data.get("Encoding Process") == "Baseline DCT, Huffman coding"
# }}}
# {{{ test_jpeg_iptc_parsing
def test_jpeg_iptc_parsing():
"""
Verifies that get_metadata successfully parses JPEG IPTC metadata from APP13.
@@ -507,28 +319,9 @@ def test_jpeg_iptc_parsing():
meta = get_metadata(test_file)
assert meta.exif_data.get("CopyrightNotice") == "PicoCTF Rights"
# }}}
def test_cli_physical_option():
"""
Verifies the ctf inspect CLI supports -p/--physical and mutual exclusion rules.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_physical.jpg"
# Mutual exclusion check
result_err = runner.invoke(cli, ["inspect", "-p", "-e", str(test_file)])
assert result_err.exit_code != 0
assert "Cannot specify more than one" in result_err.output
# Physical view check
result_phys = runner.invoke(cli, ["inspect", "-p", str(test_file)])
assert result_phys.exit_code == 0
assert "Physical Metadata" in result_phys.output
assert "Image Size" in result_phys.output
assert "1500x1000" in result_phys.output
assert "Metadata: mock_physical.jpg" not in result_phys.output
assert "EXIF Metadata" not in result_phys.output
# {{{ test_png_text_chunks_decompression
def test_png_text_chunks_decompression():
"""
Verifies that get_metadata successfully parses tEXt and compressed zTXt chunks.
@@ -536,11 +329,9 @@ def test_png_text_chunks_decompression():
import zlib
import struct
# 1. tEXt chunk: Keyword (Copyright) + NUL + Text (PicoCTF)
text_data = b"Copyright\x00PicoCTF"
text_chunk = struct.pack(">I", len(text_data)) + b"tEXt" + text_data + b"\x00\x00\x00\x00"
# 2. zTXt chunk: Keyword (Author) + NUL + CompMethod(0) + Deflated Text (John Doe)
deflated = zlib.compress(b"John Doe")
ztxt_data = b"Author\x00\x00" + deflated
ztxt_chunk = struct.pack(">I", len(ztxt_data)) + b"zTXt" + ztxt_data + b"\x00\x00\x00\x00"
@@ -554,7 +345,9 @@ def test_png_text_chunks_decompression():
meta = get_metadata(test_file)
assert meta.exif_data.get("Copyright") == "PicoCTF"
assert meta.exif_data.get("Author") == "John Doe"
# }}}
# {{{ test_gif_comment_parsing
def test_gif_comment_parsing():
"""
Verifies that get_metadata successfully parses GIF comment blocks.
@@ -571,7 +364,9 @@ def test_gif_comment_parsing():
meta = get_metadata(test_file)
assert meta.comment == "GIFComment"
# }}}
# {{{ test_adobe_xmp_formatting
def test_adobe_xmp_formatting():
"""
Verifies that get_metadata parses rdf:Description attributes and formats other
@@ -609,28 +404,4 @@ def test_adobe_xmp_formatting():
assert meta.exif_data.get("exif:Flash") == "exif:Fired | True, exif:Mode | 1"
assert meta.exif_data.get("exif:EmptyAttr") == "exif:Value"
assert "rdf:Description" not in meta.exif_data
def test_inspect_decoded_hints_cli():
"""
Verifies that inspect command successfully decodes and prints hex/base64 encoded metadata hints.
"""
from ctf.main import cli
runner = CliRunner()
# Create a mock JPEG with a base64 encoded comment: "ZmxhZ3tiNjRfbWV0YWRhdGF9" -> "flag{b64_metadata}"
import struct
comment_text = b"ZmxhZ3tiNjRfbWV0YWRhdGF9"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text + b"\xff\xd9"
test_file = TEST_ENV / "mock_encoded_comment.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = runner.invoke(cli, ["inspect", str(test_file)])
assert result.exit_code == 0
assert "Decoded Metadata Hints" in result.output
assert "Comment" in result.output
assert "base64" in result.output
assert "flag{b64_metadata}" in result.output
# }}}
+30
View File
@@ -0,0 +1,30 @@
# tests/forensics/test_parsers.py
# {{{ imports
from ctf.forensics import ParserFactory, JpegParser, PngParser, GifParser, FallbackParser
# }}}
# {{{ test_parser_factory_and_classes
def test_parser_factory_and_classes():
"""
Verifies that ParserFactory selects the correct parser subclass and
that individual FormatParser subclasses behave correctly.
"""
# Assert JPEG
jpeg_parser = ParserFactory.get_parser(b"\xff\xd8\xff\xe0\x00\x10JFIF")
assert isinstance(jpeg_parser, JpegParser)
# Assert PNG
png_parser = ParserFactory.get_parser(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
assert isinstance(png_parser, PngParser)
# Assert GIF
gif_parser = ParserFactory.get_parser(b"GIF89a\x01\x00\x01\x00")
assert isinstance(gif_parser, GifParser)
# Assert Fallback
fallback_parser = ParserFactory.get_parser(b"MZ\x90\x00\x03\x00\x00\x00")
assert isinstance(fallback_parser, FallbackParser)
assert fallback_parser.parse_physical(b"") == {}
assert fallback_parser.get_comment(b"") == ""
assert fallback_parser.get_exif_tags(b"") == {}
# }}}
+147
View File
@@ -0,0 +1,147 @@
# tests/test_analysis.py
from pathlib import Path
import pytest
from click.testing import CliRunner
from ctf.analysis import run_analysis, RouteResult
TEST_ENV = Path("tests/env")
def test_run_analysis_no_file():
"""
Verifies that run_analysis returns non-executed metadata route when file_path is None.
"""
result = run_analysis(None)
assert not result.success
assert len(result.routes) == 1
assert not result.routes[0].executed
assert "No file attached" in result.routes[0].message
def test_run_analysis_file_not_found():
"""
Verifies that run_analysis returns a failed metadata route when path does not exist.
"""
result = run_analysis(Path("non_existent_file_xyz.jpg"))
assert not result.success
assert len(result.routes) == 1
assert result.routes[0].executed
assert not result.routes[0].success
assert "File not found" in result.routes[0].message
def test_run_analysis_success_with_flag():
"""
Verifies that run_analysis successfully extracts flag from file comment.
"""
import struct
comment_text = b"flag{metadata_analyse_success}"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text + b"\xff\xd9"
test_file = TEST_ENV / "mock_analyse_flag.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = run_analysis(test_file)
assert result.success
assert len(result.routes) == 1
assert result.routes[0].success
assert "flag{metadata_analyse_success}" in result.routes[0].extracted_flags
def test_cli_analyse_success():
"""
Verifies that 'ctf analyse' CLI successfully runs and outputs the extracted flags.
"""
from ctf.main import cli
runner = CliRunner()
import struct
comment_text = b"flag{cli_analyse_success}"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text + b"\xff\xd9"
test_file = TEST_ENV / "mock_cli_analyse_flag.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = runner.invoke(cli, ["analyse", "-f", str(test_file)])
assert result.exit_code == 0
assert "Starting dynamic challenge analysis" in result.output
assert "Route: File Metadata Extraction" in result.output
assert "flag{cli_analyse_success}" in result.output
assert "Challenge solved successfully!" in result.output
def test_cli_analyse_no_file():
"""
Verifies that 'ctf analyse' CLI displays warnings when run without -f option.
"""
from ctf.main import cli
runner = CliRunner()
result = runner.invoke(cli, ["analyse"])
assert result.exit_code == 0
assert "No file attached to analyze" in result.output
assert "No clear flag found yet" in result.output
# }}}
# {{{ test_metadata_route_direct_execute
def test_metadata_route_direct_execute():
"""
Verifies that MetadataRoute can be instantiated and executed directly.
"""
from ctf.analysis import MetadataRoute, RouteResult
route = MetadataRoute()
assert route.route_name == "File Metadata Extraction"
assert route.is_applicable(Path("dummy.txt"))
# Executing on non-existent file
res = route.execute(Path("non_existent_file_xyz.bin"))
assert isinstance(res, RouteResult)
assert res.executed
assert not res.success
assert "File not found" in res.message
# }}}
# {{{ test_routing_engine_register_and_run
def test_routing_engine_register_and_run():
"""
Verifies that RoutingEngine successfully registers and runs applicable routes.
"""
from ctf.analysis import RoutingEngine, BaseRoute, RouteResult
class MockRoute(BaseRoute):
def __init__(self):
super().__init__("Mock Route")
def is_applicable(self, file_path: Path) -> bool:
return file_path.name.endswith(".mock")
def execute(self, file_path: Path) -> RouteResult:
return RouteResult(
route_name=self.route_name,
executed=True,
success=True,
message="Mock success",
extracted_flags=["flag{mock_success}"]
)
engine = RoutingEngine()
route = MockRoute()
engine.register_route(route)
# Run with no file path
res_no_path = engine.run_all(None)
assert not res_no_path.success
assert len(res_no_path.routes) == 1
assert not res_no_path.routes[0].executed
# Run with non-applicable file path
res_non_app = engine.run_all(Path("test.txt"))
assert not res_non_app.success
assert len(res_non_app.routes) == 0
# Run with applicable file path
res_app = engine.run_all(Path("test.mock"))
assert res_app.success
assert len(res_app.routes) == 1
assert res_app.routes[0].success
assert "flag{mock_success}" in res_app.routes[0].extracted_flags
# }}}
+95
View File
@@ -0,0 +1,95 @@
# tests/test_artifacts.py
from pathlib import Path
from ctf.forensics import get_metadata
from ctf.analysis import run_analysis
ARTIFACTS_DIR = Path("tests/artifacts")
# {{{ test_flag_txt
def test_flag_txt():
"""Asserts flag.txt holds the direct plaintext flag."""
flag_file = ARTIFACTS_DIR / "flag.txt"
content = flag_file.read_text().strip()
assert content == "picoCTF{h1dd3n_1n_1m4g3_5d4cba73}"
# }}}
# {{{ test_cat_jpg
def test_cat_jpg():
"""Asserts cat.jpg contains the base64-encoded license flag."""
file_path = ARTIFACTS_DIR / "cat.jpg"
meta = get_metadata(file_path)
# Check that license metadata has the base64 encoded hint
assert "EXIF:cc:license" in meta.decoded_hints
assert meta.decoded_hints["EXIF:cc:license"].get("base64") == "picoCTF{the_m3tadata_1s_modified}"
# Run analysis
res = run_analysis(file_path)
assert res.success
assert "picoCTF{the_m3tadata_1s_modified}" in res.routes[0].extracted_flags
# }}}
# {{{ test_easy_cat_jpg
def test_easy_cat_jpg():
"""Asserts easy-cat.jpg contains comment flag and license flag."""
file_path = ARTIFACTS_DIR / "easy-cat.jpg"
meta = get_metadata(file_path)
assert meta.comment == "picoCTF{easypeasy}"
# Run analysis
res = run_analysis(file_path)
assert res.success
flags = res.routes[0].extracted_flags
assert "picoCTF{easypeasy}" in flags
assert "picoCTF{the_m3tadata_1s_modified}" in flags
# }}}
# {{{ test_easy_cat_jpg_original
def test_easy_cat_jpg_original():
"""Asserts easy-cat.jpg_original contains only the license flag."""
file_path = ARTIFACTS_DIR / "easy-cat.jpg_original"
meta = get_metadata(file_path)
assert not meta.comment
res = run_analysis(file_path)
assert res.success
assert "picoCTF{the_m3tadata_1s_modified}" in res.routes[0].extracted_flags
# }}}
# {{{ test_garden_jpg
def test_garden_jpg():
"""Asserts garden.jpg contains no metadata flags."""
file_path = ARTIFACTS_DIR / "garden.jpg"
meta = get_metadata(file_path)
assert not meta.comment
res = run_analysis(file_path)
assert not res.success
assert not res.routes[0].extracted_flags
# }}}
# {{{ test_hips_jpg
def test_hips_jpg():
"""Asserts hips.jpg contains comment hint but no actual flags."""
file_path = ARTIFACTS_DIR / "hips.jpg"
meta = get_metadata(file_path)
assert "Comment" in meta.decoded_hints
# Steghide password hint decodes to pAzzword, not a flag
assert meta.decoded_hints["Comment"].get("base64->base64") == "pAzzword"
res = run_analysis(file_path)
assert not res.success
assert not res.routes[0].extracted_flags
# }}}
# {{{ test_red_png
def test_red_png():
"""Asserts red.png contains comment hint but no actual flags."""
file_path = ARTIFACTS_DIR / "red.png"
meta = get_metadata(file_path)
assert "Comment" in meta.decoded_hints
assert meta.decoded_hints["Comment"].get("base64->base64") == "pAzzword"
res = run_analysis(file_path)
assert not res.success
assert not res.routes[0].extracted_flags
# }}}
-156
View File
@@ -1,156 +0,0 @@
from pathlib import Path
import toml
from click.testing import CliRunner
from ctf.commands import basic_group
TEST_ENV = Path("tests/env")
def test_basic_test_cli():
"""
Verifies that 'basic test' runs successfully and outputs 'hello from test'.
"""
runner = CliRunner()
result = runner.invoke(basic_group, ["test"])
assert result.exit_code == 0
assert "hello from test" in result.output
def test_basic_greet_cli():
"""
Verifies that 'basic greet' runs successfully and greets the name argument.
"""
runner = CliRunner()
result = runner.invoke(basic_group, ["greet", "Alice"])
assert result.exit_code == 0
assert "hello Alice" in result.output
def test_basic_set_flag_format_cli():
"""
Verifies that 'basic set-flag-format' CLI command writes the pattern to config.toml.
"""
from ctf.utils import load_config
runner = CliRunner()
config_file = Path("/home/venus/code/ctf/config.toml")
# Save the original config to restore later
original_config = load_config(str(config_file))
try:
result = runner.invoke(basic_group, ["set-flag-format", "TEST_FLAG{[a-z]+}"])
assert result.exit_code == 0
assert "Flag format set to" in result.output
# Verify it was written to config.toml
updated_config = load_config(str(config_file))
assert updated_config["Competition"]["flag_format"] == "TEST_FLAG{[a-z]+}"
finally:
# Restore original config
from ctf.utils import write_config
write_config(original_config, str(config_file))
def test_basic_set_flag_format_original_option_cli():
"""
Verifies that 'basic set-flag-format' with --original/-o option prompts for selection and writes it.
"""
from ctf.utils import load_config
runner = CliRunner()
config_file = Path("/home/venus/code/ctf/config.toml")
# Save the original config to restore later
original_config = load_config(str(config_file))
try:
# We pass -o SKY-1111-000 and input "2" to choose the second suggested pattern
result = runner.invoke(basic_group, ["set-flag-format", "-o", "SKY-1111-000"], input="2\n")
assert result.exit_code == 0
comp_name = original_config.get("Competition", {}).get("competition", "")
if comp_name:
assert f"Suggested regex patterns for competition {comp_name}:" in result.output
else:
assert "Suggested regex patterns:" in result.output
assert "Select a pattern index" in result.output
assert "Flag format set to" in result.output
# Verify it was written to config.toml
updated_config = load_config(str(config_file))
selected_pattern = updated_config["Competition"]["flag_format"]
assert len(selected_pattern) > 0
finally:
# Restore original config
from ctf.utils import write_config
write_config(original_config, str(config_file))
def test_basic_set_flag_format_validation_cli():
"""
Verifies validation rules:
- Specifying both PATTERN and --original should fail.
- Specifying neither PATTERN nor --original should fail.
- Specifying empty/whitespace-only original flag should fail.
"""
runner = CliRunner()
# Both specified
result1 = runner.invoke(basic_group, ["set-flag-format", "PAT", "-o", "SKY-1111-000"])
assert result1.exit_code != 0
assert "Cannot specify both PATTERN and --original/-o option." in result1.output
# Neither specified
result2 = runner.invoke(basic_group, ["set-flag-format"])
assert result2.exit_code != 0
assert "Either PATTERN positional argument or --original/-o option must be specified." in result2.output
# Empty original
result3 = runner.invoke(basic_group, ["set-flag-format", "-o", " "])
assert result3.exit_code != 0
assert "Original flag cannot be empty or whitespace only." in result3.output
def test_basic_set_competition_cli():
"""
Verifies that 'basic set-competition' updates the competition name in config.toml.
"""
from ctf.utils import load_config
runner = CliRunner()
config_file = Path("/home/venus/code/ctf/config.toml")
original_config = load_config(str(config_file))
try:
result = runner.invoke(basic_group, ["set-competition", "CyberCTF2026"])
assert result.exit_code == 0
assert "Competition name set to: CyberCTF2026" in result.output
updated_config = load_config(str(config_file))
assert updated_config["Competition"]["competition"] == "CyberCTF2026"
finally:
from ctf.utils import write_config
write_config(original_config, str(config_file))
def test_basic_set_flag_format_warning_cli():
"""
Verifies that if competition name is set and not present in the example flag,
set-flag-format outputs a warning.
"""
from ctf.utils import load_config, write_config
runner = CliRunner()
config_file = Path("/home/venus/code/ctf/config.toml")
original_config = load_config(str(config_file))
try:
# Set competition name first
temp_config = load_config(str(config_file))
if "Competition" not in temp_config:
temp_config["Competition"] = {}
temp_config["Competition"]["competition"] = "SECURE"
write_config(temp_config, str(config_file))
# Now run set-flag-format with an example flag that has no "SECURE" substring
# We also pass input "1" to satisfy the choice prompt
result = runner.invoke(basic_group, ["set-flag-format", "-o", "CTF{easy_flag_123}"], input="1\n")
assert result.exit_code == 0
assert "Warning: Current competition name 'SECURE' was not found in the example flag." in result.output
finally:
write_config(original_config, str(config_file))
+35
View File
@@ -0,0 +1,35 @@
# tests/test_config.py
import toml
from pathlib import Path
from ctf.config import Config
TEST_ENV = Path("tests/env")
# {{{ test_load_config_static
def test_load_config_static():
"""Verifies Config loading using a persistent test file."""
config_file = TEST_ENV / "config.toml"
test_data = {
"Competition": {"name": "PersistentComp"},
"Enviroment": {"data_dir": str(TEST_ENV.absolute())}
}
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, "w") as f:
toml.dump(test_data, f)
cfg = Config(config_file)
assert cfg.data["Competition"]["name"] == "PersistentComp"
# }}}
# {{{ test_write_config_static
def test_write_config_static():
"""Verifies Config writing by saving and reloading."""
config_file = TEST_ENV / "test_write_config.toml"
test_data = {"TestKey": "TestVal"}
cfg = Config(config_file)
cfg.save(test_data)
reloaded = Config(config_file)
assert reloaded.data == test_data
# }}}
+28
View File
@@ -133,3 +133,31 @@ def test_try_decode_metadata_rot13_unique_only():
assert "rot13" not in res_duplicate
# }}}
# {{{ test_decoder_subclasses_and_registry
def test_decoder_subclasses_and_registry():
"""
Verifies that DecodingRegistry successfully registers concrete decoders and
that individual BaseDecoder subclasses correctly decode matching strings.
"""
from ctf.decoding import DecodingRegistry, Base64Decoder, HexDecoder, Rot13Decoder
registry = DecodingRegistry.get_default_registry()
assert len(registry.decoders) >= 7
# Assert Base64Decoder behaves correctly
b64_dec = Base64Decoder()
assert b64_dec.name == "base64"
assert b64_dec.is_applicable("ZmxhZ3tiNjRfbWV0YWRhdGF9")
assert not b64_dec.is_applicable("A")
assert b64_dec.attempt_single("ZmxhZ3tiNjRfbWV0YWRhdGF9") == "flag{b64_metadata}"
# Assert Rot13Decoder validates correctly
rot_dec = Rot13Decoder()
assert rot_dec.name == "rot13"
assert rot_dec.attempt_single("synt{ebg13}") == "flag{rot13}"
assert rot_dec.attempt_single("abezny grkg") is None # Fails validate check
# }}}
+100
View File
@@ -0,0 +1,100 @@
# tests/test_helpers.py
from pathlib import Path
from ctf.helpers import check_for_flag
# {{{ test_check_for_flag
def test_check_for_flag():
"""
Verifies that check_for_flag extracts multiple matching flags or returns empty list.
"""
res = check_for_flag("flag{one} and flag{two}")
assert set(res) == {"flag{one}", "flag{two}"}
res_custom = check_for_flag("custom{first} custom{second}", r"custom\{[a-z]+\}")
assert set(res_custom) == {"custom{first}", "custom{second}"}
assert check_for_flag("no flag matches here") == []
# }}}
# {{{ test_is_valid_flag
def test_is_valid_flag():
"""
Verifies that is_valid_flag validates flags and supports uniqueness checks.
"""
from ctf.helpers import is_valid_flag
# Valid flag
assert is_valid_flag("flag{test}")
# Invalid flag
assert not is_valid_flag("no flag here")
# Uniqueness check (success)
assert is_valid_flag("flag{new}", original="flag{old}")
# Uniqueness check (failure)
assert not is_valid_flag("flag{same}", original="flag{same}")
# }}}
# {{{ test_flag_detector_stream_detection
def test_flag_detector_stream_detection():
"""
Verifies that FlagDetectorStream successfully intercepts writes to output potential flags.
"""
from io import StringIO
from ctf.helpers import FlagDetectorStream
out = StringIO()
stream = FlagDetectorStream(out, r"flag\{[a-z_]+\}")
stream.write("Some text before flag{my_flag_here} and text after.")
output = out.getvalue()
assert "Potential flag(s) detected in command output" in output
assert "flag{my_flag_here}" in output
# }}}
# {{{ test_flag_detector_stream_anchored_stripping
def test_flag_detector_stream_anchored_stripping():
"""
Verifies that FlagDetectorStream strips standard anchors ^ and $ to support substring searches.
"""
from io import StringIO
from ctf.helpers import FlagDetectorStream
out = StringIO()
stream = FlagDetectorStream(out, r"^flag\{[a-z_]+\}$")
stream.write("random flag{my_flag_here} data")
output = out.getvalue()
assert "Potential flag(s) detected in command output" in output
assert "flag{my_flag_here}" in output
# }}}
# {{{ test_flag_detector_stream_no_pattern
def test_flag_detector_stream_no_pattern():
"""
Verifies that FlagDetectorStream passes through text unmodified if no flag pattern is configured.
"""
from io import StringIO
from ctf.helpers import FlagDetectorStream
out = StringIO()
stream = FlagDetectorStream(out, "")
stream.write("normal output flag{hello}")
assert out.getvalue() == "normal output flag{hello}"
# }}}
# {{{ test_flag_detector_stream_persistence
def test_flag_detector_stream_persistence():
"""
Verifies that FlagDetectorStream writes the detected flag to config.toml.
"""
from io import StringIO
from ctf.helpers import FlagDetectorStream
from ctf.config import Config
out = StringIO()
stream = FlagDetectorStream(out, r"flag\{[a-z_]+\}")
stream.write("found flag{persisted_flag} in stdout")
# Reload config and check last_flag
cfg = Config()
assert cfg.data.get("Competition", {}).get("last_flag") == "flag{persisted_flag}"
# }}}
+14 -132
View File
@@ -1,3 +1,4 @@
# tests/test_main.py
import sys
import pytest
from unittest.mock import patch
@@ -17,141 +18,22 @@ def test_main_entry_point_help():
# Verify it exits with a successful exit code (0)
assert exc_info.value.code == 0
def test_flag_detector_stream_detection():
# {{{ test_debug_exception_handler
def test_debug_exception_handler():
"""
Verifies that FlagDetectorStream successfully intercepts writes to output potential flags.
Verifies that debug_exception_handler prints traceback and invokes pdb.post_mortem.
"""
from io import StringIO
from ctf.main import FlagDetectorStream
from ctf.main import debug_exception_handler
from unittest.mock import patch, MagicMock
out = StringIO()
stream = FlagDetectorStream(out, r"flag\{[a-z_]+\}")
stream.write("Some text before flag{my_flag_here} and text after.")
output = out.getvalue()
assert "Potential flag(s) detected in command output" in output
assert "flag{my_flag_here}" in output
def test_flag_detector_stream_anchored_stripping():
"""
Verifies that FlagDetectorStream strips standard anchors ^ and $ to support substring searches.
"""
from io import StringIO
from ctf.main import FlagDetectorStream
out = StringIO()
stream = FlagDetectorStream(out, r"^flag\{[a-z_]+\}$")
stream.write("random flag{my_flag_here} data")
output = out.getvalue()
assert "Potential flag(s) detected in command output" in output
assert "flag{my_flag_here}" in output
def test_flag_detector_stream_no_pattern():
"""
Verifies that FlagDetectorStream passes through text unmodified if no flag pattern is configured.
"""
from io import StringIO
from ctf.main import FlagDetectorStream
out = StringIO()
stream = FlagDetectorStream(out, "")
stream.write("normal output flag{hello}")
assert out.getvalue() == "normal output flag{hello}"
def test_flag_detector_stream_persistence():
"""
Verifies that FlagDetectorStream writes the detected flag to config.toml.
"""
from io import StringIO
from ctf.main import FlagDetectorStream
from ctf.utils import load_config, write_config
from pathlib import Path
config_file = Path("/home/venus/code/ctf/config.toml")
original_config = load_config(str(config_file))
try:
out = StringIO()
stream = FlagDetectorStream(out, r"flag\{[a-z_]+\}")
stream.write("found flag{persisted_flag} in stdout")
# Reload config and check last_flag
updated_config = load_config(str(config_file))
assert updated_config.get("Competition", {}).get("last_flag") == "flag{persisted_flag}"
finally:
write_config(original_config, str(config_file))
def test_flag_cmd_cli():
"""
Verifies the ctf flag command outputs last_flag with and without --plain.
"""
from click.testing import CliRunner
from ctf.main import cli
from ctf.utils import load_config, write_config
from pathlib import Path
config_file = Path("/home/venus/code/ctf/config.toml")
original_config = load_config(str(config_file))
try:
# Pre-set last_flag in config
temp_config = load_config(str(config_file))
if "Competition" not in temp_config:
temp_config["Competition"] = {}
temp_config["Competition"]["last_flag"] = "flag{test_cli_flag}"
write_config(temp_config, str(config_file))
runner = CliRunner()
# Test standard flag command output
result_std = runner.invoke(cli, ["flag"])
assert result_std.exit_code == 0
assert "Last detected flag: flag{test_cli_flag}" in result_std.output
# Test plain flag command output (no flavor text, has newline)
result_plain = runner.invoke(cli, ["flag", "--plain"])
assert result_plain.exit_code == 0
assert result_plain.output.strip() == "flag{test_cli_flag}"
finally:
write_config(original_config, str(config_file))
def test_flag_cmd_exemption(capsys):
"""
Verifies that running ctf flag is exempted from triggering the stdout flag warning box.
"""
from ctf.main import main
from ctf.utils import load_config, write_config
from pathlib import Path
import sys
from unittest.mock import patch
import pytest
config_file = Path("/home/venus/code/ctf/config.toml")
original_config = load_config(str(config_file))
try:
temp_config = load_config(str(config_file))
if "Competition" not in temp_config:
temp_config["Competition"] = {}
temp_config["Competition"]["flag_format"] = r"flag\{[a-z_]+\}"
temp_config["Competition"]["last_flag"] = "flag{test_exempt_flag}"
write_config(temp_config, str(config_file))
with patch.object(sys, "argv", ["ctf", "flag"]):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
captured = capsys.readouterr()
# Verify that the warning box is NOT in stdout
assert "Potential flag(s) detected in command output" not in captured.out
# Verify that the actual flag info IS printed
assert "Last detected flag: flag{test_exempt_flag}" in captured.out
finally:
write_config(original_config, str(config_file))
mock_tb = MagicMock()
exc_val = ValueError("test error")
with patch("traceback.print_exception") as mock_print, \
patch("pdb.post_mortem") as mock_pm:
debug_exception_handler(ValueError, exc_val, mock_tb)
mock_print.assert_called_once_with(ValueError, exc_val, mock_tb)
mock_pm.assert_called_once_with(mock_tb)
# }}}