[GEMINI] Organize test suite to mirror new ctf.cli and ctf.forensics package structures

This commit is contained in:
venus
2026-07-19 03:46:26 -05:00
parent c61bfa11e5
commit 058b5c1eb5
6 changed files with 304 additions and 281 deletions

View File

@@ -1,6 +1,9 @@
# tests/cli/test_basic.py
# {{{ imports
from click.testing import CliRunner from click.testing import CliRunner
from ctf.cli.basic import basic_group from ctf.cli.basic import basic_group
from ctf.config import Config from ctf.config import Config
# }}}
# {{{ test_basic_test_cli # {{{ test_basic_test_cli
def test_basic_test_cli(): def test_basic_test_cli():

View File

@@ -1,17 +1,19 @@
# tests/test_cli_helpers.py # tests/cli/test_flag.py
# {{{ imports
import sys import sys
import pytest import pytest
from pathlib import Path from pathlib import Path
from click.testing import CliRunner from click.testing import CliRunner
from unittest.mock import patch from unittest.mock import patch
from ctf.config import Config from ctf.config import Config
# }}}
# {{{ test_flag_cmd_cli # {{{ test_flag_cmd_cli
def test_flag_cmd_cli(): def test_flag_cmd_cli():
""" """
Verifies the ctf flag command outputs last_flag with and without --plain. Verifies the ctf flag command outputs last_flag with and without --plain.
""" """
from ctf.main import cli from ctf.cli import cli
cfg = Config() cfg = Config()
cfg.data["Competition"]["last_flag"] = "flag{test_cli_flag}" cfg.data["Competition"]["last_flag"] = "flag{test_cli_flag}"
@@ -59,7 +61,7 @@ def test_flag_cmd_list_format():
""" """
Verifies that ctf flag --list / -l prints the current flag format. Verifies that ctf flag --list / -l prints the current flag format.
""" """
from ctf.main import cli from ctf.cli import cli
cfg = Config() cfg = Config()
cfg.data["Competition"]["flag_format"] = "TEST_FORMAT{[a-z]+}" cfg.data["Competition"]["flag_format"] = "TEST_FORMAT{[a-z]+}"
@@ -83,7 +85,7 @@ def test_flag_cmd_set_format():
""" """
Verifies that ctf flag --set / -s updates the flag format in config.toml. Verifies that ctf flag --set / -s updates the flag format in config.toml.
""" """
from ctf.main import cli from ctf.cli import cli
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["flag", "-s", "NEW_FLAG_FORMAT{[0-9]+}"]) result = runner.invoke(cli, ["flag", "-s", "NEW_FLAG_FORMAT{[0-9]+}"])

204
tests/cli/test_forensics.py Normal file
View File

