Skip to content

Commit e5bde72

Browse files
authored
Merge pull request #60 from GitHubSecurityLab/anticomputer/shell-toolbox
Add containerized shell toolbox
2 parents d066271 + 1245c0d commit e5bde72

File tree

18 files changed

+1087
-0
lines changed

18 files changed

+1087
-0
lines changed

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ ignore = [
9292
"PLR1730", # Replace `if` statement with `min()`
9393
"PLR2004", # Magic value used in comparison
9494
"PLW0602", # Using global for variable but no assignment is done
95+
"PLW0603", # Using the global statement to update a variable is discouraged
9596
"PLW1508", # Invalid type for environment variable default
9697
"PLW1510", # `subprocess.run` without explicit `check` argument
9798
"RET504", # Unnecessary assignment before `return` statement
@@ -113,3 +114,9 @@ ignore = [
113114
"W291", # Trailing whitespace
114115
"W293", # Blank line contains whitespace
115116
]
117+
118+
[tool.ruff.lint.per-file-ignores]
119+
"tests/*" = [
120+
"S101", # Use of assert (standard in pytest)
121+
"SLF001", # Private member accessed (tests legitimately access module internals)
122+
]

scripts/build_container_images.sh

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/bin/bash
2+
# SPDX-FileCopyrightText: GitHub, Inc.
3+
# SPDX-License-Identifier: MIT
4+
5+
# Build seclab container shell images.
6+
# Must be run from the root of the seclab-taskflows repository.
7+
# Images must be rebuilt whenever a Dockerfile changes.
8+
#
9+
# Usage: ./scripts/build_container_images.sh [base|malware|network|sast|all]
10+
# default: all
11+
12+
set -euo pipefail
13+
14+
__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15+
__root="$(cd "${__dir}/.." && pwd)"
16+
CONTAINERS_DIR="${__root}/src/seclab_taskflows/containers"
17+
18+
build_base() {
19+
echo "Building seclab-shell-base..."
20+
docker build -t seclab-shell-base:latest "${CONTAINERS_DIR}/base/"
21+
}
22+
23+
build_malware() {
24+
echo "Building seclab-shell-malware-analysis..."
25+
docker build -t seclab-shell-malware-analysis:latest "${CONTAINERS_DIR}/malware_analysis/"
26+
}
27+
28+
build_network() {
29+
echo "Building seclab-shell-network-analysis..."
30+
docker build -t seclab-shell-network-analysis:latest "${CONTAINERS_DIR}/network_analysis/"
31+
}
32+
33+
build_sast() {
34+
echo "Building seclab-shell-sast..."
35+
docker build -t seclab-shell-sast:latest "${CONTAINERS_DIR}/sast/"
36+
}
37+
38+
target="${1:-all}"
39+
40+
case "$target" in
41+
base)
42+
build_base
43+
;;
44+
malware)
45+
build_base
46+
build_malware
47+
;;
48+
network)
49+
build_base
50+
build_network
51+
;;
52+
sast)
53+
build_base
54+
build_sast
55+
;;
56+
all)
57+
build_base
58+
build_malware
59+
build_network
60+
build_sast
61+
;;
62+
*)
63+
echo "Unknown target: $target" >&2
64+
echo "Usage: $0 [base|malware|network|sast|all]" >&2
65+
exit 1
66+
;;
67+
esac
68+
69+
echo "Done."
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/bin/bash
2+
# SPDX-FileCopyrightText: GitHub, Inc.
3+
# SPDX-License-Identifier: MIT
4+
5+
# Run container shell demo taskflows.
6+
# Must be run from the root of the seclab-taskflows repository.
7+
#
8+
# Usage:
9+
# ./scripts/run_container_shell_demo.sh base [workspace_dir]
10+
# ./scripts/run_container_shell_demo.sh malware [workspace_dir] [target_filename]
11+
# ./scripts/run_container_shell_demo.sh network [workspace_dir] [capture_filename]
12+
# ./scripts/run_container_shell_demo.sh sast [workspace_dir] [target]
13+
#
14+
# If workspace_dir is omitted a temporary directory is used.
15+
# Requires AI_API_TOKEN to be set in the environment.
16+
17+
set -euo pipefail
18+
19+
__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20+
__root="$(cd "${__dir}/.." && pwd)"
21+
22+
export PATH="${__root}/.venv/bin:${PATH}"
23+
24+
if [ -z "${AI_API_TOKEN:-}" ]; then
25+
echo "AI_API_TOKEN is not set" >&2
26+
exit 1
27+
fi
28+
29+
demo="${1:-}"
30+
if [ -z "$demo" ]; then
31+
echo "Usage: $0 <base|malware|network|sast> [workspace_dir] [target]" >&2
32+
exit 1
33+
fi
34+
35+
workspace="${2:-$(mktemp -d)}"
36+
mkdir -p "$workspace"
37+
38+
case "$demo" in
39+
base)
40+
target="${3:-hello}"
41+
if [ ! -f "${workspace}/${target}" ]; then
42+
echo "Copying /bin/ls to ${workspace}/${target} as demo target"
43+
cp /bin/ls "${workspace}/${target}"
44+
fi
45+
CONTAINER_WORKSPACE="$workspace" \
46+
LOG_DIR="${__root}/logs" \
47+
python -m seclab_taskflow_agent \
48+
-t seclab_taskflows.taskflows.container_shell.demo_base \
49+
-g target="$target"
50+
;;
51+
malware)
52+
target="${3:-suspicious.elf}"
53+
if [ ! -f "${workspace}/${target}" ]; then
54+
echo "Copying /bin/ls to ${workspace}/${target} as demo target"
55+
cp /bin/ls "${workspace}/${target}"
56+
fi
57+
CONTAINER_WORKSPACE="$workspace" \
58+
LOG_DIR="${__root}/logs" \
59+
python -m seclab_taskflow_agent \
60+
-t seclab_taskflows.taskflows.container_shell.demo_malware_analysis \
61+
-g target="$target"
62+
;;
63+
network)
64+
capture="${3:-sample.pcap}"
65+
if [ ! -f "${workspace}/${capture}" ]; then
66+
echo "No pcap found at ${workspace}/${capture}" >&2
67+
echo "Provide a pcap file or set workspace_dir to a directory containing one." >&2
68+
exit 1
69+
fi
70+
CONTAINER_WORKSPACE="$workspace" \
71+
LOG_DIR="${__root}/logs" \
72+
python -m seclab_taskflow_agent \
73+
-t seclab_taskflows.taskflows.container_shell.demo_network_analysis \
74+
-g capture="$capture"
75+
;;
76+
sast)
77+
target="${3:-.}"
78+
target_path="${workspace}/${target}"
79+
if [ "$target" != "." ] && [ ! -d "$target_path" ] && [ ! -f "$target_path" ]; then
80+
echo "No source found at ${target_path}" >&2
81+
echo "Provide a source directory or file in workspace_dir." >&2
82+
exit 1
83+
fi
84+
if [ "$target" = "." ] && [ -z "$(ls -A "$workspace" 2>/dev/null)" ]; then
85+
echo "Generating demo Python source in ${workspace}"
86+
cat > "${workspace}/demo.py" <<'PYEOF'
87+
import os
88+
import subprocess
89+
90+
91+
def read_config(path):
92+
with open(path) as f:
93+
return f.read()
94+
95+
96+
def run_command(cmd):
97+
# intentional anti-pattern for demo purposes
98+
return subprocess.run(cmd, shell=True, capture_output=True, text=True)
99+
100+
101+
def process_input(user_input):
102+
result = run_command(f"echo {user_input}")
103+
return result.stdout
104+
105+
106+
def main():
107+
config = read_config("/etc/demo.conf") if os.path.exists("/etc/demo.conf") else ""
108+
output = process_input("hello world")
109+
print(config, output)
110+
111+
112+
if __name__ == "__main__":
113+
main()
114+
PYEOF
115+
target="demo.py"
116+
fi
117+
CONTAINER_WORKSPACE="$workspace" \
118+
LOG_DIR="${__root}/logs" \
119+
python -m seclab_taskflow_agent \
120+
-t seclab_taskflows.taskflows.container_shell.demo_sast \
121+
-g target="$target"
122+
;;
123+
*)
124+
echo "Unknown demo: $demo. Choose base, malware, network, or sast." >&2
125+
exit 1
126+
;;
127+
esac
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
FROM debian:bookworm-slim
5+
RUN apt-get update && apt-get install -y --no-install-recommends \
6+
bash coreutils python3 python3-pip curl wget git ca-certificates \
7+
file binutils xxd \
8+
&& rm -rf /var/lib/apt/lists/*
9+
WORKDIR /workspace
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
FROM seclab-shell-base:latest
5+
RUN apt-get update && apt-get install -y --no-install-recommends \
6+
binwalk yara libimage-exiftool-perl checksec \
7+
&& rm -rf /var/lib/apt/lists/*
8+
# radare2 is not in Debian bookworm apt; install prebuilt deb from GitHub releases
9+
RUN ARCH=$(dpkg --print-architecture) \
10+
&& R2_TAG=$(curl -fsSL "https://api.github.com/repos/radareorg/radare2/releases/latest" \
11+
| grep -o '"tag_name": *"[^"]*"' | grep -o '"[^"]*"$' | tr -d '"') \
12+
&& R2_VER="${R2_TAG#v}" \
13+
&& curl -fsSL "https://github.com/radareorg/radare2/releases/download/${R2_TAG}/radare2_${R2_VER}_${ARCH}.deb" \
14+
-o /tmp/r2.deb \
15+
&& apt-get install -y /tmp/r2.deb \
16+
&& rm /tmp/r2.deb
17+
RUN pip3 install --no-cache-dir --break-system-packages pwntools capstone volatility3
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
FROM seclab-shell-base:latest
5+
RUN apt-get update && apt-get install -y --no-install-recommends \
6+
nmap tcpdump tshark netcat-openbsd dnsutils curl jq httpie \
7+
&& rm -rf /var/lib/apt/lists/*
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
FROM seclab-shell-base:latest
5+
RUN apt-get update && apt-get install -y --no-install-recommends \
6+
universal-ctags global cscope ripgrep fd-find graphviz tree \
7+
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
8+
&& rm -rf /var/lib/apt/lists/*
9+
RUN pip3 install --no-cache-dir --break-system-packages semgrep pyan3
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
import atexit
5+
import logging
6+
import os
7+
import subprocess
8+
import uuid
9+
from typing import Annotated
10+
11+
from fastmcp import FastMCP
12+
from pydantic import Field
13+
from seclab_taskflow_agent.path_utils import log_file_name
14+
15+
logging.basicConfig(
16+
level=logging.DEBUG,
17+
format="%(asctime)s - %(levelname)s - %(message)s",
18+
filename=log_file_name("container_shell.log"),
19+
filemode="a",
20+
)
21+
22+
mcp = FastMCP("ContainerShell")
23+
24+
_container_name: str | None = None
25+
26+
CONTAINER_IMAGE = os.environ.get("CONTAINER_IMAGE", "")
27+
CONTAINER_WORKSPACE = os.environ.get("CONTAINER_WORKSPACE", "")
28+
CONTAINER_TIMEOUT = int(os.environ.get("CONTAINER_TIMEOUT", "30"))
29+
30+
_DEFAULT_WORKDIR = "/workspace"
31+
32+
33+
def _start_container() -> str:
34+
"""Start the Docker container and return its name."""
35+
if not CONTAINER_IMAGE:
36+
msg = "CONTAINER_IMAGE is not set — cannot start container"
37+
raise RuntimeError(msg)
38+
if CONTAINER_WORKSPACE and ":" in CONTAINER_WORKSPACE:
39+
msg = f"CONTAINER_WORKSPACE must not contain a colon: {CONTAINER_WORKSPACE!r}"
40+
raise RuntimeError(msg)
41+
name = f"seclab-shell-{uuid.uuid4().hex[:8]}"
42+
cmd = ["docker", "run", "-d", "--rm", "--name", name]
43+
if CONTAINER_WORKSPACE:
44+
cmd += ["-v", f"{CONTAINER_WORKSPACE}:/workspace"]
45+
cmd += [CONTAINER_IMAGE, "tail", "-f", "/dev/null"]
46+
logging.debug(f"Starting container: {' '.join(cmd)}")
47+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
48+
if result.returncode != 0:
49+
msg = f"docker run failed: {result.stderr.strip()}"
50+
raise RuntimeError(msg)
51+
logging.debug(f"Container started: {name}")
52+
return name
53+
54+
55+
def _stop_container() -> None:
56+
"""Stop the running container."""
57+
global _container_name
58+
if _container_name is None:
59+
return
60+
logging.debug(f"Stopping container: {_container_name}")
61+
result = subprocess.run(
62+
["docker", "stop", "--time", "5", _container_name],
63+
capture_output=True,
64+
text=True,
65+
)
66+
if result.returncode != 0:
67+
logging.warning(
68+
"docker stop failed for container %s: %s",
69+
_container_name,
70+
result.stderr.strip(),
71+
)
72+
_container_name = None
73+
74+
75+
atexit.register(_stop_container)
76+
77+
78+
@mcp.tool()
79+
def shell_exec(
80+
command: Annotated[str, Field(description="Shell command to execute inside the container")],
81+
timeout: Annotated[int, Field(description="Timeout in seconds")] = CONTAINER_TIMEOUT,
82+
workdir: Annotated[str, Field(description="Working directory inside the container")] = _DEFAULT_WORKDIR,
83+
) -> str:
84+
"""Execute a shell command inside the managed Docker container."""
85+
global _container_name
86+
if _container_name is None:
87+
try:
88+
_container_name = _start_container()
89+
except RuntimeError as e:
90+
return f"Failed to start container: {e}"
91+
92+
cmd = ["docker", "exec", "-w", workdir, _container_name, "bash", "-c", command]
93+
logging.debug(f"Executing: {' '.join(cmd)}")
94+
try:
95+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
96+
except subprocess.TimeoutExpired:
97+
return f"[exit code: timeout after {timeout}s]"
98+
99+
output = result.stdout
100+
if result.stderr:
101+
output += result.stderr
102+
output += f"[exit code: {result.returncode}]"
103+
return output
104+
105+
106+
if __name__ == "__main__":
107+
mcp.run(show_banner=False)

0 commit comments

Comments
 (0)