Compare commits
22
Commits
cd7ef151c4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75614f6a21 | ||
|
|
058b5c1eb5 | ||
|
|
c61bfa11e5 | ||
|
|
3c7fedbb13 | ||
|
|
0cd24740c8 | ||
|
|
87b5d71a64 | ||
|
|
6d92d16dc3 | ||
|
|
eae3286da4 | ||
|
|
fac6ada1db | ||
|
|
eb06206e32 | ||
|
|
7bd450abed | ||
|
|
7691b366f2 | ||
|
|
fe08e0b280 | ||
|
|
dd69a4c723 | ||
|
|
185482f3d5 | ||
|
|
7f00bdb385 | ||
|
|
b03df0af6f | ||
|
|
4bddd4a49b | ||
|
|
8b82058cc6 | ||
|
|
993bc2e934 | ||
|
|
41b6612320 | ||
|
|
46c317fca1 |
@@ -3,7 +3,9 @@
|
||||
This project serves as a centralized workspace for Capture The Flag (CTF) challenges and as a practical environment for learning Python.
|
||||
|
||||
## 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 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.
|
||||
@@ -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"`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
# }}}
|
||||
@@ -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)
|
||||
# }}}
|
||||
@@ -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]")
|
||||
# }}}
|
||||
@@ -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}")
|
||||
# }}}
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
# src/ctf/cli_steg.py
|
||||
# src/ctf/cli/steg.py
|
||||
# {{{ imports
|
||||
import click
|
||||
from pathlib import Path
|
||||
@@ -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", {})
|
||||
# }}}
|
||||
+139
-106
@@ -1,119 +1,152 @@
|
||||
# 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
|
||||
|
||||
return True
|
||||
# }}}
|
||||
# {{{ 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
|
||||
|
||||
# {{{ 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):
|
||||
matched_val = match.group(0)
|
||||
if len_check and not len_check(matched_val):
|
||||
continue
|
||||
|
||||
try:
|
||||
chepy_inst = Chepy(matched_val)
|
||||
method = getattr(chepy_inst, method_name)
|
||||
decoded = method().state
|
||||
if isinstance(decoded, bytes):
|
||||
dec_str = decoded.decode("utf-8")
|
||||
elif isinstance(decoded, str):
|
||||
dec_str = decoded
|
||||
else:
|
||||
dec_str = ""
|
||||
def is_applicable(self, matched_val: str) -> bool:
|
||||
"""Determines if the matched substring is applicable for decoding (e.g. length checks)."""
|
||||
return True
|
||||
|
||||
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 not self.is_applicable(matched_val):
|
||||
continue
|
||||
|
||||
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):
|
||||
continue
|
||||
return dec_str
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
try:
|
||||
chepy_inst = Chepy(matched_val)
|
||||
method = getattr(chepy_inst, self.method_name)
|
||||
decoded = method().state
|
||||
if isinstance(decoded, bytes):
|
||||
dec_str = decoded.decode("utf-8")
|
||||
elif isinstance(decoded, str):
|
||||
dec_str = decoded
|
||||
else:
|
||||
dec_str = ""
|
||||
|
||||
if dec_str.strip() and all(32 <= ord(c) < 127 or c in "\r\n\t" for c in dec_str):
|
||||
if not self.validate(dec_str, val):
|
||||
continue
|
||||
return dec_str
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
# }}}
|
||||
|
||||
# {{{ 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()
|
||||
|
||||
results = {}
|
||||
if not isinstance(val, str) or not val.strip() or max_depth <= 0:
|
||||
return results
|
||||
|
||||
cleaned_val = val.strip()
|
||||
if cleaned_val in seen:
|
||||
return results
|
||||
seen.add(cleaned_val)
|
||||
|
||||
for decoder in self.decoders:
|
||||
decoded_val = decoder.attempt_single(cleaned_val)
|
||||
if decoded_val is not None:
|
||||
chain_name = f"{current_chain}->{decoder.name}" if current_chain else decoder.name
|
||||
results[chain_name] = decoded_val
|
||||
|
||||
# Recurse
|
||||
nested_results = self.try_decode(
|
||||
decoded_val,
|
||||
current_chain=chain_name,
|
||||
max_depth=max_depth - 1,
|
||||
seen=set(seen)
|
||||
)
|
||||
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]:
|
||||
"""Attempts to decode a metadata value using defined formats recursively."""
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
results = {}
|
||||
if not isinstance(val, str) or not val.strip() or max_depth <= 0:
|
||||
return results
|
||||
|
||||
cleaned_val = val.strip()
|
||||
if cleaned_val in seen:
|
||||
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)
|
||||
if decoded_val is not None:
|
||||
chain_name = f"{current_chain}->{name}" if current_chain else 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(
|
||||
decoded_val,
|
||||
current_chain=chain_name,
|
||||
max_depth=max_depth - 1,
|
||||
seen=set(seen)
|
||||
)
|
||||
results.update(nested_results)
|
||||
|
||||
return results
|
||||
"""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)
|
||||
# }}}
|
||||
|
||||
@@ -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 ""
|
||||
# }}}
|
||||
@@ -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()
|
||||
# }}}
|
||||
@@ -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
|
||||
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
|
||||
|
||||
# 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:
|
||||
# {{{ 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:
|
||||
tags.update(parse_tiff(exif_data))
|
||||
except Exception: pass
|
||||
xmp_str = extract_jpeg_xmp(data)
|
||||
if xmp_str:
|
||||
try:
|
||||
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,23 +220,7 @@ 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
|
||||
NS_MAP = {
|
||||
"http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf",
|
||||
"http://creativecommons.org/ns#": "cc",
|
||||
@@ -504,7 +229,7 @@ NS_MAP = {
|
||||
"http://ns.adobe.com/exif/1.0/": "exif",
|
||||
"http://ns.adobe.com/tiff/1.0/": "tiff",
|
||||
}
|
||||
#}}}
|
||||
# }}}
|
||||
|
||||
# {{{ parse_xmp
|
||||
def parse_xmp(xmp_str: str) -> Dict[str, Any]:
|
||||
@@ -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)
|
||||
# }}}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
# }}}
|
||||
@@ -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
|
||||
# }}}
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# }}}
|
||||
@@ -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]+}"
|
||||
# }}}
|
||||
@@ -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
|
||||
# }}}
|
||||
@@ -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
|
||||
# }}}
|
||||
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 38 B |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 33 B |
Vendored
+1
@@ -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
|
||||
|
||||
# }}}
|
||||
@@ -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"") == {}
|
||||
# }}}
|
||||
@@ -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
|
||||
# }}}
|
||||
|
||||
@@ -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
|
||||
# }}}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
# }}}
|
||||
@@ -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
|
||||
# }}}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
# }}}
|
||||
|
||||
|
||||
+15
-133
@@ -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")
|
||||
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)
|
||||
|
||||
# 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_print.assert_called_once_with(ValueError, exc_val, mock_tb)
|
||||
mock_pm.assert_called_once_with(mock_tb)
|
||||
# }}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user