From 0892143d294de32a307e78ea349e19e7cdff6673 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Tue, 3 Mar 2026 15:48:51 -0500 Subject: [PATCH 1/7] Add containerized shell toolbox Adds an MCP server that manages a single Docker container per process lifetime, exposing a shell_exec tool for running arbitrary CLI commands in an isolated environment with an optional host workspace mount. Three profiles are provided, each with a Dockerfile, toolbox YAML, and demo taskflow: - base: debian:bookworm-slim + binutils, file, xxd, python3, curl, git - malware-analysis: extends base with radare2, binwalk, yara, exiftool, checksec, capstone, pwntools, volatility3 - network-analysis: extends base with nmap, tcpdump, tshark, netcat, dnsutils, jq, httpie New files: - src/seclab_taskflows/mcp_servers/container_shell.py - src/seclab_taskflows/containers/{base,malware_analysis,network_analysis}/Dockerfile - src/seclab_taskflows/toolboxes/container_shell_{base,malware_analysis,network_analysis}.yaml - src/seclab_taskflows/taskflows/container_shell/{README.md,demo_base,demo_malware_analysis,demo_network_analysis}.yaml - scripts/build_container_images.sh - scripts/run_container_shell_demo.sh - tests/test_container_shell.py (14 tests, all mocked) --- scripts/build_container_images.sh | 59 ++++++ scripts/run_container_shell_demo.sh | 79 ++++++++ .../containers/base/Dockerfile | 6 + .../containers/malware_analysis/Dockerfile | 14 ++ .../containers/network_analysis/Dockerfile | 4 + .../mcp_servers/container_shell.py | 92 +++++++++ .../taskflows/container_shell/README.md | 112 +++++++++++ .../taskflows/container_shell/demo_base.yaml | 40 ++++ .../demo_malware_analysis.yaml | 65 ++++++ .../demo_network_analysis.yaml | 63 ++++++ .../toolboxes/container_shell_base.yaml | 38 ++++ .../container_shell_malware_analysis.yaml | 44 ++++ .../container_shell_network_analysis.yaml | 34 ++++ tests/test_container_shell.py | 188 ++++++++++++++++++ 14 files changed, 838 insertions(+) create mode 100755 scripts/build_container_images.sh create mode 100755 scripts/run_container_shell_demo.sh create mode 100644 src/seclab_taskflows/containers/base/Dockerfile create mode 100644 src/seclab_taskflows/containers/malware_analysis/Dockerfile create mode 100644 src/seclab_taskflows/containers/network_analysis/Dockerfile create mode 100644 src/seclab_taskflows/mcp_servers/container_shell.py create mode 100644 src/seclab_taskflows/taskflows/container_shell/README.md create mode 100644 src/seclab_taskflows/taskflows/container_shell/demo_base.yaml create mode 100644 src/seclab_taskflows/taskflows/container_shell/demo_malware_analysis.yaml create mode 100644 src/seclab_taskflows/taskflows/container_shell/demo_network_analysis.yaml create mode 100644 src/seclab_taskflows/toolboxes/container_shell_base.yaml create mode 100644 src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml create mode 100644 src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml create mode 100644 tests/test_container_shell.py diff --git a/scripts/build_container_images.sh b/scripts/build_container_images.sh new file mode 100755 index 0000000..fa73615 --- /dev/null +++ b/scripts/build_container_images.sh @@ -0,0 +1,59 @@ +#!/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|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/" +} + +target="${1:-all}" + +case "$target" in + base) + build_base + ;; + malware) + build_base + build_malware + ;; + network) + build_base + build_network + ;; + all) + build_base + build_malware + build_network + ;; + *) + echo "Unknown target: $target" >&2 + echo "Usage: $0 [base|malware|network|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..14e57b8 --- /dev/null +++ b/scripts/run_container_shell_demo.sh @@ -0,0 +1,79 @@ +#!/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] +# +# 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" + ;; + *) + echo "Unknown demo: $demo. Choose base, malware, or network." >&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..77bfe03 --- /dev/null +++ b/src/seclab_taskflows/containers/base/Dockerfile @@ -0,0 +1,6 @@ +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..85ffff5 --- /dev/null +++ b/src/seclab_taskflows/containers/malware_analysis/Dockerfile @@ -0,0 +1,14 @@ +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..71b1424 --- /dev/null +++ b/src/seclab_taskflows/containers/network_analysis/Dockerfile @@ -0,0 +1,4 @@ +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/mcp_servers/container_shell.py b/src/seclab_taskflows/mcp_servers/container_shell.py new file mode 100644 index 0000000..25b94fc --- /dev/null +++ b/src/seclab_taskflows/mcp_servers/container_shell.py @@ -0,0 +1,92 @@ +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_id: 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")) + + +def _start_container() -> str: + """Start the Docker container and return its name.""" + 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) + if result.returncode != 0: + raise RuntimeError(f"docker run failed: {result.stderr.strip()}") + logging.debug(f"Container started: {name}") + return name + + +def _stop_container() -> None: + """Stop the running container.""" + global _container_id + if _container_id is None: + return + logging.debug(f"Stopping container: {_container_id}") + subprocess.run( + ["docker", "stop", "--time", "5", _container_id], + capture_output=True, + text=True, + ) + _container_id = None + + +atexit.register(_stop_container) + + +_DEFAULT_WORKDIR = "/workspace" if CONTAINER_WORKSPACE else "/" + + +@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_id + if _container_id is None: + try: + _container_id = _start_container() + except RuntimeError as e: + return f"Failed to start container: {e}" + + cmd = ["docker", "exec", "-w", workdir, _container_id, "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..e3373dc --- /dev/null +++ b/src/seclab_taskflows/taskflows/container_shell/README.md @@ -0,0 +1,112 @@ +# 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. + +Three 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. + +## 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 +``` + +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). + +`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 +``` + +## 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/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..2c50022 --- /dev/null +++ b/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml @@ -0,0 +1,44 @@ +# 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, keeping the host safe. + + 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/tests/test_container_shell.py b/tests/test_container_shell.py new file mode 100644 index 0000000..567270f --- /dev/null +++ b/tests/test_container_shell.py @@ -0,0 +1,188 @@ +import importlib +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_id = 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() + + +# --------------------------------------------------------------------------- +# 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_id is None + result = cs_mod.shell_exec.fn(command="echo hello") + assert cs_mod._container_id is not None + assert "hello" in result + + def test_shell_exec_runs_command(self): + cs_mod._container_id = "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_id = "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_id = "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_id = "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_id is None + + +# --------------------------------------------------------------------------- +# _stop_container tests +# --------------------------------------------------------------------------- + +class TestStopContainer: + def setup_method(self): + _reset_container() + + def test_stop_container_called_on_atexit(self): + cs_mod._container_id = "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_id is None + + def test_stop_container_no_op_when_none(self): + cs_mod._container_id = None + with patch("subprocess.run") as mock_run: + cs_mod._stop_container() + mock_run.assert_not_called() + + +# --------------------------------------------------------------------------- +# 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" From 04d652d86020b057656d220114f0760109734230 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Tue, 3 Mar 2026 15:56:10 -0500 Subject: [PATCH 2/7] Reject CONTAINER_WORKSPACE values containing a colon A colon in the workspace path breaks Docker's volume mount syntax (host:container[:options]), silently changing mount behaviour. Raise RuntimeError early in _start_container() if the colon is present. Adds a corresponding test. --- src/seclab_taskflows/mcp_servers/container_shell.py | 2 ++ tests/test_container_shell.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/seclab_taskflows/mcp_servers/container_shell.py b/src/seclab_taskflows/mcp_servers/container_shell.py index 25b94fc..be09aeb 100644 --- a/src/seclab_taskflows/mcp_servers/container_shell.py +++ b/src/seclab_taskflows/mcp_servers/container_shell.py @@ -27,6 +27,8 @@ def _start_container() -> str: """Start the Docker container and return its name.""" + if CONTAINER_WORKSPACE and ":" in CONTAINER_WORKSPACE: + raise RuntimeError(f"CONTAINER_WORKSPACE must not contain a colon: {CONTAINER_WORKSPACE!r}") name = f"seclab-shell-{uuid.uuid4().hex[:8]}" cmd = ["docker", "run", "-d", "--rm", "--name", name] if CONTAINER_WORKSPACE: diff --git a/tests/test_container_shell.py b/tests/test_container_shell.py index 567270f..1399c6e 100644 --- a/tests/test_container_shell.py +++ b/tests/test_container_shell.py @@ -70,6 +70,14 @@ def test_start_container_failure(self): 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() + # --------------------------------------------------------------------------- # shell_exec tests From 6307ae663987b973a7a8b636fb6dda86b0fade95 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Tue, 3 Mar 2026 15:59:18 -0500 Subject: [PATCH 3/7] Fix ruff linter errors in test_container_shell SLF001 (private member accessed) is expected in tests that exercise module internals directly. Suppress it via per-file-ignores for tests/*. PLW0603 (global statement used for assignment) is the correct pattern for the module-level container ID state. Add to the global ignore list alongside the existing PLW0602 exemption. --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4c6bc8f..5701e1b 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,8 @@ ignore = [ "W291", # Trailing whitespace "W293", # Blank line contains whitespace ] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = [ + "SLF001", # Private member accessed (tests legitimately access module internals) +] From c49cf6273c1ed4e98cd0c64f5eeedac16f822f92 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Tue, 3 Mar 2026 16:29:52 -0500 Subject: [PATCH 4/7] Suppress S101 (assert in tests) via per-file-ignores S101 started firing after a ruff version bump in CI, including against the pre-existing test_00.py. Use of assert is standard pytest practice; suppress it for tests/* alongside SLF001. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 5701e1b..4461251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,5 +117,6 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/*" = [ + "S101", # Use of assert (standard in pytest) "SLF001", # Private member accessed (tests legitimately access module internals) ] From b8aafc6efee3cffc83368e661260cb6d832994c1 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Tue, 3 Mar 2026 17:01:36 -0500 Subject: [PATCH 5/7] Add SAST container profile - seclab-shell-sast image extends base with semgrep, pyan3, universal-ctags, GNU global, cscope, graphviz, ripgrep, fd, tree - Toolbox YAML with server_prompt documenting Python and C call graph workflows - Demo taskflow: tree, fd, semgrep, ctags, pyan3, gtags then summarise findings - Runner generates a demo Python file with a shell=True anti-pattern if workspace is empty, so semgrep has something to find out of the box - build_container_images.sh and run_container_shell_demo.sh updated for sast target - test_toolbox_yaml_valid_sast added (16/16 passing) --- scripts/build_container_images.sh | 12 ++++- scripts/run_container_shell_demo.sh | 51 +++++++++++++++++- .../containers/sast/Dockerfile | 9 ++++ .../taskflows/container_shell/README.md | 23 +++++++- .../taskflows/container_shell/demo_sast.yaml | 54 +++++++++++++++++++ .../toolboxes/container_shell_sast.yaml | 45 ++++++++++++++++ tests/test_container_shell.py | 6 +++ 7 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 src/seclab_taskflows/containers/sast/Dockerfile create mode 100644 src/seclab_taskflows/taskflows/container_shell/demo_sast.yaml create mode 100644 src/seclab_taskflows/toolboxes/container_shell_sast.yaml diff --git a/scripts/build_container_images.sh b/scripts/build_container_images.sh index fa73615..c65661b 100755 --- a/scripts/build_container_images.sh +++ b/scripts/build_container_images.sh @@ -30,6 +30,11 @@ build_network() { 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 @@ -44,14 +49,19 @@ case "$target" in 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|all]" >&2 + echo "Usage: $0 [base|malware|network|sast|all]" >&2 exit 1 ;; esac diff --git a/scripts/run_container_shell_demo.sh b/scripts/run_container_shell_demo.sh index 14e57b8..57fc2e0 100755 --- a/scripts/run_container_shell_demo.sh +++ b/scripts/run_container_shell_demo.sh @@ -9,6 +9,7 @@ # ./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. @@ -27,7 +28,7 @@ fi demo="${1:-}" if [ -z "$demo" ]; then - echo "Usage: $0 [workspace_dir] [target]" >&2 + echo "Usage: $0 [workspace_dir] [target]" >&2 exit 1 fi @@ -72,8 +73,54 @@ case "$demo" in -t seclab_taskflows.taskflows.container_shell.demo_network_analysis \ -g capture="$capture" ;; + sast) + target="${3:-.}" + if [ ! -d "$workspace" ] && [ ! -f "${workspace}/${target}" ]; then + echo "No source found at ${workspace}/${target}" >&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, or network." >&2 + echo "Unknown demo: $demo. Choose base, malware, network, or sast." >&2 exit 1 ;; esac 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/taskflows/container_shell/README.md b/src/seclab_taskflows/taskflows/container_shell/README.md index e3373dc..62cf4a2 100644 --- a/src/seclab_taskflows/taskflows/container_shell/README.md +++ b/src/seclab_taskflows/taskflows/container_shell/README.md @@ -4,7 +4,7 @@ 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. -Three container profiles are provided. Each has its own Dockerfile, toolbox +Four container profiles are provided. Each has its own Dockerfile, toolbox YAML, and demo taskflow. ## Profiles @@ -21,6 +21,10 @@ yara, exiftool, checksec, capstone, pwntools, volatility3. 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: @@ -35,6 +39,7 @@ 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. @@ -79,6 +84,22 @@ CONTAINER_WORKSPACE=/tmp/captures python -m seclab_taskflow_agent \ -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`: 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_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 index 1399c6e..26039c8 100644 --- a/tests/test_container_shell.py +++ b/tests/test_container_shell.py @@ -194,3 +194,9 @@ def test_toolbox_yaml_valid_network(self): 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" From 7f2dbdbd0d4f2533020a16f70e1565d9f016e7cd Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Tue, 3 Mar 2026 21:05:59 -0500 Subject: [PATCH 6/7] Address PR review feedback on container shell toolbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename _container_id → _container_name throughout (it stores the name set via --name, not the Docker-assigned container ID) - Add empty-image guard in _start_container: raise clear RuntimeError when CONTAINER_IMAGE is not set rather than passing an empty string to docker run - Add 30s timeout to docker run subprocess call in _start_container - Log warning in _stop_container when docker stop fails instead of silently ignoring a non-zero returncode - Default _DEFAULT_WORKDIR to /workspace unconditionally (all images set WORKDIR /workspace; the previous "/" fallback when no workspace was mounted was inconsistent with the container image defaults) - Add SPDX headers to container_shell.py, test_container_shell.py, and all three Dockerfiles that were missing them - Remove unused importlib import from test_container_shell.py - Fix dead sast workspace existence check in run_container_shell_demo.sh (mkdir -p always creates workspace so the old condition was never true; now checks the actual target path when a specific target is provided) - Update build_container_images.sh usage comment to include sast - Clarify malware analysis toolbox prompt: /workspace is bind-mounted RW from the host, not an isolated environment - Update README CONTAINER_TIMEOUT defaults to mention sast profile (60s) - Add test_start_container_rejects_empty_image and test_stop_container_clears_name_on_failure test cases --- scripts/build_container_images.sh | 2 +- scripts/run_container_shell_demo.sh | 5 ++- .../containers/base/Dockerfile | 3 ++ .../containers/malware_analysis/Dockerfile | 3 ++ .../containers/network_analysis/Dockerfile | 3 ++ .../mcp_servers/container_shell.py | 40 ++++++++++++------- .../taskflows/container_shell/README.md | 2 +- .../container_shell_malware_analysis.yaml | 3 +- tests/test_container_shell.py | 40 +++++++++++++------ 9 files changed, 69 insertions(+), 32 deletions(-) diff --git a/scripts/build_container_images.sh b/scripts/build_container_images.sh index c65661b..d32555d 100755 --- a/scripts/build_container_images.sh +++ b/scripts/build_container_images.sh @@ -6,7 +6,7 @@ # 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|all] +# Usage: ./scripts/build_container_images.sh [base|malware|network|sast|all] # default: all set -euo pipefail diff --git a/scripts/run_container_shell_demo.sh b/scripts/run_container_shell_demo.sh index 57fc2e0..a6c43a7 100755 --- a/scripts/run_container_shell_demo.sh +++ b/scripts/run_container_shell_demo.sh @@ -75,8 +75,9 @@ case "$demo" in ;; sast) target="${3:-.}" - if [ ! -d "$workspace" ] && [ ! -f "${workspace}/${target}" ]; then - echo "No source found at ${workspace}/${target}" >&2 + 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 diff --git a/src/seclab_taskflows/containers/base/Dockerfile b/src/seclab_taskflows/containers/base/Dockerfile index 77bfe03..9a99e3e 100644 --- a/src/seclab_taskflows/containers/base/Dockerfile +++ b/src/seclab_taskflows/containers/base/Dockerfile @@ -1,3 +1,6 @@ +# 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 \ diff --git a/src/seclab_taskflows/containers/malware_analysis/Dockerfile b/src/seclab_taskflows/containers/malware_analysis/Dockerfile index 85ffff5..77afd3d 100644 --- a/src/seclab_taskflows/containers/malware_analysis/Dockerfile +++ b/src/seclab_taskflows/containers/malware_analysis/Dockerfile @@ -1,3 +1,6 @@ +# 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 \ diff --git a/src/seclab_taskflows/containers/network_analysis/Dockerfile b/src/seclab_taskflows/containers/network_analysis/Dockerfile index 71b1424..8c51b8e 100644 --- a/src/seclab_taskflows/containers/network_analysis/Dockerfile +++ b/src/seclab_taskflows/containers/network_analysis/Dockerfile @@ -1,3 +1,6 @@ +# 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 \ diff --git a/src/seclab_taskflows/mcp_servers/container_shell.py b/src/seclab_taskflows/mcp_servers/container_shell.py index be09aeb..896f570 100644 --- a/src/seclab_taskflows/mcp_servers/container_shell.py +++ b/src/seclab_taskflows/mcp_servers/container_shell.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + import atexit import logging import os @@ -18,15 +21,19 @@ mcp = FastMCP("ContainerShell") -_container_id: str | None = None +_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: + raise RuntimeError("CONTAINER_IMAGE is not set — cannot start container") if CONTAINER_WORKSPACE and ":" in CONTAINER_WORKSPACE: raise RuntimeError(f"CONTAINER_WORKSPACE must not contain a colon: {CONTAINER_WORKSPACE!r}") name = f"seclab-shell-{uuid.uuid4().hex[:8]}" @@ -35,7 +42,7 @@ def _start_container() -> str: 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) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: raise RuntimeError(f"docker run failed: {result.stderr.strip()}") logging.debug(f"Container started: {name}") @@ -44,24 +51,27 @@ def _start_container() -> str: def _stop_container() -> None: """Stop the running container.""" - global _container_id - if _container_id is None: + global _container_name + if _container_name is None: return - logging.debug(f"Stopping container: {_container_id}") - subprocess.run( - ["docker", "stop", "--time", "5", _container_id], + logging.debug(f"Stopping container: {_container_name}") + result = subprocess.run( + ["docker", "stop", "--time", "5", _container_name], capture_output=True, text=True, ) - _container_id = None + 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) -_DEFAULT_WORKDIR = "/workspace" if CONTAINER_WORKSPACE else "/" - - @mcp.tool() def shell_exec( command: Annotated[str, Field(description="Shell command to execute inside the container")], @@ -69,14 +79,14 @@ def shell_exec( workdir: Annotated[str, Field(description="Working directory inside the container")] = _DEFAULT_WORKDIR, ) -> str: """Execute a shell command inside the managed Docker container.""" - global _container_id - if _container_id is None: + global _container_name + if _container_name is None: try: - _container_id = _start_container() + _container_name = _start_container() except RuntimeError as e: return f"Failed to start container: {e}" - cmd = ["docker", "exec", "-w", workdir, _container_id, "bash", "-c", command] + 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) diff --git a/src/seclab_taskflows/taskflows/container_shell/README.md b/src/seclab_taskflows/taskflows/container_shell/README.md index 62cf4a2..0257538 100644 --- a/src/seclab_taskflows/taskflows/container_shell/README.md +++ b/src/seclab_taskflows/taskflows/container_shell/README.md @@ -50,7 +50,7 @@ Images only need to be rebuilt when a Dockerfile changes. 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 network) or 60 (malware analysis and sast). `LOG_DIR` — where to write `container_shell.log`. diff --git a/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml b/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml index 2c50022..332eb87 100644 --- a/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml +++ b/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml @@ -22,7 +22,8 @@ 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, keeping the host safe. + 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 diff --git a/tests/test_container_shell.py b/tests/test_container_shell.py index 26039c8..8be2716 100644 --- a/tests/test_container_shell.py +++ b/tests/test_container_shell.py @@ -1,4 +1,6 @@ -import importlib +# SPDX-FileCopyrightText: GitHub, Inc. +# SPDX-License-Identifier: MIT + import subprocess from unittest.mock import MagicMock, patch @@ -22,7 +24,7 @@ def _make_proc(returncode=0, stdout="", stderr=""): def _reset_container(): """Reset global container state between tests.""" - cs_mod._container_id = None + cs_mod._container_name = None # --------------------------------------------------------------------------- @@ -78,6 +80,14 @@ def test_start_container_rejects_colon_in_workspace(self): 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 @@ -95,13 +105,13 @@ def test_shell_exec_lazy_start(self): patch.object(cs_mod, "CONTAINER_WORKSPACE", ""), patch("subprocess.run", side_effect=[start_proc, exec_proc]), ): - assert cs_mod._container_id is None + assert cs_mod._container_name is None result = cs_mod.shell_exec.fn(command="echo hello") - assert cs_mod._container_id is not None + assert cs_mod._container_name is not None assert "hello" in result def test_shell_exec_runs_command(self): - cs_mod._container_id = "seclab-shell-testtest" + 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") @@ -115,14 +125,14 @@ def test_shell_exec_runs_command(self): assert "output" in result def test_shell_exec_includes_exit_code(self): - cs_mod._container_id = "seclab-shell-testtest" + 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_id = "seclab-shell-testtest" + 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") @@ -130,7 +140,7 @@ def test_shell_exec_nonzero_exit(self): assert "error" in result def test_shell_exec_timeout(self): - cs_mod._container_id = "seclab-shell-testtest" + 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 @@ -144,7 +154,7 @@ def test_shell_exec_start_failure_returns_error(self): ): result = cs_mod.shell_exec.fn(command="echo hi") assert "Failed to start container" in result - assert cs_mod._container_id is None + assert cs_mod._container_name is None # --------------------------------------------------------------------------- @@ -156,21 +166,27 @@ def setup_method(self): _reset_container() def test_stop_container_called_on_atexit(self): - cs_mod._container_id = "seclab-shell-tostop" + 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_id is None + assert cs_mod._container_name is None def test_stop_container_no_op_when_none(self): - cs_mod._container_id = None + 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 From 1245c0de3a9d8736148399e141b99dd971725911 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Tue, 3 Mar 2026 21:08:42 -0500 Subject: [PATCH 7/7] Fix EM101: assign exception messages to variables before raising --- src/seclab_taskflows/mcp_servers/container_shell.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/seclab_taskflows/mcp_servers/container_shell.py b/src/seclab_taskflows/mcp_servers/container_shell.py index 896f570..409f310 100644 --- a/src/seclab_taskflows/mcp_servers/container_shell.py +++ b/src/seclab_taskflows/mcp_servers/container_shell.py @@ -33,9 +33,11 @@ def _start_container() -> str: """Start the Docker container and return its name.""" if not CONTAINER_IMAGE: - raise RuntimeError("CONTAINER_IMAGE is not set — cannot start container") + msg = "CONTAINER_IMAGE is not set — cannot start container" + raise RuntimeError(msg) if CONTAINER_WORKSPACE and ":" in CONTAINER_WORKSPACE: - raise RuntimeError(f"CONTAINER_WORKSPACE must not contain a colon: {CONTAINER_WORKSPACE!r}") + 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: @@ -44,7 +46,8 @@ def _start_container() -> str: logging.debug(f"Starting container: {' '.join(cmd)}") result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: - raise RuntimeError(f"docker run failed: {result.stderr.strip()}") + msg = f"docker run failed: {result.stderr.strip()}" + raise RuntimeError(msg) logging.debug(f"Container started: {name}") return name