@@ -0,0 +1,204 @@
# tests/cli/test_forensics.py
# {{{ imports
import struct
from pathlib import Path
from click.testing import CliRunner
from ctf.cli.forensics import forensics_group
# }}}
# Define the persistent test environment path
TEST_ENV = Path("tests/env")
# {{{ test_forensics_metadata_cli_success
def test_forensics_metadata_cli_success():
"""
Verifies that 'forensics metadata' successfully runs via the CLI and prints
the metadata in a formatted table.
"""
runner = CliRunner()
test_file = TEST_ENV / "test_file.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(forensics_group, ["metadata", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file.png" in result.output
assert "Size" in result.output
assert "89504E47" in result.output
assert "Detected Type" in result.output
assert "PNG Image" in result.output
# }}}
# {{{ test_nested_metadata_cli_success
def test_nested_metadata_cli_success():
"""
Verifies that 'ctf forensics metadata' successfully runs via the root CLI.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "test_file_nested.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["forensics", "metadata", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file_nested.png" in result.output
assert "PNG Image" in result.output
# }}}
# {{{ test_forensics_metadata_cli_missing_file
def test_forensics_metadata_cli_missing_file():
"""
Verifies that the CLI fails gracefully when a non-existent file path is specified.
"""
runner = CliRunner()
result = runner.invoke(forensics_group, ["metadata", "non_existent_file.png"])
assert result.exit_code != 0
assert "does not exist" in result.output
# }}}
# {{{ test_forensics_signatures_cli
def test_forensics_signatures_cli():
"""
Verifies that 'forensics signatures' runs successfully and displays
the list of supported file signatures.
"""
runner = CliRunner()
result = runner.invoke(forensics_group, ["signatures"])
assert result.exit_code == 0
assert "Supported Magic Signatures" in result.output
assert "PNG Image" in result.output
assert "89504E47" in result.output
# }}}
# {{{ test_exif_cli_options
def test_exif_cli_options():
"""
Verifies CLI options -i/--inode and -e/--exif work as expected.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_exif_image.png"
# Verify mutual exclusion error
result_err = runner.invoke(cli, ["forensics", "metadata", "-i", "-e", str(test_file)])
assert result_err.exit_code != 0
assert "Cannot specify more than one" in result_err.output
# Verify --inode hides EXIF data table
result_inode = runner.invoke(cli, ["forensics", "metadata", "-i", str(test_file)])
assert result_inode.exit_code == 0
assert "Metadata: mock_exif_image.png" in result_inode.output
assert "EXIF Metadata" not in result_inode.output
# Verify --exif hides POSIX table but displays EXIF data table
result_exif = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)])
assert result_exif.exit_code == 0
assert "Metadata: mock_exif_image.png" not in result_exif.output
assert "EXIF Metadata" in result_exif.output
assert "mock_exif_image" in result_exif.output
assert "Make" in result_exif.output
assert "TEST" in result_exif.output
# }}}
# {{{ test_exif_cli_options_warning
def test_exif_cli_options_warning():
"""
Verifies CLI warns when no EXIF or comment is found and -e option is used.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "clean_no_exif.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)])
assert result.exit_code == 0
assert "No EXIF or comment metadata found" in result.output
# }}}
# {{{ test_exif_cli_comment_only
def test_exif_cli_comment_only():
"""
Verifies CLI displays comment when -e is used and only comment is found.
"""
from ctf.main import cli
runner = CliRunner()
comment_text = b"flag{comment_only}"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text
test_file = TEST_ENV / "comment_only.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)])
assert result.exit_code == 0
assert "Comment" in result.output
assert "flag{comment_only}" in result.output
# }}}
# {{{ test_adobe_xmp_cli
def test_adobe_xmp_cli():
"""
Verifies that the ctf inspect CLI prints the parsed XMP fields.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_xmp.jpg"
result = runner.invoke(cli, ["forensics", "metadata", "-e", str(test_file)])
assert result.exit_code == 0
assert "EXIF Metadata" in result.output
assert "cc:license" in result.output
assert "cGljb0NURnt0ZXN0X3htcF9mbGFnfQ==" in result.output
# }}}
# {{{ test_cli_physical_option
def test_cli_physical_option():
"""
Verifies the ctf forensics metadata CLI supports -p/--physical and mutual exclusion rules.
"""
from ctf.main import cli
runner = CliRunner()
test_file = TEST_ENV / "mock_physical.jpg"
# Mutual exclusion check
result_err = runner.invoke(cli, ["forensics", "metadata", "-p", "-e", str(test_file)])
assert result_err.exit_code != 0
assert "Cannot specify more than one" in result_err.output
# Physical view check
result_phys = runner.invoke(cli, ["forensics", "metadata", "-p", str(test_file)])
assert result_phys.exit_code == 0
assert "Physical Metadata" in result_phys.output
assert "Image Size" in result_phys.output
assert "1500x1000" in result_phys.output
assert "Metadata: mock_physical.jpg" not in result_phys.output
assert "EXIF Metadata" not in result_phys.output
# }}}
# {{{ test_metadata_decoded_hints_cli
def test_metadata_decoded_hints_cli():
"""
Verifies that metadata command successfully decodes and prints hex/base64 encoded metadata hints.
"""
from ctf.main import cli
runner = CliRunner()
# Create a mock JPEG with a base64 encoded comment: "ZmxhZ3tiNjRfbWV0YWRhdGF9" -> "flag{b64_metadata}"
comment_text = b"ZmxhZ3tiNjRfbWV0YWRhdGF9"
comment_len = len(comment_text) + 2
mock_jpeg = b"\xff\xd8\xff\xfe" + struct.pack(">H", comment_len) + comment_text + b"\xff\xd9"
test_file = TEST_ENV / "mock_encoded_comment.jpg"
with open(test_file, "wb") as f:
f.write(mock_jpeg)
result = runner.invoke(cli, ["forensics", "metadata", str(test_file)])
assert result.exit_code == 0
assert "Decoded Metadata Hints" in result.output
assert "Comment" in result.output
assert "base64" in result.output
assert "flag{b64_metadata}" in result.output
# }}}

View File

@@ -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.
@@ -105,7 +113,9 @@ def test_cli_crack_steghide_success(tmp_path):
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.
@@ -133,7 +143,9 @@ def test_cli_crack_steghide_success_output_file(tmp_path):
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.
@@ -156,3 +168,4 @@ def test_cli_crack_steghide_failure(tmp_path):
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
# }}}

View File

@@ -1,52 +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 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, metadata 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_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"
@@ -59,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.
@@ -88,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.
@@ -117,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.
@@ -140,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.
@@ -166,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.
@@ -182,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).
@@ -194,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)
@@ -211,81 +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_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
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
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
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"
@@ -301,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, ["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
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).
@@ -351,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, ["forensics", "metadata", "-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, ["forensics", "metadata", "-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).
@@ -417,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, ["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
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).
@@ -454,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.
@@ -478,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 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
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.
@@ -507,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"
@@ -525,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.
@@ -542,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
@@ -580,57 +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_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}"
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, ["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
# {{{ 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.
"""
from ctf.forensics import ParserFactory, JpegParser, PngParser, GifParser, FallbackParser
# 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"") == {}
# }}} # }}}

View File

@@ -0,0 +1,30 @@
# tests/forensics/test_parsers.py
# {{{ imports
from ctf.forensics import ParserFactory, JpegParser, PngParser, GifParser, FallbackParser
# }}}
# {{{ test_parser_factory_and_classes
def test_parser_factory_and_classes():
"""
Verifies that ParserFactory selects the correct parser subclass and
that individual FormatParser subclasses behave correctly.
"""
# Assert JPEG
jpeg_parser = ParserFactory.get_parser(b"\xff\xd8\xff\xe0\x00\x10JFIF")
assert isinstance(jpeg_parser, JpegParser)
# Assert PNG
png_parser = ParserFactory.get_parser(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
assert isinstance(png_parser, PngParser)
# Assert GIF
gif_parser = ParserFactory.get_parser(b"GIF89a\x01\x00\x01\x00")
assert isinstance(gif_parser, GifParser)
# Assert Fallback
fallback_parser = ParserFactory.get_parser(b"MZ\x90\x00\x03\x00\x00\x00")
assert isinstance(fallback_parser, FallbackParser)
assert fallback_parser.parse_physical(b"") == {}
assert fallback_parser.get_comment(b"") == ""
assert fallback_parser.get_exif_tags(b"") == {}
# }}}