devylbr/puppetlab
GitHub: devylbr/puppetlab
一个基于 Go 的恶意软件分析沙箱后端,通过在隔离的 KVM/QEMU 虚拟机中引爆可疑样本并采集行为数据,自动生成评分分析报告。
Stars: 0 | Forks: 0
# PuppetLab 🦠
一个极简但功能完备的 **恶意软件分析沙箱** 后端,使用 Go 编写。你
提交一个文件,它会由 worker 进行“引爆”,然后你会获得一份评分过的
行为报告(进程、网络、文件改动、判定结果)。
- **apiserver** — REST API:上传样本、创建/查看任务、获取报告。
- **worker** — 从 PostgreSQL 的 `SKIP LOCKED` 队列中拉取任务,通过可插拔的执行器进行**引爆**,
对行为进行评分,并生成 JSON 报告。
- **PostgreSQL** — 存储样本、任务队列和报告。
提供两种引爆驱动,通过 `PUPPETLAB_EXECUTOR` 选择:
| 驱动 | 作用 | 构建 |
|--------|--------------|-------|
| `sim` (默认) | 纯 Go 模拟引爆。可在任何地方运行,无需 Hypervisor。 | `go build` |
| `libvirt` | 通过 libvirt + QEMU guest agent 进行**真实的 KVM/QEMU** 引爆。 | `go build -tags libvirt` |
目前尚无前端。
## 目录
- [状态](#status)
- [工作原理](#how-it-works)
- [项目结构](#project-layout)
- [前置条件](#prerequisites)
- [快速开始 (Docker)](#quick-start-docker)
- [本地运行 (无 Docker)](#run-locally-no-docker)
- [配置](#configuration)
- [API](#api)
- [任务队列 (SKIP LOCKED)](#the-job-queue-skip-locked)
- [真实 VM 执行 (libvirt)](#real-vm-execution-libvirt)
- [测试](#testing)
- [命令参考](#command-reference)
## 状态
| 领域 | 状态 |
|------|-------|
| API (`/samples`, `/tasks`, `/tasks/{id}`, `/tasks/{id}/report`) | ✅ 正常工作,已通过端到端验证 |
| Postgres `SKIP LOCKED` 队列 + 租约 + 清理线程 | ✅ 正常工作,已通过并发 worker 验证 |
| `sim` 引爆驱动 | ✅ 正常工作,已通过端到端验证 |
| 行为评分 + 解析器 (`ps`/`ss`/`find`,guest-agent 协议) | ✅ 已进行单元测试 |
| `libvirt` 驱动 (编排) | ✅ 通过模拟的 guest agent 进行了单元测试 |
| `libvirt` 驱动 (针对真实 VM) | ⚠️ 需要 KVM 宿主机和客户机才能编译 (`-tags libvirt`, cgo) 并运行 — 参见 [真实 VM 执行](#real-vm-execution-libvirt) |
## 工作原理
```
client ──POST /samples──▶ apiserver ──row──▶ Postgres(tasks: queued)
client ──POST /tasks────▶ apiserver ▲ │
│ claim (FOR UPDATE SKIP LOCKED)
│ ▼
worker ◀─────────┘ detonate sample
│ (sim OR libvirt/KVM)
▼
Observations {processes, network, files}
│ score -> verdict
▼
Postgres(tasks: completed, report JSONB)
client ──GET /tasks/{id}/report─▶ apiserver ──▶ report
```
worker 每次领取一个任务,获取样本字节,将其引爆,
将原始的**观测数据 (Observations)** 转化为评分过的**报告 (Report)**,并进行存储。`sim`
和 `libvirt` 驱动可以在相同的 `Executor` 接口下互换使用。
## 项目结构
```
puppetlab/
├── cmd/
│ ├── apiserver/main.go # HTTP API entrypoint
│ └── worker/main.go # queue worker entrypoint
├── internal/
│ ├── api/ # HTTP server + handlers
│ │ ├── server.go
│ │ └── handlers.go
│ ├── config/config.go # env config
│ ├── db/ # pgx pool + embedded migration
│ │ ├── db.go
│ │ └── schema.sql # embedded & applied on startup
│ ├── models/models.go # Sample, Task, Report types
│ ├── store/store.go # all SQL incl. the SKIP LOCKED claim
│ ├── sandbox/ # detonation executors
│ │ ├── executor.go # Executor interface + driver factory
│ │ ├── sim.go # simulated driver (default)
│ │ ├── detonate.go # driver-agnostic orchestration (unit-tested)
│ │ ├── qga.go # QEMU guest-agent protocol + output parsers
│ │ ├── score.go # behavior -> verdict scoring
│ │ ├── libvirt.go # real KVM/QEMU driver (//go:build libvirt)
│ │ ├── qga_test.go # parser + scoring tests
│ │ └── detonate_test.go # full detonation flow vs a fake guest agent
│ └── worker/worker.go # poll loop: fetch sample -> detonate -> report
├── migrations/001_init.sql # standalone copy of the schema
├── Dockerfile # default image (sim driver), builds both binaries
├── Dockerfile.libvirt # worker image with the real libvirt driver
├── docker-compose.yml # postgres + apiserver + worker (sim)
└── go.mod / go.sum
```
## 前置条件
- **Docker + Docker Compose** — 最简单的方式(自带 Postgres)。
- 或者 **Go 1.23+** 以及一个可连通的 **PostgreSQL 13+** 以便在本地运行。
- 对于 `libvirt` 驱动:需要一台装有 `libvirtd`、`libvirt-dev`
(cgo) 的 Linux **KVM 宿主机**,以及一台准备好的客户机 VM。完整设置请参阅 [真实 VM 执行](#real-vm-execution-libvirt)。
## 快速开始 (Docker)
```
docker compose up --build
```
启动 PostgreSQL、运行在 `:8080` 的 API,以及一个 `sim` worker。扩容 worker 以
查看 `SKIP LOCKED` 队列如何分散负载(同一任务不会被运行两次):
```
docker compose up --build --scale worker=3
```
停止:
```
docker compose down # keep the database volume
docker compose down -v # wipe the database too
```
## 本地运行 (无 Docker)
需要 Go 1.23+ 以及一个你可以访问的 PostgreSQL。
```
createdb puppetlab # or point DATABASE_URL at any existing db
export DATABASE_URL="postgres://puppet:puppet@localhost:5432/puppetlab?sslmode=disable"
go run ./cmd/apiserver # terminal 1
go run ./cmd/worker # terminal 2 (run several for concurrency)
```
schema 会在启动时自动创建(内嵌的 `schema.sql`,在
advisory lock 下应用,因此 apiserver 和 worker 可以同时运行它)。如果想手动应用,请执行:`psql "$DATABASE_URL" -f migrations/001_init.sql`。
## 配置
所有配置均通过环境变量进行。
| 变量 | 默认值 | 使用者 | 描述 |
|----------|---------|---------|-------------|
| `DATABASE_URL` | `postgres://puppet:puppet@localhost:5432/puppetlab?sslmode=disable` | 两者均有 | Postgres 连接字符串 |
| `PORT` | `8080` | apiserver | HTTP 监听端口 |
| `PUPPETLAB_EXECUTOR` | `sim` | worker | `sim` 或 `libvirt` |
| `LIBVIRT_URI` | `qemu:///system` | worker (libvirt) | libvirt 连接 URI |
| `VM_DOMAIN` | — | worker (libvirt) | libvirt domain (VM) 名称 |
| `VM_SNAPSHOT` | — | worker (libvirt) | 每次运行前要恢复到的快照 |
| `GUEST_SAMPLE_PATH` | `/tmp/sample` | worker (libvirt) | 样本写入客户机的路径 |
| `WATCH_DIRS` | `/tmp,/root,/home` | worker (libvirt) | 为检测文件改动而进行差异对比的客户机目录 |
| `RUN_TIMEOUT_SECONDS` | `60` | worker (libvirt) | 每次引爆的最大墙上时间 |
## API
| 方法 | 路径 | 描述 |
|--------|------|-------------|
| `POST` | `/samples` | 上传样本(multipart `file`,或原始请求体)。返回 SHA-256。 |
| `POST` | `/tasks` | 为已存储的样本创建分析任务。返回 `task_id`。 |
| `GET` | `/tasks/{id}` | 任务状态:`queued` / `running` / `completed` / `failed`。 |
| `GET` | `/tasks/{id}/report` | JSON 报告(完成前返回 `409`)。 |
| `GET` | `/healthz` | 存活检测。 |
### 端到端示例
```
# 1) 上传样本(原始请求体;-F file=@path 也可行)
SHA=$(curl -s -X POST "localhost:8080/samples?filename=test.sh" \
--data-binary "echo hello" \
| sed -n 's/.*"sha256":"\([a-f0-9]*\)".*/\1/p')
# 2) 创建分析任务
TID=$(curl -s -X POST localhost:8080/tasks \
-H 'Content-Type: application/json' \
-d "{\"sample_sha256\":\"$SHA\"}" \
| sed -n 's/.*"task_id":"\([a-f0-9-]*\)".*/\1/p')
# 3) 轮询状态(使用 sim 时,状态从 queued -> running -> completed 大约需要 ~5-10s)
curl -s localhost:8080/tasks/$TID
# 4) 完成后获取报告
curl -s localhost:8080/tasks/$TID/report | python3 -m json.tool
```
### 报告结构
```
{
"task_id": "fcfc913e-a353-478c-aa7f-15d30b1ffa2d",
"verdict": "malicious",
"score": 100,
"processes": [
{ "pid": 1024, "ppid": 512, "name": "sample", "command_line": "/tmp/sample" },
{ "pid": 1101, "ppid": 1024, "name": "powershell", "command_line": "powershell -nop -w hidden -enc SQBFAFgA" }
],
"network": [
{ "protocol": "UDP", "dest_ip": "8.8.8.8", "dest_port": 53, "domain": "malware-c2.example", "bytes": 88 },
{ "protocol": "TCP", "dest_ip": "45.137.21.9", "dest_port": 4444, "bytes": 920 }
],
"files": [
{ "operation": "create", "path": "/etc/cron.d/persist" },
{ "operation": "encrypt", "path": "/home/victim/photos/*.jpg -> *.locked" }
]
}
```
当 `score >= 50` 时 `verdict` 为 `malicious`,否则为 `safe`。分数是
**根据行为得出的** —— 混淆的 PowerShell、C2 端口 (4444/1337/…)、
持久化位置 (cron、启动项、Run 键)、勒索软件式的文件操作等。
使用 `sim` 时,观测数据是合成的;使用 `libvirt` 时,它们来自
真实的 VM。无论哪种方式,评分标准都是一样的。
## 任务队列 (SKIP LOCKED)
worker 使用一条原子语句领取任务,因此多个 worker 可以共享一个队列,
而绝不会争抢同一行记录:
```
UPDATE tasks SET status='running', worker_id=$1, attempts=attempts+1,
lease_expires_at = now() + make_interval(secs => $2)
WHERE id = (
SELECT id FROM tasks WHERE status='queued'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, sample_sha256;
```
每次领取都会设置一个**租约**。如果 worker 在任务执行期间宕机,任何 worker 的清理线程
都会在租约到期后将任务重新入队(达到 `max_attempts` 后,则标记为 `failed`)。
## 真实 VM 执行 (libvirt)
`libvirt` 驱动会在真实的 KVM/QEMU 客户机中引爆样本,并通过
**QEMU guest agent** —— 一个样本网络无法察觉的带外 virtio 通道 —— 收集行为数据。针对每个任务,它会:
1. 将 domain 恢复到干净的**快照**并确保其正在运行;
2. 等待 `qemu-guest-agent` 响应;
3. 对受监控的目录进行快照(mtime/size)— *执行前*;
4. 将样本复制到客户机中(`guest-file-*`)并执行 `chmod +x`;
5. 启动它(`guest-exec`),在其运行期间轮询 `ss` 以获取出站连接;
6. 捕获 `ps` 并再次对受监控的目录进行快照 — *执行后*;
7. 返回进程 + 网络 + 文件差异 → 评分并生成同样的报告。
以下设置适用于 **Ubuntu 宿主机**。名称与驱动预期的
值(`puppetlab-guest`、快照 `clean`、`qemu:///system`)相匹配。
### 宿主机设置 (KVM/libvirt)
```
sudo apt update
sudo apt install -y qemu-kvm libvirt-daemon-system libvirt-clients \
virtinst libvirt-dev pkg-config cloud-image-utils genisoimage osinfo-db-tools
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt,kvm "$USER" # then LOG OUT and back in
export LIBVIRT_DEFAULT_URI=qemu:///system # so virsh targets system libvirt
# verify
kvm-ok # "KVM acceleration can be used"
virsh version
virsh net-list --all # 'default' present; if inactive:
virsh net-start default && virsh net-autostart default
```
### 创建客户机 VM
```
cd /tmp
wget -O noble.img https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
sudo cp noble.img /var/lib/libvirt/images/puppetlab-guest.qcow2 # use the default pool (avoids $HOME perms)
sudo qemu-img resize /var/lib/libvirt/images/puppetlab-guest.qcow2 20G
# cloud-init:设置密码,安装并启用 guest agent
cat > /tmp/user-data <<'EOF'
#cloud-config
password: puppet
chpasswd: { expire: false }
ssh_pwauth: true
packages: [qemu-guest-agent]
runcmd: [[systemctl, enable, --now, qemu-guest-agent]]
EOF
cat > /tmp/meta-data <<'EOF'
instance-id: puppetlab-guest
local-hostname: puppetlab-guest
EOF
cloud-localds /tmp/seed.iso /tmp/user-data /tmp/meta-data
sudo mv /tmp/seed.iso /var/lib/libvirt/images/seed.iso
# 创建并启动 VM(首次启动需在 NAT 上进行,以便 agent 能够安装)
virt-install --connect qemu:///system --name puppetlab-guest \
--memory 2048 --vcpus 2 \
--disk path=/var/lib/libvirt/images/puppetlab-guest.qcow2,format=qcow2,bus=virtio \
--disk path=/var/lib/libvirt/images/seed.iso,device=cdrom \
--os-variant ubuntu24.04 --network network=default,model=virtio \
--channel unix,target_type=virtio,name=org.qemu.guest_agent.0 \
--graphics none --noautoconsole --import
# 等待直到 agent 响应(首次启动时 cloud-init 大约需要 ~1-3 分钟)
virsh qemu-agent-command puppetlab-guest '{"execute":"guest-ping"}' # -> {"return":{}}
```
### 为干净的初始状态创建快照
首先分离种子 CD-ROM(内存快照无法包含 cdrom),然后
对**运行中**的状态进行快照,以便恢复时是热状态(agent 已启动):
```
virsh domblklist puppetlab-guest # note the cdrom target (e.g. sda)
virsh shutdown puppetlab-guest # wait for 'shut off' (virsh domstate ...)
virsh detach-disk puppetlab-guest sda --config
virsh start puppetlab-guest
until virsh qemu-agent-command puppetlab-guest '{"execute":"guest-ping"}' 2>/dev/null; do sleep 2; done
virsh snapshot-create-as puppetlab-guest --name clean --description "clean baseline" --atomic
# verify
virsh snapshot-list puppetlab-guest # 'clean' listed, state 'running'
virsh snapshot-revert puppetlab-guest --snapshot clean
```
### 构建并运行 libvirt worker
```
go build -tags libvirt -o bin/worker ./cmd/worker # needs libvirt-dev + cgo
export PUPPETLAB_EXECUTOR=libvirt LIBVIRT_URI=qemu:///system
export VM_DOMAIN=puppetlab-guest VM_SNAPSHOT=clean
export GUEST_SAMPLE_PATH=/tmp/sample WATCH_DIRS=/tmp,/root,/home RUN_TIMEOUT_SECONDS=60
export DATABASE_URL="postgres://puppet:puppet@localhost:5432/puppetlab?sslmode=disable"
./bin/worker # log: worker: using "libvirt" executor
```
或者作为容器运行(挂载宿主机的 libvirt socket):
```
docker build -f Dockerfile.libvirt -t puppetlab-worker-libvirt .
docker run --rm \
-e DATABASE_URL=... -e VM_DOMAIN=puppetlab-guest -e VM_SNAPSHOT=clean \
-v /var/run/libvirt:/var/run/libvirt \
puppetlab-worker-libvirt
```
`sim` 和 `libvirt` worker 可以互换,并且可以针对
同一个队列并行运行。通过相同的 [API](#api) 提交样本 —— 它们现在将在
真实的客户机中引爆。
### 首次引爆测试(无害 — 非恶意软件)
这个“样本”是一个良性脚本(驱动程序会对它执行 `chmod +x` 并运行
`/bin/sh -c '/tmp/sample'`):
```
cat > /tmp/harmless.sh <<'EOF'
#!/bin/sh
echo "puppetlab harmless test"
touch /tmp/puppetlab_was_here # -> file "create"
id > /tmp/puppetlab_id.txt # -> file "create"
sh -c 'sleep 3' & # -> child process
(exec 3<>/dev/tcp/192.168.122.1/53; sleep 4) 2>/dev/null || true # -> network entry
EOF
SHA=$(curl -s -X POST "localhost:8080/samples?filename=harmless.sh" --data-binary @/tmp/harmless.sh | sed -n 's/.*"sha256":"\([a-f0-9]*\)".*/\1/p')
TID=$(curl -s -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d "{\"sample_sha256\":\"$SHA\"}" | sed -n 's/.*"task_id":"\([a-f0-9-]*\)".*/\1/p')
curl -s localhost:8080/tasks/$TID/report | python3 -m json.tool
```
**预期结果:** 状态为 `queued → running → completed`;判定结果 `safe`(低分);
`files` 包含了那两个 `/tmp/puppetlab_*` 的创建记录;`processes` 显示
`sample`/`sh`。**运行两次** —— 第二次运行必定不会保留
`/tmp/puppetlab_was_here`,从而证明在两次引爆之间执行了快照恢复。
### 隔离网络(在测试真实恶意软件之前)
```
cat > /tmp/isolated.xml <<'EOF'
puppetlab-isolated
EOF
virsh net-define /tmp/isolated.xml
virsh net-start puppetlab-isolated && virsh net-autostart puppetlab-isolated
virsh shutdown puppetlab-guest
virsh edit puppetlab-guest # change -> 'puppetlab-isolated'
virsh start puppetlab-guest
until virsh qemu-agent-command puppetlab-guest '{"execute":"guest-ping"}' 2>/dev/null; do sleep 2; done
virsh snapshot-delete puppetlab-guest clean
virsh snapshot-create-as puppetlab-guest --name clean --atomic # re-baseline on the isolated net
```
无论网络如何,guest agent 都通过 virtio-serial 工作,因此即使在
完全没有互联网的情况下,引爆依然可以进行。
### 调试检查清单
| 症状 | 解决方案 |
|---------|-----|
| `Could not access KVM kernel module: Permission denied` | 将用户添加到 `kvm` 组,重新登录(当前 shell 使用 `newgrp kvm`)。检查 `ls -l /dev/kvm`。 |
| `failed to connect socket to '/var/run/libvirt/libvirt-sock'` | `systemctl status libvirtd`;使用 `qemu:///system`;确保在 `libvirt` 组中(重新登录)。 |
| `Guest agent is not responding` | 通道是否存在?`virsh dumpxml puppetlab-guest \| grep guest_agent`。仍在启动中 → 等待。客户机中是否安装/运行了 agent?通过 `virsh console` 执行 `systemctl status qemu-guest-agent`。 |
| 快照创建失败 (cdrom) | 首先分离种子 cdrom:`virsh detach-disk ... --config`。 |
| `internal snapshots are not supported` | 磁盘必须是 **qcow2**(`qemu-img info `),而不是 raw。 |
| 恢复缓慢 / 恢复后 agent 死亡 | 快照是在关机状态下拍摄的 —— 请在 **运行中** 且 agent 已启动的状态下重新拍摄。 |
| `Could not open '...qcow2': Permission denied` | 将磁盘放在 `/var/lib/libvirt/images` 中;检查 `journalctl -k \| grep -i apparmor`。 |
| `go build -tags libvirt` 失败 (`pkg-config: libvirt not found`) | `sudo apt install -y libvirt-dev pkg-config`。 |
| `Unknown OS name 'ubuntu24.04'` | `osinfo-query os \| grep -i ubuntu`,使用列出的 id 或 `--os-variant generic`。 |
| Worker:`lookup domain` / `lookup snapshot` 错误 | 名称不匹配 —— 检查 `virsh list --all` / `virsh snapshot-list puppetlab-guest`。 |
## 测试
```
go build ./... # default (sim) build
go vet ./...
go test ./... # parser, scoring, and full detonation-flow tests
gofmt -l . # empty output = formatted
```
`internal/sandbox/detonate_test.go` 针对模拟的 guest agent 演练了整个引爆
编排过程(文件复制 → 执行 → `ss`/`ps`/`find` 解析 → 文件差异对比 → 判定结果),因此无需
Hypervisor 即可覆盖真实 VM 的逻辑。cgo `libvirt.go` 本身只能在安装了
`libvirt-dev` 的宿主机上编译。
## 命令参考
```
# --- 构建 / 测试 ---
go mod download
go build ./... # sim build
go build -tags libvirt -o bin/worker ./cmd/worker # real-VM worker (needs libvirt-dev)
go test ./...
go vet ./... ; gofmt -l .
# --- 运行 (Docker) ---
docker compose up --build # postgres + api + worker
docker compose up --build --scale worker=3 # more workers
docker compose logs -f worker
docker compose down [-v] # stop [+ wipe db]
# --- 运行 (本地) ---
go run ./cmd/apiserver
go run ./cmd/worker
# --- API ---
curl -s localhost:8080/healthz
curl -s -X POST "localhost:8080/samples?filename=f" --data-binary "echo hi"
curl -s -X POST localhost:8080/samples -F "file=@/path/to/file"
curl -s -X POST localhost:8080/tasks -H 'Content-Type: application/json' -d '{"sample_sha256":""}'
curl -s localhost:8080/tasks/
curl -s localhost:8080/tasks//report | python3 -m json.tool
# --- database ---
psql "$DATABASE_URL" -f migrations/001_init.sql
psql "$DATABASE_URL" -c "SELECT id,status,verdict,score FROM tasks ORDER BY created_at DESC LIMIT 10;"
docker compose exec postgres psql -U puppet -d puppetlab
# --- VM (libvirt) ---
virsh list --all
virsh start|shutdown|destroy puppetlab-guest
virsh console puppetlab-guest # Ctrl+] to exit
virsh snapshot-list puppetlab-guest
virsh snapshot-revert puppetlab-guest --snapshot clean
virsh qemu-agent-command puppetlab-guest '{"execute":"guest-ping"}'
```
标签:DAST, EVTX分析, Go, IP 地址批量处理, KVM/QEMU, PostgreSQL, Ruby工具, URL发现, 安全检测, 恶意软件分析, 日志审计, 沙箱, 测试用例, 网络安全审计, 虚拟化, 请求拦截