Compare commits
21
Commits
46c317fca1
...
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 |
@@ -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.
|
This project serves as a centralized workspace for Capture The Flag (CTF) challenges and as a practical environment for learning Python.
|
||||||
|
|
||||||
## Project Intent
|
## 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
|
## 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.
|
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:
|
When discussing new implementations, features, or additions:
|
||||||
1. **Discussion**: Discuss implementation details and potential approaches.
|
1. **Discussion**: Discuss implementation details and potential approaches.
|
||||||
2. **The Pitch**: Provide a clear, technical pitch detailing the planned code modifications.
|
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:
|
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.
|
* **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"`).
|
* **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"`).
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Pure-Python, zero-dependency parsers extract metadata without using heavy extern
|
|||||||
* `ctf flag`: Output the last captured flag from the configuration.
|
* `ctf flag`: Output the last captured flag from the configuration.
|
||||||
|
|
||||||
### Forensics Commands
|
### Forensics Commands
|
||||||
* `ctf inspect <file>`: Detailed metadata inspection.
|
* `ctf forensics metadata <file>`: Detailed metadata inspection.
|
||||||
* `-i, --inode`: Output POSIX file attributes only.
|
* `-i, --inode`: Output POSIX file attributes only.
|
||||||
* `-e, --exif`: Output EXIF/XMP/IPTC data and decoded hints.
|
* `-e, --exif`: Output EXIF/XMP/IPTC data and decoded hints.
|
||||||
* `-p, --physical`: Output physical parameters of the target media.
|
* `-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/ctf/cli/basic.py
|
||||||
# src/commands.py
|
# {{{ imports
|
||||||
|
|
||||||
# vim foldmethod=marker
|
|
||||||
import click
|
import click
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
# }}}
|
||||||
|
|
||||||
# {{{ basic_group
|
# {{{ basic_group
|
||||||
# This defines a group with name basic which will nest other comands to be imported in the main loop
|
# 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
|
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.
|
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:
|
if pattern is None and original is None:
|
||||||
raise click.UsageError("Either PATTERN positional argument or --original/-o option must be specified.")
|
raise click.UsageError("Either PATTERN positional argument or --original/-o option must be specified.")
|
||||||
if pattern is not None and original is not None:
|
if pattern is not None and original is not None:
|
||||||
raise click.UsageError("Cannot specify both PATTERN and --original/-o option.")
|
raise click.UsageError("Cannot specify both PATTERN and --original/-o option.")
|
||||||
|
|
||||||
config_path = "/home/venus/code/ctf/config.toml"
|
cfg = Config()
|
||||||
config = load_config(config_path)
|
|
||||||
|
|
||||||
if "Competition" not in config:
|
if "Competition" not in cfg.data:
|
||||||
config["Competition"] = {}
|
cfg.data["Competition"] = {}
|
||||||
|
|
||||||
comp_name = config["Competition"].get("competition", "")
|
comp_name = cfg.data["Competition"].get("competition", "")
|
||||||
|
|
||||||
if original:
|
if original:
|
||||||
if not original.strip():
|
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)))
|
val = click.prompt("Select a pattern index", type=click.IntRange(1, len(patterns)))
|
||||||
pattern = patterns[val - 1]
|
pattern = patterns[val - 1]
|
||||||
|
|
||||||
config["Competition"]["flag_format"] = pattern
|
cfg.data["Competition"]["flag_format"] = pattern
|
||||||
write_config(config, config_path)
|
cfg.save(cfg.data)
|
||||||
click.echo(f"Flag format set to: {pattern}")
|
click.echo(f"Flag format set to: {pattern}")
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
@@ -77,16 +76,13 @@ def set_flag_format(pattern: str, original: str):
|
|||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
def set_competition(name: str):
|
def set_competition(name: str):
|
||||||
"""Set the name of the active competition."""
|
"""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"
|
cfg = Config()
|
||||||
config = load_config(config_path)
|
|
||||||
|
|
||||||
if "Competition" not in config:
|
if "Competition" not in cfg.data:
|
||||||
config["Competition"] = {}
|
cfg.data["Competition"] = {}
|
||||||
|
cfg.data["Competition"]["competition"] = name
|
||||||
config["Competition"]["competition"] = name
|
cfg.save(cfg.data)
|
||||||
write_config(config, config_path)
|
|
||||||
click.echo(f"Competition name set to: {name}")
|
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
|
# src/ctf/cli/forensics.py
|
||||||
# CLI wrapper and rendering layer for forensics commands
|
# {{{ imports
|
||||||
|
|
||||||
# vim foldmethod=marker
|
|
||||||
import click
|
import click
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from ctf.forensics import get_metadata, COMMON_SIGNATURES
|
from ctf.forensics import get_metadata, COMMON_SIGNATURES
|
||||||
|
# }}}
|
||||||
|
|
||||||
# {{{ forensics_group
|
# {{{ forensics_group
|
||||||
@click.group(name="forensics")
|
@click.group(name="forensics")
|
||||||
@@ -13,13 +12,13 @@ def forensics_group():
|
|||||||
pass
|
pass
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
# {{{ inspect
|
# {{{ metadata
|
||||||
@forensics_group.command()
|
@forensics_group.command(name="metadata")
|
||||||
@click.argument('path', type=click.Path(exists=True))
|
@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('-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('-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.")
|
@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"""
|
"""Lists all basic inode metadata and EXIF data about a file"""
|
||||||
flags = [inode, exif, physical]
|
flags = [inode, exif, physical]
|
||||||
if sum(flags) > 1:
|
if sum(flags) > 1:
|
||||||
@@ -140,4 +139,3 @@ def list_signatures():
|
|||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# src/ctf/cli_steg.py
|
# src/ctf/cli/steg.py
|
||||||
# {{{ imports
|
# {{{ imports
|
||||||
import click
|
import click
|
||||||
from pathlib import Path
|
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
|
# src/ctf/decoding.py
|
||||||
# {{{ imports
|
# {{{ imports
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from chepy import Chepy
|
from chepy import Chepy
|
||||||
import re
|
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
|
# {{{ BaseDecoder
|
||||||
def is_valid_flag(s: str, original: str = "") -> bool:
|
class BaseDecoder(ABC):
|
||||||
"""
|
"""Abstract base class representing a Chepy-based metadata decoder."""
|
||||||
Checks if the string contains a flag matching the active pattern or a generic pattern.
|
def __init__(self, name: str, pattern: str, method_name: str):
|
||||||
If original is provided, ensures that the decoded string contains a new unique flag
|
self.name = name
|
||||||
that was not already present in the original string.
|
self.pattern = pattern
|
||||||
"""
|
self.method_name = method_name
|
||||||
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
|
|
||||||
# }}}
|
|
||||||
|
|
||||||
# {{{ attempt_decode
|
def is_applicable(self, matched_val: str) -> bool:
|
||||||
def attempt_decode(val: str, name: str, pattern: str, method_name: str, len_check=None, validator=None) -> str | None:
|
"""Determines if the matched substring is applicable for decoding (e.g. length checks)."""
|
||||||
"""Helper to run regex matching, dynamic Chepy decoding, and string printability checks."""
|
return True
|
||||||
# Find all matches sequentially and try decoding
|
|
||||||
for match in re.finditer(pattern, val):
|
def validate(self, decoded_str: str, original_str: str) -> bool:
|
||||||
matched_val = match.group(0)
|
"""Validates if the decoded string is expected/correct (e.g. flag regex checks)."""
|
||||||
if len_check and not len_check(matched_val):
|
return True
|
||||||
continue
|
|
||||||
|
def attempt_single(self, val: str) -> str | None:
|
||||||
try:
|
"""Searches val for matches and returns the first successfully decoded printable string."""
|
||||||
chepy_inst = Chepy(matched_val)
|
for match in re.finditer(self.pattern, val):
|
||||||
method = getattr(chepy_inst, method_name)
|
matched_val = match.group(0)
|
||||||
decoded = method().state
|
if not self.is_applicable(matched_val):
|
||||||
if isinstance(decoded, bytes):
|
continue
|
||||||
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):
|
try:
|
||||||
if validator and not validator(dec_str, val):
|
chepy_inst = Chepy(matched_val)
|
||||||
continue
|
method = getattr(chepy_inst, self.method_name)
|
||||||
return dec_str
|
decoded = method().state
|
||||||
except Exception:
|
if isinstance(decoded, bytes):
|
||||||
pass
|
dec_str = decoded.decode("utf-8")
|
||||||
return None
|
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
|
# {{{ try_decode_metadata
|
||||||
def try_decode_metadata(val: str, current_chain: str = "", max_depth: int = 10, seen: Set[str] = None) -> Dict[str, str]:
|
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."""
|
"""Compatibility wrapper that routes decoding requests to the default registry."""
|
||||||
if seen is None:
|
registry = DecodingRegistry.get_default_registry()
|
||||||
seen = set()
|
return registry.try_decode(val, current_chain, max_depth, seen)
|
||||||
|
|
||||||
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
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|||||||
@@ -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
|
# src/ctf/forensics/jpeg.py
|
||||||
# Library for forensic analysis (pure functions only)
|
# {{{ imports
|
||||||
|
|
||||||
# vim foldmethod=marker
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
import stat
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import struct
|
import struct
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import List, Dict, Any
|
from typing import Dict, Any
|
||||||
|
from ctf.forensics.base import FormatParser
|
||||||
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"]),
|
|
||||||
}
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
# {{{ FileMetadata
|
# {{{ JpegParser
|
||||||
@dataclass
|
class JpegParser(FormatParser):
|
||||||
class FileMetadata:
|
def is_matching(self, data: bytes) -> bool:
|
||||||
filename: str
|
return data.startswith(b"\xff\xd8\xff")
|
||||||
size: int
|
def parse_physical(self, data: bytes) -> Dict[str, Any]:
|
||||||
magic: str
|
return get_jpeg_physical(data)
|
||||||
extension: str
|
def get_comment(self, data: bytes) -> str:
|
||||||
detected_type: str
|
return extract_jpeg_comment(data)
|
||||||
|
def get_exif_tags(self, data: bytes) -> Dict[str, Any]:
|
||||||
# Task 1: POSIX Permissions
|
tags = {}
|
||||||
permissions_octal: str
|
exif_data = extract_jpeg_exif(data)
|
||||||
permissions_symbolic: str
|
if exif_data:
|
||||||
|
try:
|
||||||
# Task 2: Ownership Identity
|
tags.update(parse_tiff(exif_data))
|
||||||
owner_uid: int
|
except Exception: pass
|
||||||
owner_username: str
|
xmp_str = extract_jpeg_xmp(data)
|
||||||
owner_gid: int
|
if xmp_str:
|
||||||
owner_groupname: str
|
try:
|
||||||
|
tags.update(parse_xmp(xmp_str))
|
||||||
# Task 3: Allocation Metrics
|
except Exception: pass
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
owner_username = pwd.getpwuid(owner_uid).pw_name
|
tags.update(extract_jpeg_iptc(data))
|
||||||
except KeyError:
|
except Exception: pass
|
||||||
pass
|
return tags
|
||||||
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
|
|
||||||
)
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
# {{{ EXIF tags
|
# {{{ EXIF tags
|
||||||
@@ -260,7 +70,8 @@ EXIF_TAGS = {
|
|||||||
0xa403: "WhiteBalance",
|
0xa403: "WhiteBalance",
|
||||||
0xa405: "FocalLengthIn35mmFilm",
|
0xa405: "FocalLengthIn35mmFilm",
|
||||||
0xa406: "SceneCaptureType",
|
0xa406: "SceneCaptureType",
|
||||||
}# }}}
|
}
|
||||||
|
# }}}
|
||||||
|
|
||||||
# {{{ parse_tiff
|
# {{{ parse_tiff
|
||||||
def parse_tiff(data: bytes) -> Dict[str, Any]:
|
def parse_tiff(data: bytes) -> Dict[str, Any]:
|
||||||
@@ -371,7 +182,6 @@ def extract_jpeg_exif(data: bytes) -> bytes:
|
|||||||
marker = data[idx+1]
|
marker = data[idx+1]
|
||||||
if marker == 0xd9: # EOI
|
if marker == 0xd9: # EOI
|
||||||
break
|
break
|
||||||
# Markers without length parameters
|
|
||||||
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
|
if marker in (0xd8, 0xd9, 0x00) or 0xd0 <= marker <= 0xd7:
|
||||||
idx += 2
|
idx += 2
|
||||||
continue
|
continue
|
||||||
@@ -386,75 +196,6 @@ def extract_jpeg_exif(data: bytes) -> bytes:
|
|||||||
return b""
|
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
|
# {{{ extract_jpeg_comment
|
||||||
def extract_jpeg_comment(data: bytes) -> str:
|
def extract_jpeg_comment(data: bytes) -> str:
|
||||||
"""Extracts raw comment string from JPEG COM (0xfe) segments."""
|
"""Extracts raw comment string from JPEG COM (0xfe) segments."""
|
||||||
@@ -479,23 +220,7 @@ def extract_jpeg_comment(data: bytes) -> str:
|
|||||||
return ""
|
return ""
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
# {{{ get_comment
|
# {{{ NS_map
|
||||||
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://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf",
|
||||||
"http://creativecommons.org/ns#": "cc",
|
"http://creativecommons.org/ns#": "cc",
|
||||||
@@ -504,7 +229,7 @@ NS_MAP = {
|
|||||||
"http://ns.adobe.com/exif/1.0/": "exif",
|
"http://ns.adobe.com/exif/1.0/": "exif",
|
||||||
"http://ns.adobe.com/tiff/1.0/": "tiff",
|
"http://ns.adobe.com/tiff/1.0/": "tiff",
|
||||||
}
|
}
|
||||||
#}}}
|
# }}}
|
||||||
|
|
||||||
# {{{ parse_xmp
|
# {{{ parse_xmp
|
||||||
def parse_xmp(xmp_str: str) -> Dict[str, Any]:
|
def parse_xmp(xmp_str: str) -> Dict[str, Any]:
|
||||||
@@ -654,84 +379,6 @@ def get_jpeg_physical(data: bytes) -> Dict[str, Any]:
|
|||||||
return physical
|
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
|
# {{{ extract_jpeg_iptc
|
||||||
def extract_jpeg_iptc(data: bytes) -> Dict[str, str]:
|
def extract_jpeg_iptc(data: bytes) -> Dict[str, str]:
|
||||||
"""Extracts IPTC/NAA metadata (Record 2) from Photoshop APP13 segments."""
|
"""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
|
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
|
# src/main.py
|
||||||
# Parses and calls commands
|
# 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 sys
|
||||||
import re
|
from ctf.cli import cli
|
||||||
|
from ctf.helpers import FlagDetectorStream, detector_state
|
||||||
# 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)
|
|
||||||
# }}}
|
|
||||||
|
|
||||||
# {{{ main
|
# {{{ main
|
||||||
def main():
|
def main():
|
||||||
from ctf.utils import load_config
|
from ctf.config import Config
|
||||||
config = load_config("/home/venus/code/ctf/config.toml")
|
cfg = Config()
|
||||||
flag_format = config.get("Competition", {}).get("flag_format", "")
|
flag_format = cfg.data.get("Competition", {}).get("flag_format", "")
|
||||||
|
|
||||||
global _active_detector
|
|
||||||
if flag_format:
|
if flag_format:
|
||||||
detector = FlagDetectorStream(sys.stdout, flag_format)
|
detector = FlagDetectorStream(sys.stdout, flag_format)
|
||||||
sys.stdout = detector
|
sys.stdout = detector
|
||||||
_active_detector = detector
|
detector_state["active"] = detector
|
||||||
try:
|
try:
|
||||||
cli()
|
cli()
|
||||||
finally:
|
finally:
|
||||||
sys.stdout = detector.original_stream
|
sys.stdout = detector.original_stream
|
||||||
_active_detector = None
|
detector_state["active"] = None
|
||||||
else:
|
else:
|
||||||
cli()
|
cli()
|
||||||
# }}}
|
# }}}
|
||||||
|
|||||||
+1
-23
@@ -1,23 +1,8 @@
|
|||||||
# src/ctf/utils.py
|
# src/ctf/utils.py
|
||||||
# basic utilities
|
# basic utilities
|
||||||
|
|
||||||
import toml
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from platformdirs import user_config_dir
|
from ctf.config import competition, enviroment
|
||||||
# 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)
|
|
||||||
|
|
||||||
# return a list of path objects for each catagory in a competition
|
# return a list of path objects for each catagory in a competition
|
||||||
def active_categories(p: Path) -> list:
|
def active_categories(p: Path) -> list:
|
||||||
@@ -213,10 +198,3 @@ def suggest_patterns(s: str, comp_name: str = "") -> list[str]:
|
|||||||
return result
|
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 shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -6,9 +7,10 @@ from unittest.mock import patch, MagicMock
|
|||||||
import click
|
import click
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ctf.steg import crack_steghide, SteghideCrackResult
|
from ctf.steg import crack_steghide, SteghideCrackResult
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_crack_steghide_missing_binary
|
||||||
def test_crack_steghide_missing_binary():
|
def test_crack_steghide_missing_binary():
|
||||||
"""
|
"""
|
||||||
Verifies that crack_steghide returns failure if steghide is not installed.
|
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"])
|
res = crack_steghide(Path("somefile.jpg"), ["pass1", "pass2"])
|
||||||
assert not res.success
|
assert not res.success
|
||||||
assert "not found on system" in res.error_message
|
assert "not found on system" in res.error_message
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_crack_steghide_file_not_found
|
||||||
def 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.
|
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"])
|
res = crack_steghide(Path("non_existent_file_12345.jpg"), ["pass1", "pass2"])
|
||||||
assert not res.success
|
assert not res.success
|
||||||
assert "File not found" in res.error_message
|
assert "File not found" in res.error_message
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_crack_steghide_success
|
||||||
def test_crack_steghide_success():
|
def test_crack_steghide_success():
|
||||||
"""
|
"""
|
||||||
Verifies that crack_steghide returns success, the password, and payload when cracked.
|
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)
|
# We want to mock subprocess.run to write a payload to the out_file (-xf)
|
||||||
# when the correct password is tried.
|
# when the correct password is tried.
|
||||||
def mock_run(cmd, *args, **kwargs):
|
def mock_run(cmd, *args, **kwargs):
|
||||||
# cmd: ['steghide', 'extract', '-sf', '...', '-p', password, '-xf', out_file, '-f']
|
|
||||||
password = cmd[5]
|
password = cmd[5]
|
||||||
out_file = cmd[7]
|
out_file = cmd[7]
|
||||||
mock_res = MagicMock()
|
mock_res = MagicMock()
|
||||||
if password == "correct_pass":
|
if password == "correct_pass":
|
||||||
mock_res.returncode = 0
|
mock_res.returncode = 0
|
||||||
# Write a mock payload file
|
|
||||||
Path(out_file).write_bytes(b"flag{steghide_cracked_payload}")
|
Path(out_file).write_bytes(b"flag{steghide_cracked_payload}")
|
||||||
else:
|
else:
|
||||||
mock_res.returncode = 1
|
mock_res.returncode = 1
|
||||||
@@ -57,7 +61,9 @@ def test_crack_steghide_success():
|
|||||||
assert res.password == "correct_pass"
|
assert res.password == "correct_pass"
|
||||||
assert res.payload == b"flag{steghide_cracked_payload}"
|
assert res.payload == b"flag{steghide_cracked_payload}"
|
||||||
assert res.error_message is None
|
assert res.error_message is None
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_crack_steghide_failure
|
||||||
def test_crack_steghide_failure():
|
def test_crack_steghide_failure():
|
||||||
"""
|
"""
|
||||||
Verifies that crack_steghide returns failure if the wordlist is exhausted.
|
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.password is None
|
||||||
assert res.payload is None
|
assert res.payload is None
|
||||||
assert "Password not found in wordlist" in res.error_message
|
assert "Password not found in wordlist" in res.error_message
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_cli_crack_steghide_success
|
||||||
def test_cli_crack_steghide_success(tmp_path):
|
def test_cli_crack_steghide_success(tmp_path):
|
||||||
"""
|
"""
|
||||||
Verifies that the crack-steghide CLI command prints success panel and previews payload.
|
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}"
|
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)])
|
res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file)])
|
||||||
assert res.exit_code == 0
|
assert res.exit_code == 0
|
||||||
assert "Successfully cracked!" in res.output
|
assert "Successfully cracked!" in res.output
|
||||||
assert "pass2" in res.output
|
assert "pass2" in res.output
|
||||||
assert "flag{cli_steg_preview}" 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):
|
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.
|
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"
|
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)])
|
res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file), "-o", str(out_file)])
|
||||||
assert res.exit_code == 0
|
assert res.exit_code == 0
|
||||||
assert "Successfully cracked!" in res.output
|
assert "Successfully cracked!" in res.output
|
||||||
assert out_file.exists()
|
assert out_file.exists()
|
||||||
assert out_file.read_bytes() == b"written_file_content"
|
assert out_file.read_bytes() == b"written_file_content"
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_cli_crack_steghide_failure
|
||||||
def test_cli_crack_steghide_failure(tmp_path):
|
def test_cli_crack_steghide_failure(tmp_path):
|
||||||
"""
|
"""
|
||||||
Verifies that the crack-steghide CLI command displays correct error message on failure.
|
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."
|
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)])
|
res = runner.invoke(cli, ["steg", "crack-steghide", str(mock_file), "-w", str(wordlist_file)])
|
||||||
assert res.exit_code == 0
|
assert res.exit_code == 0
|
||||||
assert "Cracking failed: Password not found in wordlist" in res.output
|
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
|
# tests/forensics/test_metadata.py
|
||||||
import toml
|
# {{{ imports
|
||||||
import os
|
import os
|
||||||
import stat
|
|
||||||
import sys
|
import sys
|
||||||
from click.testing import CliRunner
|
import stat
|
||||||
from ctf.utils import load_config, active_categories
|
from pathlib import Path
|
||||||
|
import pytest
|
||||||
from ctf.forensics import get_metadata
|
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
|
# Define the persistent test environment path
|
||||||
TEST_ENV = Path("tests/env")
|
TEST_ENV = Path("tests/env")
|
||||||
|
|
||||||
# =====================================================================
|
# {{{ test_active_categories_static
|
||||||
# 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"
|
|
||||||
|
|
||||||
def test_active_categories_static():
|
def test_active_categories_static():
|
||||||
"""Verifies active_categories using the pre-created 'comp1' folder."""
|
"""Verifies active_categories using the pre-created 'comp1' folder."""
|
||||||
comp_dir = TEST_ENV / "comp1"
|
comp_dir = TEST_ENV / "comp1"
|
||||||
@@ -72,10 +25,9 @@ def test_active_categories_static():
|
|||||||
|
|
||||||
assert "web" in cat_names
|
assert "web" in cat_names
|
||||||
assert "pwn" in cat_names
|
assert "pwn" in cat_names
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_inspect_permissions
|
||||||
# --- Task-Specific Universal Metadata Tests ---
|
|
||||||
|
|
||||||
def test_inspect_permissions():
|
def test_inspect_permissions():
|
||||||
"""
|
"""
|
||||||
Task 1: POSIX Permissions.
|
Task 1: POSIX Permissions.
|
||||||
@@ -101,8 +53,9 @@ def test_inspect_permissions():
|
|||||||
meta_new = get_metadata(test_file)
|
meta_new = get_metadata(test_file)
|
||||||
assert meta_new.permissions_octal == "0o644"
|
assert meta_new.permissions_octal == "0o644"
|
||||||
assert meta_new.permissions_symbolic == "-rw-r--r--"
|
assert meta_new.permissions_symbolic == "-rw-r--r--"
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_inspect_ownership
|
||||||
def test_inspect_ownership():
|
def test_inspect_ownership():
|
||||||
"""
|
"""
|
||||||
Task 2: Ownership Identity.
|
Task 2: Ownership Identity.
|
||||||
@@ -130,8 +83,9 @@ def test_inspect_ownership():
|
|||||||
# Fallback for Windows
|
# Fallback for Windows
|
||||||
assert isinstance(meta.owner_username, str)
|
assert isinstance(meta.owner_username, str)
|
||||||
assert isinstance(meta.owner_groupname, str)
|
assert isinstance(meta.owner_groupname, str)
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_inspect_allocation
|
||||||
def test_inspect_allocation():
|
def test_inspect_allocation():
|
||||||
"""
|
"""
|
||||||
Task 3: Allocation Metrics.
|
Task 3: Allocation Metrics.
|
||||||
@@ -153,8 +107,9 @@ def test_inspect_allocation():
|
|||||||
assert meta.allocated_size == expected_blocks_size
|
assert meta.allocated_size == expected_blocks_size
|
||||||
else:
|
else:
|
||||||
assert meta.allocated_size >= 1234
|
assert meta.allocated_size >= 1234
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_inspect_hard_links
|
||||||
def test_inspect_hard_links():
|
def test_inspect_hard_links():
|
||||||
"""
|
"""
|
||||||
Task 4: Hard Link Count.
|
Task 4: Hard Link Count.
|
||||||
@@ -179,8 +134,9 @@ def test_inspect_hard_links():
|
|||||||
finally:
|
finally:
|
||||||
if link_file.exists():
|
if link_file.exists():
|
||||||
link_file.unlink()
|
link_file.unlink()
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_inspect_inode_device
|
||||||
def test_inspect_inode_device():
|
def test_inspect_inode_device():
|
||||||
"""
|
"""
|
||||||
Task 5: Inode & Device Identifiers.
|
Task 5: Inode & Device Identifiers.
|
||||||
@@ -195,8 +151,9 @@ def test_inspect_inode_device():
|
|||||||
|
|
||||||
assert meta.inode == stat_info.st_ino
|
assert meta.inode == stat_info.st_ino
|
||||||
assert meta.device == stat_info.st_dev
|
assert meta.device == stat_info.st_dev
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_inspect_extended_attributes
|
||||||
def test_inspect_extended_attributes():
|
def test_inspect_extended_attributes():
|
||||||
"""
|
"""
|
||||||
Task 6: Extended Attributes (xattr).
|
Task 6: Extended Attributes (xattr).
|
||||||
@@ -207,15 +164,12 @@ def test_inspect_extended_attributes():
|
|||||||
with open(test_file, "wb") as f:
|
with open(test_file, "wb") as f:
|
||||||
f.write(b"data")
|
f.write(b"data")
|
||||||
|
|
||||||
# Attempt to set an extended attribute (Linux/macOS specific)
|
|
||||||
has_xattr_support = False
|
has_xattr_support = False
|
||||||
if sys.platform in ("linux", "darwin"):
|
if sys.platform in ("linux", "darwin"):
|
||||||
try:
|
try:
|
||||||
import xattr
|
|
||||||
# Use user namespace for custom attribute
|
|
||||||
os.setxattr(str(test_file), "user.ctf_flag", b"FLAG{filesystem_metadata_ftw}")
|
os.setxattr(str(test_file), "user.ctf_flag", b"FLAG{filesystem_metadata_ftw}")
|
||||||
has_xattr_support = True
|
has_xattr_support = True
|
||||||
except (ImportError, OSError, AttributeError):
|
except (OSError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
meta = get_metadata(test_file)
|
meta = get_metadata(test_file)
|
||||||
@@ -224,97 +178,23 @@ def test_inspect_extended_attributes():
|
|||||||
assert "user.ctf_flag" in meta.extended_attributes
|
assert "user.ctf_flag" in meta.extended_attributes
|
||||||
assert meta.extended_attributes["user.ctf_flag"] == "FLAG{filesystem_metadata_ftw}"
|
assert meta.extended_attributes["user.ctf_flag"] == "FLAG{filesystem_metadata_ftw}"
|
||||||
else:
|
else:
|
||||||
# Should gracefully return an empty dict if not supported or none defined
|
|
||||||
assert isinstance(meta.extended_attributes, dict)
|
assert isinstance(meta.extended_attributes, dict)
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_pure_exif_parsing
|
||||||
# --- 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)
|
|
||||||
|
|
||||||
def test_pure_exif_parsing():
|
def test_pure_exif_parsing():
|
||||||
"""
|
"""
|
||||||
Verifies that get_metadata successfully parses EXIF fields from a mock PNG.
|
Verifies that get_metadata successfully parses EXIF fields from a mock PNG.
|
||||||
"""
|
"""
|
||||||
import struct
|
import struct
|
||||||
tiff_data = (
|
tiff_data = (
|
||||||
b"II\x2a\x00\x08\x00\x00\x00" # TIFF Header
|
b"II\x2a\x00\x08\x00\x00\x00"
|
||||||
b"\x02\x00" # Num entries
|
b"\x02\x00"
|
||||||
b"\x0f\x01\x02\x00\x05\x00\x00\x00\x26\x00\x00\x00" # Entry 1 (Make: offset 38)
|
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" # Entry 2 (Model: offset 43)
|
b"\x10\x01\x02\x00\x06\x00\x00\x00\x2b\x00\x00\x00"
|
||||||
b"\x00\x00\x00\x00" # Next IFD offset
|
b"\x00\x00\x00\x00"
|
||||||
b"TEST\x00" # Make value
|
b"TEST\x00"
|
||||||
b"MODEL\x00" # Model value
|
b"MODEL\x00"
|
||||||
)
|
)
|
||||||
png_header = b"\x89PNG\r\n\x1a\n"
|
png_header = b"\x89PNG\r\n\x1a\n"
|
||||||
exif_chunk_type = b"eXIf"
|
exif_chunk_type = b"eXIf"
|
||||||
@@ -330,35 +210,9 @@ def test_pure_exif_parsing():
|
|||||||
meta = get_metadata(test_file)
|
meta = get_metadata(test_file)
|
||||||
assert meta.exif_data.get("Make") == "TEST"
|
assert meta.exif_data.get("Make") == "TEST"
|
||||||
assert meta.exif_data.get("Model") == "MODEL"
|
assert meta.exif_data.get("Model") == "MODEL"
|
||||||
|
# }}}
|
||||||
|
|
||||||
def test_exif_cli_options():
|
# {{{ test_jpeg_comment_extraction
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
def test_jpeg_comment_extraction():
|
def test_jpeg_comment_extraction():
|
||||||
"""
|
"""
|
||||||
Verifies that get_metadata successfully parses a JPEG comment (COM marker).
|
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)
|
meta = get_metadata(test_file)
|
||||||
assert meta.comment == "picoCTF{test_comment_flag}"
|
assert meta.comment == "picoCTF{test_comment_flag}"
|
||||||
|
# }}}
|
||||||
|
|
||||||
def test_exif_cli_options_warning():
|
# {{{ test_adobe_xmp_extraction
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
def test_adobe_xmp_extraction():
|
def test_adobe_xmp_extraction():
|
||||||
"""
|
"""
|
||||||
Verifies that get_metadata successfully parses XMP fields (like license resource).
|
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)
|
meta = get_metadata(test_file)
|
||||||
assert meta.exif_data.get("cc:license") == "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ=="
|
assert meta.exif_data.get("cc:license") == "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ=="
|
||||||
|
# }}}
|
||||||
|
|
||||||
def test_adobe_xmp_cli():
|
# {{{ test_jpeg_physical_parsing
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
def test_jpeg_physical_parsing():
|
def test_jpeg_physical_parsing():
|
||||||
"""
|
"""
|
||||||
Verifies that get_metadata successfully parses JPEG physical parameters (JFIF & SOF).
|
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("Image Size") == "1500x1000"
|
||||||
assert meta.physical_data.get("Megapixels") == "1.5"
|
assert meta.physical_data.get("Megapixels") == "1.5"
|
||||||
assert meta.physical_data.get("Encoding Process") == "Baseline DCT, Huffman coding"
|
assert meta.physical_data.get("Encoding Process") == "Baseline DCT, Huffman coding"
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_jpeg_iptc_parsing
|
||||||
def test_jpeg_iptc_parsing():
|
def test_jpeg_iptc_parsing():
|
||||||
"""
|
"""
|
||||||
Verifies that get_metadata successfully parses JPEG IPTC metadata from APP13.
|
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)
|
meta = get_metadata(test_file)
|
||||||
assert meta.exif_data.get("CopyrightNotice") == "PicoCTF Rights"
|
assert meta.exif_data.get("CopyrightNotice") == "PicoCTF Rights"
|
||||||
|
# }}}
|
||||||
|
|
||||||
def test_cli_physical_option():
|
# {{{ test_png_text_chunks_decompression
|
||||||
"""
|
|
||||||
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
|
|
||||||
def test_png_text_chunks_decompression():
|
def test_png_text_chunks_decompression():
|
||||||
"""
|
"""
|
||||||
Verifies that get_metadata successfully parses tEXt and compressed zTXt chunks.
|
Verifies that get_metadata successfully parses tEXt and compressed zTXt chunks.
|
||||||
@@ -536,11 +329,9 @@ def test_png_text_chunks_decompression():
|
|||||||
import zlib
|
import zlib
|
||||||
import struct
|
import struct
|
||||||
|
|
||||||
# 1. tEXt chunk: Keyword (Copyright) + NUL + Text (PicoCTF)
|
|
||||||
text_data = b"Copyright\x00PicoCTF"
|
text_data = b"Copyright\x00PicoCTF"
|
||||||
text_chunk = struct.pack(">I", len(text_data)) + b"tEXt" + text_data + b"\x00\x00\x00\x00"
|
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")
|
deflated = zlib.compress(b"John Doe")
|
||||||
ztxt_data = b"Author\x00\x00" + deflated
|
ztxt_data = b"Author\x00\x00" + deflated
|
||||||
ztxt_chunk = struct.pack(">I", len(ztxt_data)) + b"zTXt" + ztxt_data + b"\x00\x00\x00\x00"
|
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)
|
meta = get_metadata(test_file)
|
||||||
assert meta.exif_data.get("Copyright") == "PicoCTF"
|
assert meta.exif_data.get("Copyright") == "PicoCTF"
|
||||||
assert meta.exif_data.get("Author") == "John Doe"
|
assert meta.exif_data.get("Author") == "John Doe"
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_gif_comment_parsing
|
||||||
def test_gif_comment_parsing():
|
def test_gif_comment_parsing():
|
||||||
"""
|
"""
|
||||||
Verifies that get_metadata successfully parses GIF comment blocks.
|
Verifies that get_metadata successfully parses GIF comment blocks.
|
||||||
@@ -571,7 +364,9 @@ def test_gif_comment_parsing():
|
|||||||
|
|
||||||
meta = get_metadata(test_file)
|
meta = get_metadata(test_file)
|
||||||
assert meta.comment == "GIFComment"
|
assert meta.comment == "GIFComment"
|
||||||
|
# }}}
|
||||||
|
|
||||||
|
# {{{ test_adobe_xmp_formatting
|
||||||
def test_adobe_xmp_formatting():
|
def test_adobe_xmp_formatting():
|
||||||
"""
|
"""
|
||||||
Verifies that get_metadata parses rdf:Description attributes and formats other
|
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:Flash") == "exif:Fired | True, exif:Mode | 1"
|
||||||
assert meta.exif_data.get("exif:EmptyAttr") == "exif:Value"
|
assert meta.exif_data.get("exif:EmptyAttr") == "exif:Value"
|
||||||
assert "rdf:Description" not in meta.exif_data
|
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
|
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 sys
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -17,141 +18,22 @@ def test_main_entry_point_help():
|
|||||||
# Verify it exits with a successful exit code (0)
|
# Verify it exits with a successful exit code (0)
|
||||||
assert exc_info.value.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 debug_exception_handler
|
||||||
from ctf.main import FlagDetectorStream
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
out = StringIO()
|
mock_tb = MagicMock()
|
||||||
stream = FlagDetectorStream(out, r"flag\{[a-z_]+\}")
|
exc_val = ValueError("test error")
|
||||||
|
with patch("traceback.print_exception") as mock_print, \
|
||||||
stream.write("Some text before flag{my_flag_here} and text after.")
|
patch("pdb.post_mortem") as mock_pm:
|
||||||
output = out.getvalue()
|
|
||||||
assert "Potential flag(s) detected in command output" in output
|
debug_exception_handler(ValueError, exc_val, mock_tb)
|
||||||
assert "flag{my_flag_here}" in output
|
|
||||||
|
|
||||||
def test_flag_detector_stream_anchored_stripping():
|
|
||||||
"""
|
|
||||||
Verifies that FlagDetectorStream strips standard anchors ^ and $ to support substring searches.
|
|
||||||
"""
|
|
||||||
from io import StringIO
|
|
||||||
from ctf.main import FlagDetectorStream
|
|
||||||
|
|
||||||
out = StringIO()
|
|
||||||
stream = FlagDetectorStream(out, r"^flag\{[a-z_]+\}$")
|
|
||||||
|
|
||||||
stream.write("random flag{my_flag_here} data")
|
|
||||||
output = out.getvalue()
|
|
||||||
assert "Potential flag(s) detected in command output" in output
|
|
||||||
assert "flag{my_flag_here}" in output
|
|
||||||
|
|
||||||
def test_flag_detector_stream_no_pattern():
|
|
||||||
"""
|
|
||||||
Verifies that FlagDetectorStream passes through text unmodified if no flag pattern is configured.
|
|
||||||
"""
|
|
||||||
from io import StringIO
|
|
||||||
from ctf.main import FlagDetectorStream
|
|
||||||
|
|
||||||
out = StringIO()
|
|
||||||
stream = FlagDetectorStream(out, "")
|
|
||||||
|
|
||||||
stream.write("normal output flag{hello}")
|
|
||||||
assert out.getvalue() == "normal output flag{hello}"
|
|
||||||
|
|
||||||
def test_flag_detector_stream_persistence():
|
|
||||||
"""
|
|
||||||
Verifies that FlagDetectorStream writes the detected flag to config.toml.
|
|
||||||
"""
|
|
||||||
from io import StringIO
|
|
||||||
from ctf.main import FlagDetectorStream
|
|
||||||
from ctf.utils import load_config, write_config
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
config_file = Path("/home/venus/code/ctf/config.toml")
|
|
||||||
original_config = load_config(str(config_file))
|
|
||||||
|
|
||||||
try:
|
|
||||||
out = StringIO()
|
|
||||||
stream = FlagDetectorStream(out, r"flag\{[a-z_]+\}")
|
|
||||||
stream.write("found flag{persisted_flag} in stdout")
|
|
||||||
|
|
||||||
# Reload config and check last_flag
|
mock_print.assert_called_once_with(ValueError, exc_val, mock_tb)
|
||||||
updated_config = load_config(str(config_file))
|
mock_pm.assert_called_once_with(mock_tb)
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user