diff --git a/pyproject.toml b/pyproject.toml index 4c6bc8f..4461251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ ignore = [ "PLR1730", # Replace `if` statement with `min()` "PLR2004", # Magic value used in comparison "PLW0602", # Using global for variable but no assignment is done + "PLW0603", # Using the global statement to update a variable is discouraged "PLW1508", # Invalid type for environment variable default "PLW1510", # `subprocess.run` without explicit `check` argument "RET504", # Unnecessary assignment before `return` statement @@ -113,3 +114,9 @@ ignore = [ "W291", # Trailing whitespace "W293", # Blank line contains whitespace ] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "S101", # Use of assert (standard in pytest) + "SLF001", # Private member accessed (tests legitimately access module internals) +] diff --git a/scripts/build_container_images.sh b/scripts/build_container_images.sh new file mode 100755 index 0000000..d32555d --- /dev/null +++ b/scripts/build_container_images.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +# Build seclab container shell images. +# Must be run from the root of the seclab-taskflows repository. +# Images must be rebuilt whenever a Dockerfile changes. +# +# Usage: ./scripts/build_container_images.sh [base|malware|network|sast|all] +# default: all + +set -euo pipefail + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "${__dir}/.." && pwd)" +CONTAINERS_DIR="${__root}/src/seclab_taskflows/containers" + +build_base() { + echo "Building seclab-shell-base..." + docker build -t seclab-shell-base:latest "${CONTAINERS_DIR}/base/" +} + +build_malware() { + echo "Building seclab-shell-malware-analysis..." + docker build -t seclab-shell-malware-analysis:latest "${CONTAINERS_DIR}/malware_analysis/" +} + +build_network() { + echo "Building seclab-shell-network-analysis..." + docker build -t seclab-shell-network-analysis:latest "${CONTAINERS_DIR}/network_analysis/" +} + +build_sast() { + echo "Building seclab-shell-sast..." + docker build -t seclab-shell-sast:latest "${CONTAINERS_DIR}/sast/" +} + +target="${1:-all}" + +case "$target" in + base) + build_base + ;; + malware) + build_base + build_malware + ;; + network) + build_base + build_network + ;; + sast) + build_base + build_sast + ;; + all) + build_base + build_malware + build_network + build_sast + ;; + *) + echo "Unknown target: $target" >&2 + echo "Usage: $0 [base|malware|network|sast|all]" >&2 + exit 1 + ;; +esac + +echo "Done." diff --git a/scripts/run_container_shell_demo.sh b/scripts/run_container_shell_demo.sh new file mode 100755 index 0000000..a6c43a7 --- /dev/null +++ b/scripts/run_container_shell_demo.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +# Run container shell demo taskflows. +# Must be run from the root of the seclab-taskflows repository. +# +# Usage: +# ./scripts/run_container_shell_demo.sh base [workspace_dir] +# ./scripts/run_container_shell_demo.sh malware [workspace_dir] [target_filename] +# ./scripts/run_container_shell_demo.sh network [workspace_dir] [capture_filename] +# ./scripts/run_container_shell_demo.sh sast [workspace_dir] [target] +# +# If workspace_dir is omitted a temporary directory is used. +# Requires AI_API_TOKEN to be set in the environment. + +set -euo pipefail + +__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +__root="$(cd "${__dir}/.." && pwd)" + +export PATH="${__root}/.venv/bin:${PATH}" + +if [ -z "${AI_API_TOKEN:-}" ]; then + echo "AI_API_TOKEN is not set" >&2 + exit 1 +fi + +demo="${1:-}" +if [ -z "$demo" ]; then + echo "Usage: $0 [workspace_dir] [target]" >&2 + exit 1 +fi + +workspace="${2:-$(mktemp -d)}" +mkdir -p "$workspace" + +case "$demo" in + base) + target="${3:-hello}" + if [ ! -f "${workspace}/${target}" ]; then + echo "Copying /bin/ls to ${workspace}/${target} as demo target" + cp /bin/ls "${workspace}/${target}" + fi + CONTAINER_WORKSPACE="$workspace" \ + LOG_DIR="${__root}/logs" \ + python -m seclab_taskflow_agent \ + -t seclab_taskflows.taskflows.container_shell.demo_base \ + -g target="$target" + ;; + malware) + target="${3:-suspicious.elf}" + if [ ! -f "${workspace}/${target}" ]; then + echo "Copying /bin/ls to ${workspace}/${target} as demo target" + cp /bin/ls "${workspace}/${target}" + fi + CONTAINER_WORKSPACE="$workspace" \ + LOG_DIR="${__root}/logs" \ + python -m seclab_taskflow_agent \ + -t seclab_taskflows.taskflows.container_shell.demo_malware_analysis \ + -g target="$target" + ;; + network) + capture="${3:-sample.pcap}" + if [ ! -f "${workspace}/${capture}" ]; then + echo "No pcap found at ${workspace}/${capture}" >&2 + echo "Provide a pcap file or set workspace_dir to a directory containing one." >&2 + exit 1 + fi + CONTAINER_WORKSPACE="$workspace" \ + LOG_DIR="${__root}/logs" \ + python -m seclab_taskflow_agent \ + -t seclab_taskflows.taskflows.container_shell.demo_network_analysis \ + -g capture="$capture" + ;; + sast) + target="${3:-.}" + target_path="${workspace}/${target}" + if [ "$target" != "." ] && [ ! -d "$target_path" ] && [ ! -f "$target_path" ]; then + echo "No source found at ${target_path}" >&2 + echo "Provide a source directory or file in workspace_dir." >&2 + exit 1 + fi + if [ "$target" = "." ] && [ -z "$(ls -A "$workspace" 2>/dev/null)" ]; then + echo "Generating demo Python source in ${workspace}" + cat > "${workspace}/demo.py" <<'PYEOF' +import os +import subprocess + + +def read_config(path): + with open(path) as f: + return f.read() + + +def run_command(cmd): + # intentional anti-pattern for demo purposes + return subprocess.run(cmd, shell=True, capture_output=True, text=True) + + +def process_input(user_input): + result = run_command(f"echo {user_input}") + return result.stdout + + +def main(): + config = read_config("/etc/demo.conf") if os.path.exists("/etc/demo.conf") else "" + output = process_input("hello world") + print(config, output) + + +if __name__ == "__main__": + main() +PYEOF + target="demo.py" + fi + CONTAINER_WORKSPACE="$workspace" \ + LOG_DIR="${__root}/logs" \ + python -m seclab_taskflow_agent \ + -t seclab_taskflows.taskflows.container_shell.demo_sast \ + -g target="$target" + ;; + *) + echo "Unknown demo: $demo. Choose base, malware, network, or sast." >&2 + exit 1 + ;; +esac diff --git a/src/seclab_taskflows/containers/base/Dockerfile b/src/seclab_taskflows/containers/base/Dockerfile new file mode 100644 index 0000000..9a99e3e --- /dev/null +++ b/src/seclab_taskflows/containers/base/Dockerfile @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash coreutils python3 python3-pip curl wget git ca-certificates \ + file binutils xxd \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /workspace diff --git a/src/seclab_taskflows/containers/malware_analysis/Dockerfile b/src/seclab_taskflows/containers/malware_analysis/Dockerfile new file mode 100644 index 0000000..77afd3d --- /dev/null +++ b/src/seclab_taskflows/containers/malware_analysis/Dockerfile @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +FROM seclab-shell-base:latest +RUN apt-get update && apt-get install -y --no-install-recommends \ + binwalk yara libimage-exiftool-perl checksec \ + && rm -rf /var/lib/apt/lists/* +# radare2 is not in Debian bookworm apt; install prebuilt deb from GitHub releases +RUN ARCH=$(dpkg --print-architecture) \ + && R2_TAG=$(curl -fsSL "https://api.github.com/repos/radareorg/radare2/releases/latest" \ + | grep -o '"tag_name": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"') \ + && R2_VER="${R2_TAG#v}" \ + && curl -fsSL "https://github.com/radareorg/radare2/releases/download/${R2_TAG}/radare2_${R2_VER}_${ARCH}.deb" \ + -o /tmp/r2.deb \ + && apt-get install -y /tmp/r2.deb \ + && rm /tmp/r2.deb +RUN pip3 install --no-cache-dir --break-system-packages pwntools capstone volatility3 diff --git a/src/seclab_taskflows/containers/network_analysis/Dockerfile b/src/seclab_taskflows/containers/network_analysis/Dockerfile new file mode 100644 index 0000000..8c51b8e --- /dev/null +++ b/src/seclab_taskflows/containers/network_analysis/Dockerfile @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +FROM seclab-shell-base:latest +RUN apt-get update && apt-get install -y --no-install-recommends \ + nmap tcpdump tshark netcat-openbsd dnsutils curl jq httpie \ + && rm -rf /var/lib/apt/lists/* diff --git a/src/seclab_taskflows/containers/sast/Dockerfile b/src/seclab_taskflows/containers/sast/Dockerfile new file mode 100644 index 0000000..32be95a --- /dev/null +++ b/src/seclab_taskflows/containers/sast/Dockerfile @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +FROM seclab-shell-base:latest +RUN apt-get update && apt-get install -y --no-install-recommends \ + universal-ctags global cscope ripgrep fd-find graphviz tree \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && rm -rf /var/lib/apt/lists/* +RUN pip3 install --no-cache-dir --break-system-packages semgrep pyan3 diff --git a/src/seclab_taskflows/mcp_servers/container_shell.py b/src/seclab_taskflows/mcp_servers/container_shell.py new file mode 100644 index 0000000..409f310 --- /dev/null +++ b/src/seclab_taskflows/mcp_servers/container_shell.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +import atexit +import logging +import os +import subprocess +import uuid +from typing import Annotated + +from fastmcp import FastMCP +from pydantic import Field +from seclab_taskflow_agent.path_utils import log_file_name + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(message)s", + filename=log_file_name("container_shell.log"), + filemode="a", +) + +mcp = FastMCP("ContainerShell") + +_container_name: str | None = None + +CONTAINER_IMAGE = os.environ.get("CONTAINER_IMAGE", "") +CONTAINER_WORKSPACE = os.environ.get("CONTAINER_WORKSPACE", "") +CONTAINER_TIMEOUT = int(os.environ.get("CONTAINER_TIMEOUT", "30")) + +_DEFAULT_WORKDIR = "/workspace" + + +def _start_container() -> str: + """Start the Docker container and return its name.""" + if not CONTAINER_IMAGE: + msg = "CONTAINER_IMAGE is not set — cannot start container" + raise RuntimeError(msg) + if CONTAINER_WORKSPACE and ":" in CONTAINER_WORKSPACE: + msg = f"CONTAINER_WORKSPACE must not contain a colon: {CONTAINER_WORKSPACE!r}" + raise RuntimeError(msg) + name = f"seclab-shell-{uuid.uuid4().hex[:8]}" + cmd = ["docker", "run", "-d", "--rm", "--name", name] + if CONTAINER_WORKSPACE: + cmd += ["-v", f"{CONTAINER_WORKSPACE}:/workspace"] + cmd += [CONTAINER_IMAGE, "tail", "-f", "/dev/null"] + logging.debug(f"Starting container: {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + msg = f"docker run failed: {result.stderr.strip()}" + raise RuntimeError(msg) + logging.debug(f"Container started: {name}") + return name + + +def _stop_container() -> None: + """Stop the running container.""" + global _container_name + if _container_name is None: + return + logging.debug(f"Stopping container: {_container_name}") + result = subprocess.run( + ["docker", "stop", "--time", "5", _container_name], + capture_output=True, + text=True, + ) + if result.returncode != 0: + logging.warning( + "docker stop failed for container %s: %s", + _container_name, + result.stderr.strip(), + ) + _container_name = None + + +atexit.register(_stop_container) + + +@mcp.tool() +def shell_exec( + command: Annotated[str, Field(description="Shell command to execute inside the container")], + timeout: Annotated[int, Field(description="Timeout in seconds")] = CONTAINER_TIMEOUT, + workdir: Annotated[str, Field(description="Working directory inside the container")] = _DEFAULT_WORKDIR, +) -> str: + """Execute a shell command inside the managed Docker container.""" + global _container_name + if _container_name is None: + try: + _container_name = _start_container() + except RuntimeError as e: + return f"Failed to start container: {e}" + + cmd = ["docker", "exec", "-w", workdir, _container_name, "bash", "-c", command] + logging.debug(f"Executing: {' '.join(cmd)}") + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return f"[exit code: timeout after {timeout}s]" + + output = result.stdout + if result.stderr: + output += result.stderr + output += f"[exit code: {result.returncode}]" + return output + + +if __name__ == "__main__": + mcp.run(show_banner=False) diff --git a/src/seclab_taskflows/taskflows/container_shell/README.md b/src/seclab_taskflows/taskflows/container_shell/README.md new file mode 100644 index 0000000..0257538 --- /dev/null +++ b/src/seclab_taskflows/taskflows/container_shell/README.md @@ -0,0 +1,133 @@ +# Container Shell Taskflows + +Runs arbitrary CLI commands inside an isolated Docker container. One container +per MCP server process — started on the first `shell_exec` call, stopped on +exit. An optional host directory is mounted at `/workspace` inside the container. + +Four container profiles are provided. Each has its own Dockerfile, toolbox +YAML, and demo taskflow. + +## Profiles + +**base** (`seclab-shell-base:latest`) +General-purpose. Includes bash, coreutils, python3, file, binutils, xxd, +curl, wget, git. + +**malware-analysis** (`seclab-shell-malware-analysis:latest`) +Static binary and firmware analysis. Extends base with radare2, binwalk, +yara, exiftool, checksec, capstone, pwntools, volatility3. + +**network-analysis** (`seclab-shell-network-analysis:latest`) +Packet capture analysis and network recon. Extends base with nmap, tcpdump, +tshark, netcat, dig, jq, httpie. + +**sast** (`seclab-shell-sast:latest`) +Static analysis and code exploration. Extends base with semgrep, pyan3, +universal-ctags, GNU global, cscope, graphviz, ripgrep, fd, tree. + +## Building the images + +Run from the repository root: + +``` +./scripts/build_container_images.sh +``` + +To build a single profile (the base image is always built first when needed): + +``` +./scripts/build_container_images.sh base +./scripts/build_container_images.sh malware +./scripts/build_container_images.sh network +./scripts/build_container_images.sh sast +``` + +Images only need to be rebuilt when a Dockerfile changes. + +## Environment variables + +`CONTAINER_WORKSPACE` — host path to mount at `/workspace`. Optional; omit if +you do not need to pass files into the container. + +`CONTAINER_TIMEOUT` — default command timeout in seconds. Defaults to 30 (base +and network) or 60 (malware analysis and sast). + +`LOG_DIR` — where to write `container_shell.log`. + +## Running the demos + +Create a workspace directory with a target file, then run the agent: + +**Base demo** — inspects any ELF binary using standard binutils: + +``` +cp /bin/ls /tmp/demo/hello +CONTAINER_WORKSPACE=/tmp/demo python -m seclab_taskflow_agent \ + -t seclab_taskflows.taskflows.container_shell.demo_base +``` + +**Malware analysis demo** — static triage of a suspicious ELF (not executed): + +``` +cp /bin/ls /tmp/samples/suspicious.elf +CONTAINER_WORKSPACE=/tmp/samples python -m seclab_taskflow_agent \ + -t seclab_taskflows.taskflows.container_shell.demo_malware_analysis +``` + +Override the target filename with `-g target=`. + +**Network analysis demo** — analyses a pcap file: + +``` +CONTAINER_WORKSPACE=/tmp/captures python -m seclab_taskflow_agent \ + -t seclab_taskflows.taskflows.container_shell.demo_network_analysis \ + -g capture=sample.pcap +``` + +**SAST demo** — static analysis and call graph extraction for a source repo: + +``` +CONTAINER_WORKSPACE=/path/to/src python -m seclab_taskflow_agent \ + -t seclab_taskflows.taskflows.container_shell.demo_sast +``` + +If no source is present the runner script generates a demo Python file with a +shell-injection anti-pattern so semgrep has something to find: + +``` +./scripts/run_container_shell_demo.sh sast +``` + +Override the analysis target with `-g target=` (relative to /workspace). + +## Using container_shell in your own taskflows + +Reference the appropriate toolbox and set `CONTAINER_WORKSPACE` in `env`: + +```yaml +taskflow: + - task: + agents: + - seclab_taskflow_agent.personalities.assistant + toolboxes: + - seclab_taskflows.toolboxes.container_shell_malware_analysis + env: + CONTAINER_WORKSPACE: "{{ env('SAMPLE_DIR') }}" + user_prompt: | + Analyse the binary at /workspace/target.elf using static analysis only. +``` + +`shell_exec` requires user confirmation by default (`confirm: [shell_exec]` in +all toolbox YAMLs). Pass `headless: true` at the task level to skip +confirmation in automated pipelines. + +## Notes + +- The container is shared across all `shell_exec` calls within a single + taskflow run. State (files written, processes started) persists between calls. +- `--rm` is set on `docker run`, so the container is removed automatically when + stopped. +- The container name follows the pattern `seclab-shell-<8 hex chars>` and is + visible in `docker ps`. +- If `docker run` fails (e.g. image not found), `shell_exec` returns an error + string rather than raising, so the agent can report the problem cleanly. diff --git a/src/seclab_taskflows/taskflows/container_shell/demo_base.yaml b/src/seclab_taskflows/taskflows/container_shell/demo_base.yaml new file mode 100644 index 0000000..85e9416 --- /dev/null +++ b/src/seclab_taskflows/taskflows/container_shell/demo_base.yaml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +# Demo: base container shell. +# Inspects a target binary using tools available in seclab-shell-base. +# Set CONTAINER_WORKSPACE to a directory containing the binary before running. +# Example: +# CONTAINER_WORKSPACE=/tmp/demo python -m seclab_taskflow_agent \ +# -t seclab_taskflows.taskflows.container_shell.demo_base + +seclab-taskflow-agent: + filetype: taskflow + version: "1.0" + +globals: + target: hello + +taskflow: + - task: + must_complete: true + headless: true + agents: + - seclab_taskflow_agent.personalities.assistant + toolboxes: + - seclab_taskflows.toolboxes.container_shell_base + env: + CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE') }}" + user_prompt: | + A binary named {{ globals.target }} is available at /workspace/{{ globals.target }}. + + Run each of the following steps in order and report the findings: + + 1. Confirm the file exists: `ls -lh /workspace/{{ globals.target }}` + 2. Identify the file type: `file /workspace/{{ globals.target }}` + 3. Extract printable strings (minimum length 8): `strings -n 8 /workspace/{{ globals.target }}` + 4. Show the ELF section headers: `readelf -S /workspace/{{ globals.target }}` + 5. List exported symbols: `nm -D /workspace/{{ globals.target }} 2>/dev/null || nm /workspace/{{ globals.target }}` + 6. Hex dump the first 64 bytes: `xxd /workspace/{{ globals.target }} | head -4` + + Summarise what you learned about the binary from these steps. diff --git a/src/seclab_taskflows/taskflows/container_shell/demo_malware_analysis.yaml b/src/seclab_taskflows/taskflows/container_shell/demo_malware_analysis.yaml new file mode 100644 index 0000000..79e53f6 --- /dev/null +++ b/src/seclab_taskflows/taskflows/container_shell/demo_malware_analysis.yaml @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +# Demo: malware analysis container shell. +# Performs static analysis on a suspicious ELF binary placed in CONTAINER_WORKSPACE. +# The binary is never executed — all analysis is static. +# Example: +# CONTAINER_WORKSPACE=/tmp/samples python -m seclab_taskflow_agent \ +# -t seclab_taskflows.taskflows.container_shell.demo_malware_analysis \ +# -g target=suspicious.elf + +seclab-taskflow-agent: + filetype: taskflow + version: "1.0" + +globals: + target: suspicious.elf + +taskflow: + - task: + must_complete: true + headless: true + agents: + - seclab_taskflow_agent.personalities.assistant + toolboxes: + - seclab_taskflows.toolboxes.container_shell_malware_analysis + env: + CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE') }}" + user_prompt: | + Perform static analysis on /workspace/{{ globals.target }}. + Do NOT execute the binary at any point. + + Work through each step below and record findings: + + 1. File type and basic info: + `file /workspace/{{ globals.target }}` + + 2. Binary metadata (architecture, bits, security mitigations): + `rabin2 -I /workspace/{{ globals.target }}` + + 3. Imported library functions: + `rabin2 -i /workspace/{{ globals.target }}` + + 4. Exported symbols: + `rabin2 -E /workspace/{{ globals.target }}` + + 5. Interesting strings (minimum length 8): + `rabin2 -z /workspace/{{ globals.target }}` + + 6. Section layout: + `rabin2 -S /workspace/{{ globals.target }}` + + 7. Security mitigations cross-check: + `checksec --file=/workspace/{{ globals.target }}` + + 8. Entropy per section (high entropy may indicate packing/encryption): + `r2 -q -c "iS~entropy" /workspace/{{ globals.target }} 2>/dev/null || echo "entropy unavailable"` + + Based on the above, assess: + - What does the binary appear to do? + - Are there indicators of packing, obfuscation, or anti-analysis techniques? + - What security mitigations are present or absent? + - What imported functions are of interest from a security perspective? + + Write a concise triage report. diff --git a/src/seclab_taskflows/taskflows/container_shell/demo_network_analysis.yaml b/src/seclab_taskflows/taskflows/container_shell/demo_network_analysis.yaml new file mode 100644 index 0000000..3241468 --- /dev/null +++ b/src/seclab_taskflows/taskflows/container_shell/demo_network_analysis.yaml @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +# Demo: network analysis container shell. +# Analyses a pcap file placed in CONTAINER_WORKSPACE. +# Example: +# CONTAINER_WORKSPACE=/tmp/captures python -m seclab_taskflow_agent \ +# -t seclab_taskflows.taskflows.container_shell.demo_network_analysis \ +# -g capture=sample.pcap + +seclab-taskflow-agent: + filetype: taskflow + version: "1.0" + +globals: + capture: sample.pcap + +taskflow: + - task: + must_complete: true + headless: true + agents: + - seclab_taskflow_agent.personalities.assistant + toolboxes: + - seclab_taskflows.toolboxes.container_shell_network_analysis + env: + CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE') }}" + user_prompt: | + Analyse the packet capture at /workspace/{{ globals.capture }}. + + Work through each step below and record findings: + + 1. Confirm the file and get a summary: + `tshark -r /workspace/{{ globals.capture }} -q -z io,phs` + + 2. List all unique conversations (IP pairs): + `tshark -r /workspace/{{ globals.capture }} -q -z conv,ip` + + 3. List all DNS queries: + `tshark -r /workspace/{{ globals.capture }} -Y dns -T fields -e dns.qry.name | sort -u` + + 4. List all HTTP requests (method, host, URI): + `tshark -r /workspace/{{ globals.capture }} -Y http.request -T fields \ + -e http.request.method -e http.host -e http.request.uri | sort -u` + + 5. Show any cleartext credentials (basic auth, form data): + `tshark -r /workspace/{{ globals.capture }} -Y "http contains \"password\" or http contains \"passwd\"" \ + -T fields -e http.file_data 2>/dev/null | head -20` + + 6. Extract TLS SNI values (reveals contacted hostnames even in encrypted traffic): + `tshark -r /workspace/{{ globals.capture }} -Y tls.handshake.extensions_server_name \ + -T fields -e tls.handshake.extensions_server_name | sort -u` + + 7. Check for large outbound data transfers (potential exfiltration): + `tshark -r /workspace/{{ globals.capture }} -q -z conv,tcp | sort -k 3 -n -r | head -20` + + Based on the above, report: + - What hosts and services were contacted? + - Were any credentials or sensitive data visible in cleartext? + - Are there signs of data exfiltration, C2 beaconing, or other suspicious patterns? + - Any notable DNS or TLS observations? + + Write a concise analysis report. diff --git a/src/seclab_taskflows/taskflows/container_shell/demo_sast.yaml b/src/seclab_taskflows/taskflows/container_shell/demo_sast.yaml new file mode 100644 index 0000000..627a934 --- /dev/null +++ b/src/seclab_taskflows/taskflows/container_shell/demo_sast.yaml @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +# Demo: SAST container shell. +# Analyses source code in CONTAINER_WORKSPACE using semgrep, ctags, and pyan3. +# Example: +# CONTAINER_WORKSPACE=/path/to/src python -m seclab_taskflow_agent \ +# -t seclab_taskflows.taskflows.container_shell.demo_sast \ +# -g target=mymodule.py + +seclab-taskflow-agent: + filetype: taskflow + version: "1.0" + +globals: + target: "." + +taskflow: + - task: + must_complete: true + headless: true + agents: + - seclab_taskflow_agent.personalities.assistant + toolboxes: + - seclab_taskflows.toolboxes.container_shell_sast + env: + CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE') }}" + user_prompt: | + Perform static analysis on the source code at /workspace/{{ globals.target }}. + + 1. Directory overview: + `tree /workspace/{{ globals.target }} 2>/dev/null | head -40` + + 2. Locate source files: + `fd -e py -e c -e h /workspace/{{ globals.target }} | head -30` + + 3. Security scan with semgrep (report findings only, suppress progress): + `semgrep scan --config=auto --quiet /workspace/{{ globals.target }} 2>/dev/null` + If semgrep requires network access and it is unavailable, note that and skip. + + 4. Symbol index with ctags: + `ctags -R --fields=+ne -f /tmp/tags /workspace/{{ globals.target }} && grep -v "^!" /tmp/tags | head -40` + + 5. Call graph (Python only — skip if no .py files): + `pyan3 $(fd -e py /workspace/{{ globals.target }} | tr '\n' ' ') --dot --no-defines 2>/dev/null | head -60` + + 6. Cross-reference entry points with GNU global: + `cd /workspace/{{ globals.target }} && gtags --compact -v 2>/dev/null && global -x main 2>/dev/null || echo "gtags: no entry point found"` + + Based on the above, summarize: + - Security findings from semgrep (severity, rule, file, line) + - Key functions/classes and their call relationships + - Identified entry points and reachable sensitive operations + - Areas recommended for deeper review diff --git a/src/seclab_taskflows/toolboxes/container_shell_base.yaml b/src/seclab_taskflows/toolboxes/container_shell_base.yaml new file mode 100644 index 0000000..206acfd --- /dev/null +++ b/src/seclab_taskflows/toolboxes/container_shell_base.yaml @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +seclab-taskflow-agent: + filetype: toolbox + version: "1.0" + +server_params: + kind: stdio + command: python + args: ["-m", "seclab_taskflows.mcp_servers.container_shell"] + env: + CONTAINER_IMAGE: "seclab-shell-base:latest" + CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" + CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}" + LOG_DIR: "{{ env('LOG_DIR') }}" + +confirm: + - shell_exec + +server_prompt: | + ## Container Shell (base) + You have access to an isolated Docker container. Use `shell_exec` to run commands. + The working directory is /workspace (mapped from the host workspace if configured). + + Available tools in this container: + - bash, coreutils (ls, cat, grep, find, sed, awk, sort, uniq, wc, ...) + - file — identify file type by magic bytes + - strings — extract printable strings from binary files + - objdump — disassemble and dump object/binary files + - readelf — display ELF binary structure + - nm — list symbols from object files + - xxd / hexdump — hex inspection + - python3 — scripting + - curl / wget — HTTP requests + - git — version control + + All commands run inside the container. Output includes stdout, stderr, and exit code. diff --git a/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml b/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml new file mode 100644 index 0000000..332eb87 --- /dev/null +++ b/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +seclab-taskflow-agent: + filetype: toolbox + version: "1.0" + +server_params: + kind: stdio + command: python + args: ["-m", "seclab_taskflows.mcp_servers.container_shell"] + env: + CONTAINER_IMAGE: "seclab-shell-malware-analysis:latest" + CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" + CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}" + LOG_DIR: "{{ env('LOG_DIR') }}" + +confirm: + - shell_exec + +server_prompt: | + ## Container Shell (malware analysis) + You have access to an isolated Docker container for binary and malware analysis. + The working directory is /workspace — place samples here before analysis. + ALL tooling runs inside the container. Note: /workspace is bind-mounted from + the host, so files written there are visible on the host side as well. + + Available tools: + - file, strings, xxd, hexdump — initial triage / string extraction + - objdump, readelf, nm — ELF structure and disassembly + - radare2 (r2, rabin2, rasm2) — reverse engineering and disassembly framework + - binwalk — firmware / archive extraction and analysis + - yara — pattern/signature matching + - exiftool — metadata extraction + - checksec — binary security feature detection + - capstone (Python) — disassembly library for scripting + - pwntools (Python) — exploit development / binary analysis library + - volatility3 — memory forensics framework + - python3 — scripting and automation + + Recommended workflow: + 1. `file ` — identify type + 2. `strings -n 8 ` — extract strings + 3. `rabin2 -I ` — binary info (arch, bits, security features) + 4. `r2 -A -q -c "pdf @ main" ` — disassemble main function diff --git a/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml b/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml new file mode 100644 index 0000000..81923e4 --- /dev/null +++ b/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +seclab-taskflow-agent: + filetype: toolbox + version: "1.0" + +server_params: + kind: stdio + command: python + args: ["-m", "seclab_taskflows.mcp_servers.container_shell"] + env: + CONTAINER_IMAGE: "seclab-shell-network-analysis:latest" + CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" + CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}" + LOG_DIR: "{{ env('LOG_DIR') }}" + +confirm: + - shell_exec + +server_prompt: | + ## Container Shell (network analysis) + You have access to an isolated Docker container for network analysis and recon. + Files (e.g. pcap captures) are available at /workspace. + + Available tools: + - nmap — network discovery and port scanning + - tcpdump — packet capture and inspection + - tshark — Wireshark CLI for pcap analysis + - netcat (nc) — TCP/UDP connections and simple servers + - dig / nslookup — DNS lookups + - curl / wget — HTTP inspection + - jq — JSON parsing and transformation + - httpie (http) — human-friendly HTTP client diff --git a/src/seclab_taskflows/toolboxes/container_shell_sast.yaml b/src/seclab_taskflows/toolboxes/container_shell_sast.yaml new file mode 100644 index 0000000..9a2ec79 --- /dev/null +++ b/src/seclab_taskflows/toolboxes/container_shell_sast.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +seclab-taskflow-agent: + filetype: toolbox + version: "1.0" + +server_params: + kind: stdio + command: python + args: ["-m", "seclab_taskflows.mcp_servers.container_shell"] + env: + CONTAINER_IMAGE: "seclab-shell-sast:latest" + CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" + CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}" + LOG_DIR: "{{ env('LOG_DIR') }}" + +confirm: + - shell_exec + +server_prompt: | + ## Container Shell (SAST) + You have access to an isolated Docker container for static analysis and code exploration. + Source code is available at /workspace. All tools run inside the container. + + Available tools: + - semgrep — SAST scanner; run with `semgrep scan --config=auto /workspace` + - pyan3 — Python static call graph generator (dot output for graphviz) + - ctags — Multi-language tag/symbol index (`ctags -R .`) + - global (gtags/global) — Source code cross-reference: `gtags` to index, `global -r ` to find callers + - cscope — C/C++ cross-reference browser (`cscope -R -b` to build, `cscope -R -L -2 ` for callers) + - graphviz (dot) — Render dot graphs: `dot -Tpng callgraph.dot -o callgraph.png` + - ripgrep (rg) — Fast regex search: `rg /workspace` + - fd — Fast file finder: `fd -e py /workspace` + - tree — Directory structure: `tree /workspace` + - grep, sed, awk, find — Standard code exploration + + Typical call graph workflow (Python): + 1. `fd -e py /workspace | head -30` — find Python files + 2. `pyan3 $(fd -e py /workspace | tr '\n' ' ') --dot --no-defines > /tmp/cg.dot` + 3. `dot -Tsvg /tmp/cg.dot -o /tmp/cg.svg && cat /tmp/cg.svg | head -100` + + Typical call graph workflow (C): + 1. `cd /workspace && ctags -R --fields=+ne .` + 2. `cd /workspace && cscope -R -b && cscope -R -L -2 ` diff --git a/tests/test_container_shell.py b/tests/test_container_shell.py new file mode 100644 index 0000000..8be2716 --- /dev/null +++ b/tests/test_container_shell.py @@ -0,0 +1,218 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +import seclab_taskflows.mcp_servers.container_shell as cs_mod +from seclab_taskflow_agent.available_tools import AvailableTools + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_proc(returncode=0, stdout="", stderr=""): + proc = MagicMock() + proc.returncode = returncode + proc.stdout = stdout + proc.stderr = stderr + return proc + + +def _reset_container(): + """Reset global container state between tests.""" + cs_mod._container_name = None + + +# --------------------------------------------------------------------------- +# _start_container tests +# --------------------------------------------------------------------------- + +class TestStartContainer: + def setup_method(self): + _reset_container() + + def test_start_container_success(self): + with ( + patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"), + patch.object(cs_mod, "CONTAINER_WORKSPACE", "/host/workspace"), + patch("subprocess.run", return_value=_make_proc(returncode=0)) as mock_run, + ): + name = cs_mod._start_container() + assert name.startswith("seclab-shell-") + cmd = mock_run.call_args[0][0] + assert "docker" in cmd + assert "run" in cmd + assert "--name" in cmd + assert "-v" in cmd + assert "/host/workspace:/workspace" in cmd + assert "test-image:latest" in cmd + assert "tail" in cmd + + def test_start_container_no_workspace(self): + with ( + patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"), + patch.object(cs_mod, "CONTAINER_WORKSPACE", ""), + patch("subprocess.run", return_value=_make_proc(returncode=0)) as mock_run, + ): + name = cs_mod._start_container() + assert name.startswith("seclab-shell-") + cmd = mock_run.call_args[0][0] + assert "-v" not in cmd + + def test_start_container_failure(self): + with ( + patch.object(cs_mod, "CONTAINER_IMAGE", "missing-image:latest"), + patch.object(cs_mod, "CONTAINER_WORKSPACE", ""), + patch("subprocess.run", return_value=_make_proc(returncode=1, stderr="image not found")), + ): + with pytest.raises(RuntimeError, match="docker run failed"): + cs_mod._start_container() + + def test_start_container_rejects_colon_in_workspace(self): + with ( + patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"), + patch.object(cs_mod, "CONTAINER_WORKSPACE", "/host/path:ro"), + ): + with pytest.raises(RuntimeError, match="CONTAINER_WORKSPACE must not contain a colon"): + cs_mod._start_container() + + def test_start_container_rejects_empty_image(self): + with ( + patch.object(cs_mod, "CONTAINER_IMAGE", ""), + patch.object(cs_mod, "CONTAINER_WORKSPACE", ""), + ): + with pytest.raises(RuntimeError, match="CONTAINER_IMAGE is not set"): + cs_mod._start_container() + + +# --------------------------------------------------------------------------- +# shell_exec tests +# --------------------------------------------------------------------------- + +class TestShellExec: + def setup_method(self): + _reset_container() + + def test_shell_exec_lazy_start(self): + start_proc = _make_proc(returncode=0) + exec_proc = _make_proc(returncode=0, stdout="hello\n") + with ( + patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"), + patch.object(cs_mod, "CONTAINER_WORKSPACE", ""), + patch("subprocess.run", side_effect=[start_proc, exec_proc]), + ): + assert cs_mod._container_name is None + result = cs_mod.shell_exec.fn(command="echo hello") + assert cs_mod._container_name is not None + assert "hello" in result + + def test_shell_exec_runs_command(self): + cs_mod._container_name = "seclab-shell-testtest" + exec_proc = _make_proc(returncode=0, stdout="output\n") + with patch("subprocess.run", return_value=exec_proc) as mock_run: + result = cs_mod.shell_exec.fn(command="echo output", workdir="/workspace") + cmd = mock_run.call_args[0][0] + assert "docker" in cmd + assert "exec" in cmd + assert "-w" in cmd + assert "/workspace" in cmd + assert "seclab-shell-testtest" in cmd + assert "echo output" in cmd + assert "output" in result + + def test_shell_exec_includes_exit_code(self): + cs_mod._container_name = "seclab-shell-testtest" + exec_proc = _make_proc(returncode=0, stdout="done\n") + with patch("subprocess.run", return_value=exec_proc): + result = cs_mod.shell_exec.fn(command="true") + assert "[exit code: 0]" in result + + def test_shell_exec_nonzero_exit(self): + cs_mod._container_name = "seclab-shell-testtest" + exec_proc = _make_proc(returncode=1, stdout="", stderr="error\n") + with patch("subprocess.run", return_value=exec_proc): + result = cs_mod.shell_exec.fn(command="false") + assert "[exit code: 1]" in result + assert "error" in result + + def test_shell_exec_timeout(self): + cs_mod._container_name = "seclab-shell-testtest" + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=5)): + result = cs_mod.shell_exec.fn(command="sleep 999", timeout=5) + assert "timeout" in result + + def test_shell_exec_start_failure_returns_error(self): + _reset_container() + with ( + patch.object(cs_mod, "CONTAINER_IMAGE", "bad-image:latest"), + patch.object(cs_mod, "CONTAINER_WORKSPACE", ""), + patch("subprocess.run", return_value=_make_proc(returncode=1, stderr="image not found")), + ): + result = cs_mod.shell_exec.fn(command="echo hi") + assert "Failed to start container" in result + assert cs_mod._container_name is None + + +# --------------------------------------------------------------------------- +# _stop_container tests +# --------------------------------------------------------------------------- + +class TestStopContainer: + def setup_method(self): + _reset_container() + + def test_stop_container_called_on_atexit(self): + cs_mod._container_name = "seclab-shell-tostop" + with patch("subprocess.run", return_value=_make_proc(returncode=0)) as mock_run: + cs_mod._stop_container() + cmd = mock_run.call_args[0][0] + assert "docker" in cmd + assert "stop" in cmd + assert "seclab-shell-tostop" in cmd + assert cs_mod._container_name is None + + def test_stop_container_no_op_when_none(self): + cs_mod._container_name = None + with patch("subprocess.run") as mock_run: + cs_mod._stop_container() + mock_run.assert_not_called() + + def test_stop_container_clears_name_on_failure(self): + cs_mod._container_name = "seclab-shell-tostop" + with patch("subprocess.run", return_value=_make_proc(returncode=1, stderr="not found")): + cs_mod._stop_container() + assert cs_mod._container_name is None + + +# --------------------------------------------------------------------------- +# Toolbox YAML validation +# --------------------------------------------------------------------------- + +class TestToolboxYaml: + def test_toolbox_yaml_valid_base(self): + tools = AvailableTools() + result = tools.get_toolbox("seclab_taskflows.toolboxes.container_shell_base") + assert result is not None + assert result["seclab-taskflow-agent"]["filetype"] == "toolbox" + + def test_toolbox_yaml_valid_malware(self): + tools = AvailableTools() + result = tools.get_toolbox("seclab_taskflows.toolboxes.container_shell_malware_analysis") + assert result is not None + assert result["seclab-taskflow-agent"]["filetype"] == "toolbox" + + def test_toolbox_yaml_valid_network(self): + tools = AvailableTools() + result = tools.get_toolbox("seclab_taskflows.toolboxes.container_shell_network_analysis") + assert result is not None + assert result["seclab-taskflow-agent"]["filetype"] == "toolbox" + + def test_toolbox_yaml_valid_sast(self): + tools = AvailableTools() + result = tools.get_toolbox("seclab_taskflows.toolboxes.container_shell_sast") + assert result is not None + assert result["seclab-taskflow-agent"]["filetype"] == "toolbox"