Compare commits

..

20 Commits

Author SHA1 Message Date
venus
4dc0a24152 coming allong nicely. adding more commands, tests, and
artifacts for testing. basic framework down just adding features now
2026-07-18 02:38:13 -05:00
venus
8274d08f3e Add test case verifying exemption of flag command from stdout matching 2026-07-18 02:37:43 -05:00
venus
1c3b63d7d8 Add tests for persistent flag storage and ctf flag CLI command 2026-07-18 02:28:35 -05:00
venus
817f70e925 Update test suite for FlagDetectorStream and remove old flag-detect command 2026-07-18 02:21:38 -05:00
venus
24ce48610d Add and update test cases for pure forensics implementation 2026-07-17 23:42:17 -05:00
venus
861e1b8e83 test: update tests to support unanchored and wildcard regex options 2026-07-17 02:33:48 -05:00
venus
f2b349c42d docs/test: add tests and documentation for competition-aware patterns and commands 2026-07-17 02:18:55 -05:00
venus
9e91f8ef0f docs: update architecture and add tests for flag regex generation 2026-07-17 02:06:34 -05:00
venus
c069e51263 Standardize matching test files structure, add test_main.py and test_commands.py, and update GEMINI.md 2026-07-17 01:55:44 -05:00
venus
cf21abe0bc Update GEMINI.md with Vim folding mandate and add test cases for set-flag-format CLI command 2026-07-17 01:53:04 -05:00
venus
82ada56655 Add unit and integration tests for flag-detect CLI command 2026-07-17 01:44:06 -05:00
venus
839454f044 Add test assertions for Detected Type row in inspect CLI output 2026-07-17 01:32:08 -05:00
venus
75ce6b3a1b Implement forensics signatures command and expand supported magic signatures dictionary 2026-07-17 01:27:31 -05:00
venus
aebdf8f31b Commit workspace transition from test_utils.py to test_forensics.py with new signatures CLI test case 2026-07-17 01:27:14 -05:00
venus
3664208c74 Implement all 6 universal filesystem metadata properties inside inspect command 2026-07-17 01:20:57 -05:00
venus
1b53658a50 Update GEMINI.md, add test cases for universal metadata, and configure rich dependency 2026-07-17 01:19:25 -05:00
venus
70e793c4a3 working command structure and advanced llm Integration 2026-07-17 01:09:50 -05:00
venus
58351e1086 added architecture and switched to CLICK implementation 2026-07-13 22:32:20 -05:00
venus
4b16a69b1a qol changes 2026-07-13 21:11:27 -05:00
venus
fbda1dd079 added a gemini.md file, added some commands to main.py modified readme
and made a  forensiscs page
2026-07-13 21:10:31 -05:00
37 changed files with 1625 additions and 80 deletions

