Compare commits
20 Commits
ab1c520cc2
...
4dc0a24152
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc0a24152 | ||
|
|
8274d08f3e | ||
|
|
1c3b63d7d8 | ||
|
|
817f70e925 | ||
|
|
24ce48610d | ||
|
|
861e1b8e83 | ||
|
|
f2b349c42d | ||
|
|
9e91f8ef0f | ||
|
|
c069e51263 | ||
|
|
cf21abe0bc | ||
|
|
82ada56655 | ||
|
|
839454f044 | ||
|
|
75ce6b3a1b | ||
|
|
aebdf8f31b | ||
|
|
3664208c74 | ||
|
|
1b53658a50 | ||
|
|
70e793c4a3 | ||
|
|
58351e1086 | ||
|
|
4b16a69b1a | ||
|
|
fbda1dd079 |
106
ARCHITECTURE.md
Normal file
106
ARCHITECTURE.md
Normal 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.
|
||||
42
GEMINI.md
42
GEMINI.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
35
project_status.md
Normal 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.
|
||||
@@ -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
79
src/ctf/cli_forensics.py
Normal 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)
|
||||
# }}}
|
||||
|
||||
@@ -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
168
src/ctf/forensics.py
Normal 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
|
||||
)
|
||||
# }}}
|
||||
|
||||
139
src/ctf/main.py
139
src/ctf/main.py
@@ -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()
|
||||
|
||||
201
src/ctf/utils.py
201
src/ctf/utils.py
@@ -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
BIN
tests/artifacts/cat.jpg
Normal file
Binary file not shown.
BIN
tests/artifacts/easy-cat.jpg
Normal file
BIN
tests/artifacts/easy-cat.jpg
Normal file
Binary file not shown.
BIN
tests/artifacts/easy-cat.jpg_original
Normal file
BIN
tests/artifacts/easy-cat.jpg_original
Normal file
Binary file not shown.
BIN
tests/artifacts/hips.jpg
Normal file
BIN
tests/artifacts/hips.jpg
Normal file
Binary file not shown.
BIN
tests/artifacts/red.png
Normal file
BIN
tests/artifacts/red.png
Normal file
Binary file not shown.
1
tests/env/alloc_test.bin
vendored
Normal file
1
tests/env/alloc_test.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
1
tests/env/callback_test.txt
vendored
Normal file
1
tests/env/callback_test.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Hello World
|
||||
1
tests/env/clean_data.bin
vendored
Normal file
1
tests/env/clean_data.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
this is a completely normal text file without flags.
|
||||
5
tests/env/config.toml
vendored
Normal file
5
tests/env/config.toml
vendored
Normal 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
1
tests/env/empty_data.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
just some plain text without any flags here
|
||||
0
tests/env/empty_test.txt
vendored
Normal file
0
tests/env/empty_test.txt
vendored
Normal file
1
tests/env/flag_data.bin
vendored
Normal file
1
tests/env/flag_data.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
random_data_here_flag{found_statically_in_file}more_data
|
||||
1
tests/env/inode_test.bin
vendored
Normal file
1
tests/env/inode_test.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data
|
||||
1
tests/env/link_test.bin
vendored
Normal file
1
tests/env/link_test.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
link
|
||||
1
tests/env/mismatch_image.png
vendored
Normal file
1
tests/env/mismatch_image.png
vendored
Normal file
@@ -0,0 +1 @@
|
||||
%PDF-1.4 header info
|
||||
1
tests/env/owner_test.bin
vendored
Normal file
1
tests/env/owner_test.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data
|
||||
1
tests/env/perm_test.bin
vendored
Normal file
1
tests/env/perm_test.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data
|
||||
1
tests/env/pure_flag_data.bin
vendored
Normal file
1
tests/env/pure_flag_data.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
pre_flag{hello_world_1337}post_stuff
|
||||
2
tests/env/test_file.png
vendored
Normal file
2
tests/env/test_file.png
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
<EFBFBD>PNG
|
||||
|
||||
3
tests/env/test_image.png
vendored
Normal file
3
tests/env/test_image.png
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<EFBFBD>PNG
|
||||
|
||||
Extra bytes here
|
||||
1
tests/env/xattr_test.bin
vendored
Normal file
1
tests/env/xattr_test.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data
|
||||
152
tests/test_commands.py
Normal file
152
tests/test_commands.py
Normal 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
274
tests/test_forensics.py
Normal 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
156
tests/test_main.py
Normal 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))
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 2. Act: Load the config using our function
|
||||
loaded_data = load_config(str(config_file))
|
||||
|
||||
# 3. Assert: Verify the data matches
|
||||
assert loaded_data["Competition"]["name"] == "TestComp"
|
||||
assert loaded_data["Environment"]["data_dir"] == "/tmp/ctf"
|
||||
# =====================================================================
|
||||
# 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.
|
||||
# =====================================================================
|
||||
|
||||
def test_load_config_missing_file():
|
||||
# Act: Try to load a file that doesn't exist
|
||||
loaded_data = load_config("nonexistent_file.toml")
|
||||
|
||||
# Assert: Should return an empty dictionary
|
||||
assert loaded_data == {}
|
||||
# =====================================================================
|
||||
# 2. Mocking/Setup Requirements
|
||||
# =====================================================================
|
||||
# - No external database, network APIs, or filesystem mocks are needed
|
||||
# as `suggest_patterns` is a pure string-processing utility.
|
||||
# =====================================================================
|
||||
|
||||
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")
|
||||
|
||||
# Asserting we get a list of patterns
|
||||
assert isinstance(patterns, list)
|
||||
assert len(patterns) > 0
|
||||
|
||||
# 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
50
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user