106
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,106 @@
# Project Architecture: AI-Enhanced CTF Toolchain
This document describes the architecture, directory layout, core modules, testing setup, and planned components of the CTF Toolchain project.
---
## 1. Directory Structure
The project follows a standard modern Python layout (utilizing `src/` directory layout) and is managed via the `uv` toolchain.
```text
├── config.toml # Mock/Default configuration for local testing
├── pyproject.toml # Hatchling build configuration & project dependencies
├── uv.lock # uv lockfile for exact dependency resolution
├── src/
│ └── ctf/
│ ├── __init__.py # Module initializer
│ ├── main.py # CLI Entry Point
│ ├── commands.py # CLI Commands and action functions
│ ├── utils.py # Core utility functions (file parsing, config, paths)
│ └── forensics.py # Forensics analysis tools
└── tests/
├── env/ # Sandboxed, persistent test environment directories
└── test_utils.py # Unit/Integration tests for utility functions
```
---
## 2. Core Modules & Configuration
### A. Configuration & State ([config.toml](file:///home/venus/code/ctf/config.toml))
The toolchain requires configuration of directories and active challenges:
* `Competition`: Keeps track of the current `producer`, `competition`, `category`, and `challenge`.
* `Environment`: Declares the base directory (`ctf_dir`) where all CTF files are stored.
### B. Utilities ([utils.py](file:///home/venus/code/ctf/src/ctf/utils.py))
Provides helper functions for filesystem management and configuration parsing:
* `load_config(path)`: Loads and parses configuration data from a TOML file.
* `write_config(data, path)`: Serializes/updates a dictionary to a TOML file.
* `active_categories(path)`: Iterates over a competition path to return all active categories.
* `active_competitions(dir)`: Scans the base directory for active competitions, skipping designated helper directories (like `tools`).
* `suggest_patterns(flag, comp_name)`: Analyzes a provided sample flag string and generates a prioritized, deduplicated list of regular expression pattern candidates, optionally incorporating the competition name.
### C. Commands ([commands.py](file:///home/venus/code/ctf/src/ctf/commands.py))
Houses the logic for generic CLI context and active competition commands:
* `Set_Challenge(comp, chal, setDirectory)`: Sets the current active challenge/competition context. *(Note: Currently has a `NameError` due to reference to an undefined `state` object.)*
* `set-competition (name)`: Sets the name of the active competition in configuration.
* `set-flag-format (pattern)`: Sets the flag regex format in configuration. Supports optional `PATTERN` positional argument or an interactive choice using `-o`/`--original` which takes a sample flag and prompts the user to select from suggested regex patterns, warning if the competition name is not found in the sample.
### D. Forensics ([forensics.py](file:///home/venus/code/ctf/src/ctf/forensics.py))
Implements specialized forensic inspection utilities registered as a nested subgroup under the CLI:
* `info`: Inspects target file sizes, reads magic bytes, and warns if extensions do not match detected signatures.
* `flag-search`: Extracts printable string sequences (equivalent to GNU `strings`) and matches them against regular expression patterns to find potential flags.
---
## 3. CLI Entry Point ([main.py](file:///home/venus/code/ctf/src/ctf/main.py))
* Serves as the central CLI entry point via the `main()` function.
* Initializes the root Click `cli` group and registers nested sub-groups, such as `forensics_group`.
---
## 4. Test Infrastructure
* **Framework**: `pytest` (run via `uv run pytest`).
* **Sandbox**: [tests/env](file:///home/venus/code/ctf/tests/env) acts as a persistent mock directory tree containing temporary competition directories (like `comp1`, `comp2`) and mock files (e.g. valid PNGs, mismatching PDFs, text files with flag payloads) to safely test scanning, parsing, and CLI command execution without touching actual user data.
---
## 5. Planned Architectural Components
### A. Forensics Pipeline & Extended Metadata
* **Pipeline Coordination**: A central runner that coordinates file inspections, signature verification, and string searches sequentially on target files.
* **Carving & Extraction Toolkit**: Deep inspection and carving utilities (such as archive extracting, custom chunk/payload carvers, and EXIF/metadata header decoders).
### B. Core Solving Infrastructure & QOL
* **Solving Assistants**: Standard templates and modular helper scripts for repeating CTF patterns (e.g., base conversions, cipher decoding, and request generation).
* **Context & Progress Management**: Full CLI commands to organize active challenges, automate note tracking, and manage workspace folders dynamically.
* **Per-Challenge Progress Logging**: A logging interface that creates separate progress logs for each challenge inside a configurable directory, tracking actions, milestones, attempts, and command history.
### C. Agentic Solving Orchestration
* **Action Space & Sandboxing**: API boundaries and isolated docker/sandbox execution paths to run unknown commands and binaries safely.
* **Autonomous Solving Agent**: Integration with LLM controllers that parse output from the forensics pipeline, suggest plans, iterate on solving steps, and verify flag captures.
---
## 6. CLI Data Flow & Presentation Guidelines
### A. Model-View Separation
All CLI command modules (such as [forensics.py](file:///home/venus/code/ctf/src/ctf/forensics.py)) must separate data extraction logic from command-line rendering.
* **Data Models**: Standard Python `@dataclass` objects should be defined to house parsed metadata (e.g., file size, magic bytes, detected types, warnings, and format-specific attributes).
* **Decoupled Parsers**: Extraction helper functions must return these dataclass instances instead of printing directly to standard output. This keeps the core parser functions purely functional and fully testable in unit tests.
### B. Console Rendering with `rich`
To provide a clean, modern, and easily readable console output without building a full terminal user interface (TUI):
* **Tables**: Use `rich.table.Table` to align and structure multi-column metadata outputs.
* **Formatting & Alerts**: Utilize `rich.console` or `rich.panel.Panel` to highlight warnings (such as signature/extension mismatches) with distinct styling and colors.
* **JSON Serialization**: Dataclasses should be easily convertible to dictionaries to support raw JSON output options for scripting pipelines.
### C. Function Isolation & Pipeline Composition
To support fully automated or agentic solving, all processing functions must be isolated and highly composable:
* **Pure Functions**: Core analysis and transformation functions (e.g., file carving, cipher decoders, and metadata extraction) must remain pure. They must accept standard Python types (e.g., `Path`, `bytes`, `str`) and return structured dataclasses without depending on global CLI state, click contexts, or interactive user prompts.
* **Pipeline Chaining**: The output of one tool must serve as valid input for another. For example, a list of files or byte arrays carved from a file-carver must be pipeable directly into the string parser or decompression module.
* **Programmatic API**: The entire core logic must be importable as standard Python APIs (`import ctf.utils`, `import ctf.forensics`) separate from CLI bindings. This allows external scripts, test cases, or autonomous agents to programmatically chain functions together to build complex, self-contained solving pipelines.

View File

@@ -6,12 +6,52 @@ This project serves as a centralized workspace for Capture The Flag (CTF) challe
The primary goal is to use the context of CTF challenges (forensics, crypto, web, etc.) to explore and explain Python concepts, automation, and tooling.
## Core Mandates for Gemini CLI
1. **No Unsolicited Code:** Do NOT write or modify code unless explicitly issued a **Directive** to do so.
1. **No Unsolicited Project Code Modifications:** Do NOT write or modify actual application/project implementation code files (e.g. within `src/` or core scripts) unless explicitly directed to do so. Only update test cases (e.g., in `tests/`) and markdown documentation files.
2. **Focus on Explanation:** Prioritize high-signal explanations of Python mechanics, libraries, and documentation.
3. **Summarization:** When directed, summarize documentation (local or web-based) to aid in understanding CTF tools and Python modules.
4. **Educational Context:** Use the existing scripts and tools in this repository (like `psk_crack.py`, `exploit.sh`, or the `tools/` directory) as examples when explaining technical concepts.
5. **Vim Folding Markers:** Wrap all classes, command groups, subcommands, and functions inside project implementation files in standard Vim/Neovim folding syntax markers (`# {{{ <name>` and `# }}}`).
6. **Matching Test Files:** Every Python implementation file inside `src/` must have a corresponding test file under the `tests/` directory named `test_<filename>.py`.
7. **Write Only, Do Not Run**: The agent must only write code and tests. Do not attempt to run tests or execute command-line scripts; the user will handle all execution, verification, and testing tasks.
## Interaction Workflow
When discussing new implementations, features, or additions:
1. **Discussion**: Discuss implementation details and potential approaches.
2. **The Pitch**: Provide a clear, technical pitch detailing the planned code modifications.
3. **Implementation Cycle**: Once the user approves the pitch:
* **Add Test Cases**: Write or update the corresponding test cases in the test files (e.g. `tests/test_forensics.py`) following the SDET instructions.
* **Git Commit Current State**: Create a git commit of the current workspace state (including the new tests) *before* modifying any implementation files.
* **Update the Codebase**: Write/update the actual project implementation files as approved. Do **NOT** create a git commit after writing this implementation code. This ensures all implementation changes remain unstaged so the user can easily run `git diff` to review them.
* **Hand Over**: Present the changes to the user so they can run the tests. Do not run the tests yourself.
## Python Learning Objectives
- Understanding standard library modules relevant to security (e.g., `os`, `sys`, `base64`, `hashlib`).
- Analyzing existing scripts to understand control flow, data structures, and error handling.
- Exploring how Python interacts with the shell and external tools.
## Test cases
- Verifying user written code
- When asked, Write tests cases in `test_utils.py`
- Don't run anything, just write the cases
- create a fully temporary test environment in `tests/env`
- Don't fully delete the environment between tests, just modify as needed when writing new test cases
- write tests explaining successful passes and failures
- Try to find breaking elements of user code. Find harder cases that will fail on empty inputs, etc.
- Include tests that chain multiple functions together
You are an expert Software Development Engineer in Test (SDET). Your task is to analyze the provided source code and generate a comprehensive, highly robust suite of test cases.
Do not just write "happy path" tests. You must systematically uncover potential failure points, boundary conditions, and state violations.
### Your Analysis Process
Before writing any test code, you must complete the following analysis:
1. **Identify Inputs & Outputs:** Map every input parameter, its expected type/bounds, and all possible return values or side effects.
2. **Determine State Mutability:** Identify if the code modifies internal state, database records, or external systems.
3. **Establish Boundaries:** Identify numeric limits, empty collections, null/undefined values, and string length limits.
4. **Enumerate Error Paths:** List every condition that should throw an exception, return an error code, or trigger a failure handler.
### Output Requirements
For the given code, output the test suite structured as follows:
1. **Test Strategy Summary:** A brief list of the core scenarios being tested (Happy Path, Boundary, Edge Case, Error Handling).
2. **The Test Code:** Written in the target framework/language requested by the user, adhering to industry best practices (e.g., AAA pattern, clean assertions, descriptive test names).
3. **Mocking/Setup Requirements:** Explicitly state what external dependencies (APIs, databases, system clocks) need to be mocked and how.

View File

@@ -27,3 +27,12 @@ Creates prompt for AI to generate a write up showing your process
# Misc
the end goal is to create a methodology solid enough to build out a full AI tool chain around it while allowing tight human integration
## features
- Cyberchef like decoding and magic feature
- regexing input text for flag
- flag selection and filtering with remembered prev. flags
# Tools
- forensics
- forensics tool for basic file analysis

View File

@@ -1,8 +1,10 @@
[Competition]
producer = "testProd"
competition = "testComp"
competition = "picoctf"
catagory = "textCat"
challenge = "testChal"
flag_format = "picoCTF\\{.*\\}"
last_flag = "picoCTF{flag}"
[Enviroment]
ctf_dir = "/home/venus/ctf"

35
project_status.md Normal file
View File

@@ -0,0 +1,35 @@
# Project Status & Roadmap
This file tracks the completed progress and upcoming development milestones for the AI-Enhanced CTF Toolchain.
---
## Current Status: Forensics Utilities Implemented 🚀
### Progress Made So Far
1. **Project Structuring**: Set up a modern Python `src/` layout with `uv` as the package manager and `hatchling` as the build backend.
2. **Config Management**: Implemented basic TOML configuration loading and writing functions in [utils.py](file:///home/venus/code/ctf/src/ctf/utils.py) using [config.toml](file:///home/venus/code/ctf/config.toml).
3. **Directory Scanners**: Implemented functions to list active competitions and categories automatically skipping helper folders like `tools`.
4. **CLI Entry Points & Subcommands**: Configured the Click main group in [main.py](file:///home/venus/code/ctf/src/ctf/main.py) to register nested subcommands properly.
5. **Forensics Analysis (New)**: Implemented `ctf forensics info` and `ctf forensics flag-search` in [forensics.py](file:///home/venus/code/ctf/src/ctf/forensics.py) to inspect magic bytes/signatures, verify file extension matches, and search for flag pattern regular expressions.
6. **Testing Environment**: Established a sandbox folder at [tests/env](file:///home/venus/code/ctf/tests/env) and implemented tests in [test_utils.py](file:///home/venus/code/ctf/tests/test_utils.py) validating the CLI command executions and boundary cases.
7. **Documentation**: Created the project [ARCHITECTURE.md](file:///home/venus/code/ctf/ARCHITECTURE.md) to define standard layouts, modules, and testing behavior.
---
## Upcoming Milestones & Features
### 📅 Phase 1: Functional Forensics Pipeline & Toolkit
* **Pipeline Automation**: Integrate the existing forensics command-line utilities into a cohesive analysis pipeline where files are automatically checked for magic bytes, file extensions, and flag patterns.
* **Metadata & Extraction Toolkit**: Extend forensics tools to extract specific metadata (e.g., EXIF records, archive tables) and automate extraction/carving of nested data structures (e.g., binwalk-like carving, automated unzipping, extraction of hidden payloads).
* **QOL Utilities**: Add standard format outputs (JSON, Rich logs) and automatic logging of analysis artifacts to speed up user-led inspections.
### 📅 Phase 2: Core CTF Solving & Solver QOL
* **Challenge Organization**: Implement automated download management, challenge creation, directory configuration, and context management (`set-challenge`).
* **Solving Assistants**: Build automated helper scripts for common solving needs (e.g., basic cryptography decoders, web request templates, PSK cracking utility integration).
* **Solve Tracker & Note-taking QOL**: Create a command-line interface to capture solver actions, record active notes, log flag attempts, and update challenge statuses.
* **Per-Challenge Progress Logging**: Design a challenge-specific progress logger that creates and maintains isolated log files for each active challenge in a specified workspace directory, tracking attempts, timestamps, solver notes, and command history.
### 📅 Phase 3: Agentic Solving Capabilities
* **Sandbox Environments**: Prepare secure, isolated environments to run untrusted challenge scripts or binaries.
* **Agent Orchestration**: Equip the toolchain with LLM agents capable of viewing the forensics pipeline outputs, reading challenge text, suggesting next steps, executing terminal tools, and recursively working to solve the challenge autonomously.

View File

@@ -7,6 +7,8 @@ requires-python = ">=3.14"
dependencies = [
"platformdirs>=4.9.4",
"toml>=0.10.2",
"click",
"rich",
]
[build-system]

79
src/ctf/cli_forensics.py Normal file
View File

@@ -0,0 +1,79 @@
# src/ctf/cli_forensics.py
# CLI wrapper and rendering layer for forensics commands
# vim foldmethod=marker
import click
from pathlib import Path
from ctf.forensics import get_metadata, COMMON_SIGNATURES
# {{{ forensics_group
@click.group(name="forensics")
def forensics_group():
""" A collection of forensics tools """
pass
# }}}
# {{{ tf
@forensics_group.command()
def tf():
click.echo("hello from forensics")
# }}}
# {{{ inspect
@forensics_group.command()
@click.argument('path', type=click.Path(exists=True))
def inspect(path):
"""Lists all basic inode metadata about a file"""
meta = get_metadata(Path(path))
# Present using Rich Table
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title=f"Metadata: {meta.filename}", show_header=False)
table.add_column("Key", style="bold cyan")
table.add_column("Value")
table.add_row("Filename", meta.filename)
table.add_row("Size", f"{meta.size} bytes")
table.add_row("Magic Bytes (Hex)", meta.magic)
table.add_row("Detected Type", meta.detected_type)
table.add_row("Extension", meta.extension)
table.add_row("Permissions", f"{meta.permissions_symbolic} ({meta.permissions_octal})")
table.add_row("Owner", f"{meta.owner_username} (UID: {meta.owner_uid})")
table.add_row("Group", f"{meta.owner_groupname} (GID: {meta.owner_gid})")
table.add_row("Allocated Space", f"{meta.allocated_size} bytes")
table.add_row("Hard Links", str(meta.hard_links))
table.add_row("Inode", str(meta.inode))
table.add_row("Device", str(meta.device))
if meta.extended_attributes:
xattr_str = ", ".join(f"{k}={v}" for k, v in meta.extended_attributes.items())
table.add_row("Extended Attributes", xattr_str)
console.print(table)
return meta
# }}}
# {{{ list_signatures
@forensics_group.command(name="signatures")
def list_signatures():
"""List all supported file magic signatures and expected extensions."""
from rich.console import Console
from rich.table import Table
console = Console()
table = Table(title="Supported Magic Signatures", show_header=True)
table.add_column("Magic Bytes (Hex)", style="bold cyan")
table.add_column("File Type", style="bold green")
table.add_column("Expected Exts")
for signature, (type_name, exts) in COMMON_SIGNATURES.items():
hex_str = signature.hex().upper()
exts_str = ", ".join(exts) if exts else "Any / None"
table.add_row(hex_str, type_name, exts_str)
console.print(table)
# }}}

View File

@@ -1,22 +1,92 @@
# functions for commands needed
# src/commands.py
import os
from ctf.utils import state
from pathlib import path
# vim foldmethod=marker
import click
from pathlib import Path
# {{{ basic_group
# This defines a group with name basic which will nest other comands to be imported in the main loop
@click.group(name="basic")
def basic_group(): pass
# }}}
# {{{ test
# Adds a simple commmand to be run with `ctf basic test`
@basic_group.command(name="test")
def test():
print("hello from test")
def Set_Challenge(comp: str, chal: str, setDirectory: bool):
# set the current challenge and competition from input
if state.current_comp != comp:
state.current_comp=comp
state.comp_dir=pathlib
# TODO archive the old competitions
# }}}
if state.current_chal != chal:
state.current_chal=chal
print("challenge already set")
# TODO archive the old challenges
# TODO set the directory to challenge directory, with ignore option
# {{{ greet
# A simple command with a positional(required) argument name
@basic_group.command(name="greet")
@click.argument('name')
def greet(name):
print(f"hello {name}")
# }}}
# {{{ set_flag_format
# Sets the flag format for the current competition in config.toml
@basic_group.command(name="set-flag-format")
@click.argument("pattern", required=False)
@click.option("-o", "--original", help="Suggest regex patterns from an original flag example.")
def set_flag_format(pattern: str, original: str):
"""Set the flag regex format for the active competition.
You can either specify the regex PATTERN directly, or provide an example flag
via the -o/--original option to generate and select from a list of suggested patterns.
"""
from ctf.utils import load_config, write_config, suggest_patterns
if pattern is None and original is None:
raise click.UsageError("Either PATTERN positional argument or --original/-o option must be specified.")
if pattern is not None and original is not None:
raise click.UsageError("Cannot specify both PATTERN and --original/-o option.")
config_path = "/home/venus/code/ctf/config.toml"
config = load_config(config_path)
if "Competition" not in config:
config["Competition"] = {}
comp_name = config["Competition"].get("competition", "")
if original:
if not original.strip():
raise click.UsageError("Original flag cannot be empty or whitespace only.")
if comp_name and comp_name.lower() not in original.lower():
click.echo(f"Warning: Current competition name '{comp_name}' was not found in the example flag.")
patterns = suggest_patterns(original, comp_name=comp_name)
click.echo(f"Suggested regex patterns for competition {comp_name}:")
for i, pat in enumerate(patterns, 1):
click.echo(f" {i}. {pat}")
val = click.prompt("Select a pattern index", type=click.IntRange(1, len(patterns)))
pattern = patterns[val - 1]
config["Competition"]["flag_format"] = pattern
write_config(config, config_path)
click.echo(f"Flag format set to: {pattern}")
# }}}
# {{{ set_competition
# Sets the competition name in config.toml
@basic_group.command(name="set-competition")
@click.argument("name")
def set_competition(name: str):
"""Set the name of the active competition."""
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"]["competition"] = name
write_config(config, config_path)
click.echo(f"Competition name set to: {name}")
# }}}

168
src/ctf/forensics.py Normal file
View File

@@ -0,0 +1,168 @@
# src/ctf/forensics.py
# Library for forensic analysis (pure functions only)
# vim foldmethod=marker
from dataclasses import dataclass, field
from pathlib import Path
import stat
import os
import sys
from typing import List, Dict
try:
import pwd
import grp
except ImportError:
pwd = None
grp = None
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
# Task 1: POSIX Permissions
permissions_octal: str
permissions_symbolic: str
# Task 2: Ownership Identity
owner_uid: int
owner_username: str
owner_gid: int
owner_groupname: str
# Task 3: Allocation Metrics
allocated_size: int
# Task 4: Hard Links
hard_links: int
# Task 5: Inode & Device Identifiers
inode: int
device: int
# Task 6: Extended Attributes
extended_attributes: Dict[str, str] = field(default_factory=dict)
# }}}
# {{{ 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:
owner_username = pwd.getpwuid(owner_uid).pw_name
except KeyError:
pass
if grp is not None:
try:
owner_groupname = grp.getgrgid(owner_gid).gr_name
except KeyError:
pass
# Allocation Metrics
if hasattr(stat_info, "st_blocks"):
allocated_size = stat_info.st_blocks * 512
else:
allocated_size = size
# Hard Links
hard_links = stat_info.st_nlink
# Inode & Device Identifiers
inode = stat_info.st_ino
device = stat_info.st_dev
# Extended Attributes
extended_attributes = {}
if hasattr(os, "listxattr") and hasattr(os, "getxattr"):
try:
attrs = os.listxattr(p)
for attr in attrs:
try:
val = os.getxattr(p, attr)
extended_attributes[attr] = val.decode("utf-8", errors="ignore")
except OSError:
pass
except OSError:
pass
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
)
# }}}

View File

@@ -1,48 +1,115 @@
# src/main.py
# Parses and calls commands
import argparse
from pathlib import Path
from ctf.utils import *
from ctf.commands import basic_group
from ctf.cli_forensics import forensics_group
import click
import sys
import re
def set_arguments():
parser = argparse.ArgumentParser( #type:ignore
prog="ctf",
description="A collection of cli tools to improve your ctf workflow",
epilog="")
parser.add_argument('action') # positional argument, action to be taken
parser.add_argument('-c', '--count') # option that takes a value
parser.add_argument('-v', '--verbose', action='store_true') # on/off flag
args = parser.parse_args()
return args
# Globally track the detector instance if it is active
_active_detector = None
# Class for a competition, defining active catagories, challenges completed, etc
# return a list of paths for each catagory in a competition
def active_categories(p: Path) -> list:
cats = []
if not p.exists(): return []
for cat in p.iterdir():
cats.append(cat)
return cats
#return a dictionary of competitions in the base directory
def active_competitions(dir: str) -> dict:
comps = {}
p = Path(dir)
if not p.exists(): return {}
for item in p.iterdir():
if item.name == "tools": continue
comps[item] = active_categories(item)
print(item.name)
print(comps[item.name])
return comps
# {{{ 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."""
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, nl=False)
else:
click.echo(f"Last detected flag: {last_flag}")
finally:
if _active_detector is not None:
_active_detector.detecting = True
# }}}
# {{{ main
def main():
active_comps = active_competitions(enviroment["ctf_dir"])
print(active_comps)
@click.group()
def cli(): pass
cli.add_command(forensics_group)
cli.add_command(basic_group)
cli.add_command(flag_cmd)
from ctf.utils import load_config
config = load_config("/home/venus/code/ctf/config.toml")
flag_format = config.get("Competition", {}).get("flag_format", "")
global _active_detector
if flag_format:
detector = FlagDetectorStream(sys.stdout, flag_format)
sys.stdout = detector
_active_detector = detector
try:
cli()
finally:
sys.stdout = detector.original_stream
_active_detector = None
else:
cli()
# }}}
if __name__ == "__main__":
main()

View File

@@ -1,27 +1,222 @@
# src/ctf/utils.py
# basic utilities
import tomllib
import json
import toml
from pathlib import Path
from platformdirs import user_config_dir
# Parse config file
# 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
def active_categories(p: Path) -> list:
cats = []
if not p.exists(): return []
for cat in p.iterdir():
cats.append(cat)
return cats
#return a lsit of Path objects of competitions in the base directory
def active_competitions(dir: str) -> dict:
comps = {}
p = Path(dir)
if not p.exists(): return {}
for item in p.iterdir():
if item.name == "tools": continue
comps[item] = active_categories(item)
print(item.name)
print(comps[item])
return comps
# {{{ suggest_patterns
def suggest_patterns(s: str, comp_name: str = "") -> list[str]:
"""Suggests a list of regex patterns from a sample flag string, optionally incorporating the competition name."""
if not s or not s.strip():
raise ValueError("Input string cannot be empty or whitespace only.")
def get_type(c):
if c.isupper():
return 'U'
elif c.islower():
return 'L'
elif c.isdigit():
return 'D'
else:
return 'S'
groups = []
comp_idx = -1
matched_prefix = ""
if comp_name and comp_name.strip():
comp_idx = s.lower().find(comp_name.lower())
if comp_idx != -1:
matched_prefix = s[comp_idx : comp_idx + len(comp_name)]
# Parse prefix before competition name
prefix_part = s[:comp_idx]
if prefix_part:
current_type = None
current_chars = []
for c in prefix_part:
ctype = get_type(c)
if ctype != current_type:
if current_type is not None:
groups.append((current_type, "".join(current_chars)))
current_type = ctype
current_chars = [c]
else:
current_chars.append(c)
if current_type is not None:
groups.append((current_type, "".join(current_chars)))
# Insert competition name group
groups.append(('C', matched_prefix))
# Parse suffix after competition name
suffix_part = s[comp_idx + len(comp_name):]
if suffix_part:
current_type = None
current_chars = []
for c in suffix_part:
ctype = get_type(c)
if ctype != current_type:
if current_type is not None:
groups.append((current_type, "".join(current_chars)))
current_type = ctype
current_chars = [c]
else:
current_chars.append(c)
if current_type is not None:
groups.append((current_type, "".join(current_chars)))
else:
# Normal grouping without competition name
current_type = None
current_chars = []
for c in s:
ctype = get_type(c)
if ctype != current_type:
if current_type is not None:
groups.append((current_type, "".join(current_chars)))
current_type = ctype
current_chars = [c]
else:
current_chars.append(c)
if current_type is not None:
groups.append((current_type, "".join(current_chars)))
def escape_special(chars):
res = []
for c in chars:
if c in r".+*?^$()[]{}|\/":
res.append("\\" + c)
else:
res.append(c)
return "".join(res)
def build_pat(use_counts=True, merge_case=False):
parts = []
for gtype, gchars in groups:
if gtype == 'C':
parts.append(escape_special(gchars))
elif gtype == 'U':
if merge_case:
parts.append(f"[A-Za-z]{{{len(gchars)}}}" if use_counts else "[A-Za-z]+")
else:
parts.append(f"[A-Z]{{{len(gchars)}}}" if use_counts else "[A-Z]+")
elif gtype == 'L':
if merge_case:
parts.append(f"[A-Za-z]{{{len(gchars)}}}" if use_counts else "[A-Za-z]+")
else:
parts.append(f"[a-z]{{{len(gchars)}}}" if use_counts else "[a-z]+")
elif gtype == 'D':
parts.append(f"\\d{{{len(gchars)}}}" if use_counts else "\\d+")
else:
parts.append(escape_special(gchars))
return "".join(parts)
candidates = []
# 1. Unanchored patterns (ideal for searching/scanning)
candidates.append(build_pat(use_counts=True, merge_case=False))
candidates.append(build_pat(use_counts=False, merge_case=False))
candidates.append(build_pat(use_counts=True, merge_case=True))
candidates.append(build_pat(use_counts=False, merge_case=True))
# Custom character class
unique_specials = set()
has_alpha = False
has_digit = False
for c in s:
if c.isalnum():
if c.isalpha():
has_alpha = True
if c.isdigit():
has_digit = True
else:
unique_specials.add(c)
char_class_parts = []
if has_alpha:
char_class_parts.append("A-Za-z")
if has_digit:
char_class_parts.append("0-9")
for spec in sorted(unique_specials):
if spec in r"-[]\^$":
char_class_parts.append("\\" + spec)
else:
char_class_parts.append(spec)
candidates.append(f"[{''.join(char_class_parts)}]+")
candidates.append(escape_special(s))
# Wildcard patterns with .* and .*? (unanchored)
if comp_idx == 0:
suffix_part = s[len(comp_name):]
esc_prefix = escape_special(matched_prefix)
if suffix_part.startswith("{") and suffix_part.endswith("}"):
candidates.append(f"{esc_prefix}\\{{.*\\}}")
candidates.append(f"{esc_prefix}\\{{.*?\\}}")
candidates.append(f"{esc_prefix}\\{{[^}}]*\\}}")
candidates.append(f"{esc_prefix}\\{{[A-Za-z0-9_\\-]+\\}}")
inner_content = suffix_part[1:-1]
if all(c.islower() or c.isdigit() or c == '_' for c in inner_content):
candidates.append(f"{esc_prefix}\\{{[a-z0-9_]+\\}}")
else:
candidates.append(f"{esc_prefix}.*")
candidates.append(f"{esc_prefix}.*?")
else:
candidates.append(".*")
candidates.append(".*?")
# 2. Anchored versions of the above (ideal for strict validation)
anchored_candidates = []
for cand in candidates:
anchored_candidates.append(f"^{cand}$")
candidates.extend(anchored_candidates)
# Deduplicate while preserving order
seen = set()
result = []
for c in candidates:
if c not in seen:
seen.add(c)
result.append(c)
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"]

BIN
tests/artifacts/cat.jpg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tests/artifacts/hips.jpg Normal file

Binary file not shown.

BIN
tests/artifacts/red.png Normal file

Binary file not shown.

1
tests/env/alloc_test.bin vendored Normal file
View File

@@ -0,0 +1 @@
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

1
tests/env/callback_test.txt vendored Normal file
View File

@@ -0,0 +1 @@
Hello World

1
tests/env/clean_data.bin vendored Normal file
View File

@@ -0,0 +1 @@
this is a completely normal text file without flags.

5
tests/env/config.toml vendored Normal file
View File

@@ -0,0 +1,5 @@
[Competition]
name = "PersistentComp"
[Enviroment]
data_dir = "/home/venus/code/ctf/tests/env"

1
tests/env/empty_data.bin vendored Normal file
View File

@@ -0,0 +1 @@
just some plain text without any flags here

0
tests/env/empty_test.txt vendored Normal file
View File

1
tests/env/flag_data.bin vendored Normal file
View File

@@ -0,0 +1 @@
random_data_here_flag{found_statically_in_file}more_data

1
tests/env/inode_test.bin vendored Normal file
View File

@@ -0,0 +1 @@
data

1
tests/env/link_test.bin vendored Normal file
View File

@@ -0,0 +1 @@
link

1
tests/env/mismatch_image.png vendored Normal file
View File

@@ -0,0 +1 @@
%PDF-1.4 header info

1
tests/env/owner_test.bin vendored Normal file
View File

@@ -0,0 +1 @@
data

1
tests/env/perm_test.bin vendored Normal file
View File

@@ -0,0 +1 @@
data

1
tests/env/pure_flag_data.bin vendored Normal file
View File

@@ -0,0 +1 @@
pre_flag{hello_world_1337}post_stuff

2
tests/env/test_file.png vendored Normal file
View File

@@ -0,0 +1,2 @@
<EFBFBD>PNG


3
tests/env/test_image.png vendored Normal file
View File

@@ -0,0 +1,3 @@
<EFBFBD>PNG

Extra bytes here

1
tests/env/xattr_test.bin vendored Normal file
View File

@@ -0,0 +1 @@
data

152
tests/test_commands.py Normal file
View File

@@ -0,0 +1,152 @@
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
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))

274
tests/test_forensics.py Normal file
View File

@@ -0,0 +1,274 @@
from pathlib import Path
import toml
import os
import stat
import sys
from click.testing import CliRunner
from ctf.utils import load_config, active_categories
from ctf.forensics import get_metadata
from ctf.cli_forensics import forensics_group, inspect
# Define the persistent test environment path
TEST_ENV = Path("tests/env")
# =====================================================================
# 1. Test Strategy Summary
# =====================================================================
# Core scenarios tested:
#
# A. Happy Path:
# - Verifying metadata attributes (permissions, ownership, size,
# hard links, inode, device) map exactly to filesystem ground truth.
#
# B. Boundary Conditions:
# - Testing empty files (0 bytes) for correct apparent size and allocation.
# - Testing file permissions changes (e.g. read-only, executable) and
# making sure the parsed octal/symbolic codes match.
# - Testing multiple hard links (count > 1).
#
# C. Edge Cases & OS Portability:
# - Extended attributes (xattr) support, handling platforms where xattr
# is not present or supported on the active file system.
# - Graceful system identity fallbacks for UID/GID names when passwd/group
# databases are unavailable or running on non-Unix systems.
#
# D. Error Handling:
# - Accessing non-existent paths (CLI validation errors).
# =====================================================================
# =====================================================================
# 2. Mocking/Setup Requirements
# =====================================================================
# - Filesystem sandbox: Uses `tests/env/` to dynamically create files
# with specific mode bits, content, and links.
# - No external network/API mocking is required.
# - OS-specific conditional blocks handle platforms (e.g., Windows)
# where POSIX ownership resolution or xattr is not native.
# =====================================================================
def test_load_config_static():
"""Verifies load_config using the persistent test file."""
config_file = TEST_ENV / "config.toml"
test_data = {
"Competition": {"name": "PersistentComp"},
"Enviroment": {"data_dir": str(TEST_ENV.absolute())}
}
config_file.parent.mkdir(parents=True, exist_ok=True)
with open(config_file, "w") as f:
toml.dump(test_data, f)
loaded_data = load_config(str(config_file))
assert loaded_data["Competition"]["name"] == "PersistentComp"
def test_active_categories_static():
"""Verifies active_categories using the pre-created 'comp1' folder."""
comp_dir = TEST_ENV / "comp1"
comp_dir.mkdir(parents=True, exist_ok=True)
(comp_dir / "web").mkdir(exist_ok=True)
(comp_dir / "pwn").mkdir(exist_ok=True)
cats = active_categories(comp_dir)
cat_names = [c.name for c in cats]
assert "web" in cat_names
assert "pwn" in cat_names
# --- Task-Specific Universal Metadata Tests ---
def test_inspect_permissions():
"""
Task 1: POSIX Permissions.
Verifies that the parsed octal and symbolic representation of permissions
matches Python's native os.stat mode decoding.
"""
test_file = TEST_ENV / "perm_test.bin"
with open(test_file, "wb") as f:
f.write(b"data")
# Set permissions explicitly to 0o755 (rwxr-xr-x)
test_file.chmod(0o755)
meta = get_metadata(test_file)
# Assert correct octal format
assert meta.permissions_octal == "0o755"
# Assert correct symbolic format (standard file type prefix '-')
assert meta.permissions_symbolic == "-rwxr-xr-x"
# Change permissions to 0o644 (rw-r--r--)
test_file.chmod(0o644)
meta_new = get_metadata(test_file)
assert meta_new.permissions_octal == "0o644"
assert meta_new.permissions_symbolic == "-rw-r--r--"
def test_inspect_ownership():
"""
Task 2: Ownership Identity.
Verifies that numeric UID/GID and resolved username/groupname match.
"""
test_file = TEST_ENV / "owner_test.bin"
with open(test_file, "wb") as f:
f.write(b"data")
stat_info = test_file.stat()
meta = get_metadata(test_file)
assert meta.owner_uid == stat_info.st_uid
assert meta.owner_gid == stat_info.st_gid
# On Unix, verify that user and group strings are resolved
if sys.platform != "win32":
import pwd
import grp
expected_user = pwd.getpwuid(stat_info.st_uid).pw_name
expected_group = grp.getgrgid(stat_info.st_gid).gr_name
assert meta.owner_username == expected_user
assert meta.owner_groupname == expected_group
else:
# Fallback for Windows
assert isinstance(meta.owner_username, str)
assert isinstance(meta.owner_groupname, str)
def test_inspect_allocation():
"""
Task 3: Allocation Metrics.
Verifies apparent file size matches size_bytes, and allocated_size
corresponds to st_blocks * 512 bytes.
"""
test_file = TEST_ENV / "alloc_test.bin"
with open(test_file, "wb") as f:
f.write(b"A" * 1234)
stat_info = test_file.stat()
meta = get_metadata(test_file)
assert meta.size == 1234
# st_blocks is Unix-specific. On other platforms, fallback to size on disk
if hasattr(stat_info, "st_blocks"):
expected_blocks_size = stat_info.st_blocks * 512
assert meta.allocated_size == expected_blocks_size
else:
assert meta.allocated_size >= 1234
def test_inspect_hard_links():
"""
Task 4: Hard Link Count.
Verifies that the hard link count changes when files are linked.
"""
test_file = TEST_ENV / "link_test.bin"
with open(test_file, "wb") as f:
f.write(b"link")
meta_single = get_metadata(test_file)
assert meta_single.hard_links == 1
# Create a hard link
link_file = TEST_ENV / "link_test_hard.bin"
if link_file.exists():
link_file.unlink()
try:
os.link(str(test_file), str(link_file))
meta_linked = get_metadata(test_file)
assert meta_linked.hard_links == 2
finally:
if link_file.exists():
link_file.unlink()
def test_inspect_inode_device():
"""
Task 5: Inode & Device Identifiers.
Verifies that the unique Inode number and Device ID match os.stat results.
"""
test_file = TEST_ENV / "inode_test.bin"
with open(test_file, "wb") as f:
f.write(b"data")
stat_info = test_file.stat()
meta = get_metadata(test_file)
assert meta.inode == stat_info.st_ino
assert meta.device == stat_info.st_dev
def test_inspect_extended_attributes():
"""
Task 6: Extended Attributes (xattr).
Checks that user-defined extended attributes can be retrieved if supported
by the platform and filesystem.
"""
test_file = TEST_ENV / "xattr_test.bin"
with open(test_file, "wb") as f:
f.write(b"data")
# Attempt to set an extended attribute (Linux/macOS specific)
has_xattr_support = False
if sys.platform in ("linux", "darwin"):
try:
import xattr
# Use user namespace for custom attribute
os.setxattr(str(test_file), "user.ctf_flag", b"FLAG{filesystem_metadata_ftw}")
has_xattr_support = True
except (ImportError, OSError, AttributeError):
pass
meta = get_metadata(test_file)
if has_xattr_support:
assert "user.ctf_flag" in meta.extended_attributes
assert meta.extended_attributes["user.ctf_flag"] == "FLAG{filesystem_metadata_ftw}"
else:
# Should gracefully return an empty dict if not supported or none defined
assert isinstance(meta.extended_attributes, dict)
# --- CLI Validation Tests ---
def test_forensics_inspect_cli_success():
"""
Verifies that 'forensics inspect' successfully runs via the CLI and prints
the metadata in a formatted table.
"""
runner = CliRunner()
test_file = TEST_ENV / "test_file.png"
with open(test_file, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
result = runner.invoke(forensics_group, ["inspect", str(test_file)])
assert result.exit_code == 0
assert "Metadata: test_file.png" in result.output
assert "Size" in result.output
assert "89504E47" in result.output
assert "Detected Type" in result.output
assert "PNG Image" in result.output
def test_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)

156
tests/test_main.py Normal file
View File

@@ -0,0 +1,156 @@
import sys
import pytest
from unittest.mock import patch
from ctf.main import main
def test_main_entry_point_help():
"""
Verifies that calling main() executes the Click CLI app and responds
to '--help' by listing registration groups.
"""
# Mock sys.argv to simulate running 'ctf --help' from the shell
with patch.object(sys, "argv", ["ctf", "--help"]):
# Click calls sys.exit() after displaying help, raising SystemExit
with pytest.raises(SystemExit) as exc_info:
main()
# Verify it exits with a successful exit code (0)
assert exc_info.value.code == 0
def test_flag_detector_stream_detection():
"""
Verifies that FlagDetectorStream successfully intercepts writes to output potential flags.
"""
from io import StringIO
from ctf.main 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
def test_flag_detector_stream_anchored_stripping():
"""
Verifies that FlagDetectorStream strips standard anchors ^ and $ to support substring searches.
"""
from io import StringIO
from ctf.main import FlagDetectorStream
out = StringIO()
stream = FlagDetectorStream(out, r"^flag\{[a-z_]+\}$")
stream.write("random flag{my_flag_here} data")
output = out.getvalue()
assert "Potential flag(s) detected in command output" in output
assert "flag{my_flag_here}" in output
def test_flag_detector_stream_no_pattern():
"""
Verifies that FlagDetectorStream passes through text unmodified if no flag pattern is configured.
"""
from io import StringIO
from ctf.main import FlagDetectorStream
out = StringIO()
stream = FlagDetectorStream(out, "")
stream.write("normal output flag{hello}")
assert out.getvalue() == "normal output flag{hello}"
def test_flag_detector_stream_persistence():
"""
Verifies that FlagDetectorStream writes the detected flag to config.toml.
"""
from io import StringIO
from ctf.main import FlagDetectorStream
from ctf.utils import load_config, write_config
from pathlib import Path
config_file = Path("/home/venus/code/ctf/config.toml")
original_config = load_config(str(config_file))
try:
out = StringIO()
stream = FlagDetectorStream(out, r"flag\{[a-z_]+\}")
stream.write("found flag{persisted_flag} in stdout")
# Reload config and check last_flag
updated_config = load_config(str(config_file))
assert updated_config.get("Competition", {}).get("last_flag") == "flag{persisted_flag}"
finally:
write_config(original_config, str(config_file))
def test_flag_cmd_cli():
"""
Verifies the ctf flag command outputs last_flag with and without --plain.
"""
from click.testing import CliRunner
from ctf.main import cli
from ctf.utils import load_config, write_config
from pathlib import Path
config_file = Path("/home/venus/code/ctf/config.toml")
original_config = load_config(str(config_file))
try:
# Pre-set last_flag in config
temp_config = load_config(str(config_file))
if "Competition" not in temp_config:
temp_config["Competition"] = {}
temp_config["Competition"]["last_flag"] = "flag{test_cli_flag}"
write_config(temp_config, str(config_file))
runner = CliRunner()
# Test standard flag command output
result_std = runner.invoke(cli, ["flag"])
assert result_std.exit_code == 0
assert "Last detected flag: flag{test_cli_flag}" in result_std.output
# Test plain flag command output (no flavor text, no newline)
result_plain = runner.invoke(cli, ["flag", "--plain"])
assert result_plain.exit_code == 0
assert result_plain.output == "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))

View File

@@ -1,28 +1,146 @@
from pathlib import Path
import toml
from ctf.utils import load_config
from ctf.utils import suggest_patterns
import pytest
def test_load_config_valid_file(tmp_path):
# 1. Setup: Create a temporary TOML file
config_file = tmp_path / "test_config.toml"
test_data = {
"Competition": {"name": "TestComp"},
"Environment": {"data_dir": "/tmp/ctf"}
}
with open(config_file, "w") as f:
toml.dump(test_data, f)
# =====================================================================
# 1. Test Strategy Summary
# =====================================================================
# This suite verifies the `suggest_patterns` utility function which parses
# a sample flag string and generates a prioritized, deduplicated list of
# candidate regular expression patterns.
#
# Core scenarios tested:
# A. Happy Path:
# - Verifying pattern suggestion output for typical flags like 'SKY-1111-000'
# and 'CTF{easy_flag_123}'.
# - Verifying order and counts of specific vs. general patterns.
#
# B. Boundary Conditions:
# - Handling single character inputs (e.g. 'A', '1', '{').
# - Handling strings containing only delimiter/special characters (e.g. '---').
# - Handling inputs with no numeric values or no alphabetic values.
#
# C. Edge Cases:
# - Mixing case strings (e.g., 'AbCdEf') and verifying case-insensitive
# or case-specific group behavior.
#
# D. Error Handling:
# - Empty strings or strings containing only whitespace characters should
# raise ValueError.
# =====================================================================
# 2. Act: Load the config using our function
loaded_data = load_config(str(config_file))
# =====================================================================
# 2. Mocking/Setup Requirements
# =====================================================================
# - No external database, network APIs, or filesystem mocks are needed
# as `suggest_patterns` is a pure string-processing utility.
# =====================================================================
# 3. Assert: Verify the data matches
assert loaded_data["Competition"]["name"] == "TestComp"
assert loaded_data["Environment"]["data_dir"] == "/tmp/ctf"
def test_suggest_patterns_happy_path_sky():
"""
Verifies that the suggestions for a hyphenated alpha-numeric flag like 'SKY-1111-000'
include specific character group counts, variable length matches, and the literal match.
Both unanchored and anchored options are verified.
"""
patterns = suggest_patterns("SKY-1111-000")
def test_load_config_missing_file():
# Act: Try to load a file that doesn't exist
loaded_data = load_config("nonexistent_file.toml")
# Asserting we get a list of patterns
assert isinstance(patterns, list)
assert len(patterns) > 0
# Assert: Should return an empty dictionary
assert loaded_data == {}
# Expected unanchored options:
assert "[A-Z]{3}-\\d{4}-\\d{3}" in patterns
assert "[A-Z]+-\\d+-\\d+" in patterns
assert "[A-Za-z]{3}-\\d{4}-\\d{3}" in patterns
assert "[A-Za-z0-9\\-]+" in patterns
assert "SKY-1111-000" in patterns
# Expected anchored options:
assert "^[A-Z]{3}-\\d{4}-\\d{3}$" in patterns
assert "^[A-Z]+-\\d+-\\d+$" in patterns
assert "^SKY-1111-000$" in patterns
def test_suggest_patterns_happy_path_ctf():
"""
Verifies suggestions for flags with braces and underscores like 'CTF{easy_flag_123}'.
Note that special regex characters like '{' and '}' are correctly escaped.
"""
patterns = suggest_patterns("CTF{easy_flag_123}")
# Unanchored options:
assert "[A-Z]{3}\\{[a-z]{4}_[a-z]{4}_\\d{3}\\}" in patterns
assert "[A-Z]+\\{[a-z]+_[a-z]+_\\d+\\}" in patterns
assert "[A-Za-z0-9_\\{\\}]+" in patterns
assert "CTF\\{easy_flag_123\\}" in patterns
# Anchored options:
assert "^[A-Z]{3}\\{[a-z]{4}_[a-z]{4}_\\d{3}\\}$" in patterns
assert "^[A-Z]+\\{[a-z]+_[a-z]+_\\d+\\}$" in patterns
assert "^CTF\\{easy_flag_123\\}$" in patterns
def test_suggest_patterns_only_specials():
"""
Verifies that a string with only special characters works and generates valid patterns.
"""
patterns = suggest_patterns("---")
# All patterns should deduplicate to just the exact literal match or similar
assert "^---$" in patterns
def test_suggest_patterns_single_char():
"""
Verifies pattern suggestion on single character flags.
"""
assert "^[A-Z]{1}$" in suggest_patterns("A")
assert "^\\d{1}$" in suggest_patterns("5")
def test_suggest_patterns_empty_and_whitespace():
"""
Verifies that empty string or whitespace-only inputs trigger a ValueError.
"""
with pytest.raises(ValueError, match="Input string cannot be empty or whitespace only."):
suggest_patterns("")
with pytest.raises(ValueError, match="Input string cannot be empty or whitespace only."):
suggest_patterns(" ")
def test_suggest_patterns_with_competition_name_braced():
"""
Verifies that when the competition name is matched in a braced flag,
additional general wildcard patterns like 'CTF\\{.*\\}' and '^CTF\\{.*\\}$' are generated.
"""
patterns = suggest_patterns("CTF{easy_flag_123}", comp_name="CTF")
# Check that competition prefix is kept literal in specific/variable patterns (both unanchored & anchored)
assert "CTF\\{[a-z]{4}_[a-z]{4}_\\d{3}\\}" in patterns
assert "^CTF\\{[a-z]{4}_[a-z]{4}_\\d{3}\\}$" in patterns
# Check that braced wildcard options are suggested (both unanchored & anchored)
assert "CTF\\{.*\\}" in patterns
assert "CTF\\{.*?\\}" in patterns
assert "CTF\\{[^}]*\\}" in patterns
assert "^CTF\\{.*\\}$" in patterns
assert "^CTF\\{[A-Za-z0-9_\\-]+\\}$" in patterns
assert "^CTF\\{[a-z0-9_]+\\}$" in patterns
def test_suggest_patterns_with_competition_name_non_braced():
"""
Verifies that when the competition name is matched in a non-braced flag,
suffix wildcards like 'SKY-.*' and '^SKY-.*$' are suggested.
"""
patterns = suggest_patterns("SKY-1111-000", comp_name="SKY")
assert "SKY-\\d{4}-\\d{3}" in patterns
assert "^SKY-\\d{4}-\\d{3}$" in patterns
assert "SKY-.*" in patterns
assert "^SKY-.*$" in patterns
def test_suggest_patterns_with_competition_name_mismatch():
"""
Verifies that if the competition name is not in the example flag,
it falls back to standard regex suggestion matching.
"""
patterns_mismatch = suggest_patterns("CTF{easy_flag_123}", comp_name="SKY")
patterns_none = suggest_patterns("CTF{easy_flag_123}")
assert patterns_mismatch == patterns_none

50
uv.lock generated
View File

@@ -2,6 +2,18 @@ version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -16,7 +28,9 @@ name = "ctf"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "platformdirs" },
{ name = "rich" },
{ name = "toml" },
]
@@ -27,7 +41,9 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "click" },
{ name = "platformdirs", specifier = ">=4.9.4" },
{ name = "rich" },
{ name = "toml", specifier = ">=0.10.2" },
]
@@ -43,6 +59,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.1"
@@ -95,6 +132,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "toml"
version = "0.10.2"