feat: add canary tests for real-world scan workflows

- Add docker-compose.canary.yaml with multi-stage Dockerfile to build osmedeus from source and layer onto toolbox base, ensuring canary tests exercise current code not released binaries
- Implement three canary test scenarios (repo SAST scan, domain-lite reconnaissance, CIDR IP scanning) with lifecycle management (container startup/cleanup) and comprehensive assertions on filesystem artifacts and API records
- Add Makefile targets (test-canary-all, test-canary-repo, test-canary-domain, test-canary-ip, canary-up, canary-down) for granular test execution with configurable timeouts (20-60 minutes)
This commit is contained in:
j3ssie
2026-02-10 17:25:15 +07:00
parent 23ede677c4
commit ef00c176e5
5 changed files with 648 additions and 1 deletions
+46 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build run test test-unit test-integration test-workflow-integration test-e2e test-e2e-verbose test-e2e-ssh test-e2e-api test-e2e-nix test-e2e-install test-docker test-ssh test-distributed test-all test-summary test-ci clean install install-gotestsum lint fmt db-seed db-clean db-migrate run-server-debug swagger update-ui snapshot-release github-release run-github-action docker-toolbox docker-toolbox-run docker-toolbox-shell docker-publish
.PHONY: build run test test-unit test-integration test-workflow-integration test-e2e test-e2e-verbose test-e2e-ssh test-e2e-api test-e2e-nix test-e2e-install test-docker test-ssh test-distributed test-canary-all test-canary-repo test-canary-domain test-canary-ip canary-up canary-down test-all test-summary test-ci clean install install-gotestsum lint fmt db-seed db-clean db-migrate run-server-debug swagger update-ui snapshot-release github-release run-github-action docker-toolbox docker-toolbox-run docker-toolbox-shell docker-publish
# Go parameters
GOCMD=go
@@ -191,6 +191,47 @@ test-e2e-install: build install-gotestsum
@echo "$(PREFIX) Running install E2E tests..."
$(TESTCMD) $(TESTFLAGS) -run TestInstall ./test/e2e/...
# ── Canary tests (real scans inside Docker toolbox, requires Docker) ──────────
# Build and start the canary container (shared setup for individual targets)
canary-up: install-gotestsum
@echo "$(PREFIX) Building canary Docker image..."
docker-compose -f build/docker/docker-compose.canary.yaml build
@echo "$(PREFIX) Starting canary container..."
docker-compose -f build/docker/docker-compose.canary.yaml up -d
@echo "$(PREFIX) Waiting for API server..."
@for i in $$(seq 1 60); do curl -sf http://localhost:8002/health > /dev/null 2>&1 && break || sleep 2; done
@echo "$(PREFIX) Canary container ready."
# Tear down the canary container
canary-down:
@echo "$(PREFIX) Cleaning up canary container..."
docker-compose -f build/docker/docker-compose.canary.yaml down -v
# Run ALL canary scans (builds container, runs all 3, cleans up — 30-60min)
test-canary-all: canary-up
@echo "$(PREFIX) Running all canary tests (30-60 minutes)..."
$(TESTCMD) $(TESTFLAGS) -run TestCanary_FullSuite -timeout 60m ./test/e2e/... || ($(MAKE) canary-down && exit 1)
@$(MAKE) canary-down
# Repo scan canary (juice-shop SAST, ~25min)
test-canary-repo: canary-up
@echo "$(PREFIX) Running repo scan canary test..."
$(TESTCMD) $(TESTFLAGS) -run TestCanary_Repo -timeout 30m ./test/e2e/... || ($(MAKE) canary-down && exit 1)
@$(MAKE) canary-down
# Domain-lite scan canary (hackerone.com, ~20min)
test-canary-domain: canary-up
@echo "$(PREFIX) Running domain-lite scan canary test..."
$(TESTCMD) $(TESTFLAGS) -run TestCanary_Domain -timeout 25m ./test/e2e/... || ($(MAKE) canary-down && exit 1)
@$(MAKE) canary-down
# CIDR scan canary (IP list, ~25min)
test-canary-ip: canary-up
@echo "$(PREFIX) Running CIDR scan canary test..."
$(TESTCMD) $(TESTFLAGS) -run TestCanary_CIDR -timeout 30m ./test/e2e/... || ($(MAKE) canary-down && exit 1)
@$(MAKE) canary-down
# All tests
test-all: test-unit test-integration
@@ -369,6 +410,10 @@ help:
@echo " make test-docker Run Docker runner tests"
@echo " make test-ssh Run SSH runner unit tests"
@echo " make test-distributed Run distributed scan E2E tests (requires Redis)"
@echo " make test-canary-all Run all canary tests (real scans in Docker, 30-60min)"
@echo " make test-canary-repo Run repo scan canary (juice-shop SAST, ~25min)"
@echo " make test-canary-domain Run domain-lite canary (hackerone.com, ~20min)"
@echo " make test-canary-ip Run CIDR scan canary (IP list, ~25min)"
@echo " make test-coverage Run tests with coverage report"
@echo " make test-summary Quick pass/fail summary (dots format)"
@echo " make test-ci Run tests with JUnit XML output"
+65
View File
@@ -0,0 +1,65 @@
# Osmedeus Canary Test Dockerfile
# Multi-stage build that compiles from source and layers onto the toolbox image,
# ensuring canary tests exercise the CURRENT source code, not the last release.
# ── Stage 1: build from source ──────────────────────────────────────────────
FROM golang:1.25-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /osmedeus ./cmd/osmedeus
# ── Stage 2: runtime (mirrors Dockerfile.toolbox) ───────────────────────────
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
wget \
git \
unzip \
jq \
bash \
xz-utils \
python3 \
python3-pip \
python3-venv \
chromium-browser \
sudo \
golang-go \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN ln -sf /usr/bin/python3 /usr/bin/python
ENV PATH="/root/go/bin:${PATH}"
ENV GOPATH="/root/go"
RUN mkdir -p /root/osmedeus-base /root/workspaces-osmedeus /root/go/bin
WORKDIR /root
# Install released osmedeus (pulls workflows, tools, etc.)
RUN curl -fsSLk https://www.osmedeus.org/install.sh | bash
ENV PATH="/root/.local/bin:/root/go/bin:/root/osmedeus-base/external-binaries:${PATH}"
# Overwrite the released binary with the one built from source
COPY --from=builder /osmedeus /root/.local/bin/osmedeus
COPY --from=builder /osmedeus /root/go/bin/osmedeus
# Copy the canary entrypoint
COPY build/docker/canary-entrypoint.sh /usr/local/bin/canary-entrypoint.sh
RUN chmod +x /usr/local/bin/canary-entrypoint.sh
# Verify the source-built binary works
RUN osmedeus health
EXPOSE 8002
ENTRYPOINT ["/usr/local/bin/canary-entrypoint.sh"]
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Canary test entrypoint: starts API server in background and keeps container alive.
# Tests interact via:
# docker exec osm-canary osmedeus run ...
# curl http://localhost:8002/osm/api/...
set -euo pipefail
echo "[canary] Starting osmedeus API server on :8002 ..."
osmedeus serve --port 8002 --host 0.0.0.0 -A &
SERVER_PID=$!
# Give the server a moment to bind
sleep 2
echo "[canary] API server started (PID ${SERVER_PID})"
echo "[canary] Container is ready — waiting for test commands."
# Keep the container alive; forward SIGTERM to the server
trap "kill ${SERVER_PID} 2>/dev/null; exit 0" SIGTERM SIGINT
wait ${SERVER_PID}
+23
View File
@@ -0,0 +1,23 @@
services:
osm-canary:
build:
context: ../..
dockerfile: build/docker/Dockerfile.canary
container_name: osm-canary
hostname: osm-canary
volumes:
- canary-workspaces:/root/workspaces-osmedeus
- canary-data:/root/osmedeus-base
ports:
- "8002:8002"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8002/health"]
interval: 10s
timeout: 5s
start_period: 30s
retries: 5
restart: "no"
volumes:
canary-workspaces:
canary-data:
+492
View File
@@ -0,0 +1,492 @@
package e2e
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ── constants ────────────────────────────────────────────────────────────────
const (
canaryContainerName = "osm-canary"
canaryAPIBase = "http://localhost:8002"
canaryWorkspaceRoot = "/root/workspaces-osmedeus"
)
// ── compose helpers ──────────────────────────────────────────────────────────
func getCanaryComposePath(t *testing.T) string {
t.Helper()
return filepath.Join(getProjectRoot(t), "build", "docker", "docker-compose.canary.yaml")
}
// startCanaryContainer builds the canary image and starts the container.
// It returns a cleanup function that tears everything down.
func startCanaryContainer(t *testing.T, log *TestLogger) func() {
t.Helper()
composePath := getCanaryComposePath(t)
log.Step("Building canary Docker image (this may take a while)")
cmd := exec.Command("docker-compose", "-f", composePath, "build")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Failed to build canary image: %v\nOutput: %s", err, output)
}
log.Success("Canary image built")
log.Step("Starting canary container")
cmd = exec.Command("docker-compose", "-f", composePath, "up", "-d")
output, err = cmd.CombinedOutput()
if err != nil {
t.Fatalf("Failed to start canary container: %v\nOutput: %s", err, output)
}
log.Success("Canary container started")
return func() {
log.Info("Tearing down canary container")
cmd := exec.Command("docker-compose", "-f", composePath, "down", "-v")
_ = cmd.Run()
}
}
// waitForCanaryAPI polls the health endpoint until the API is ready.
func waitForCanaryAPI(t *testing.T, log *TestLogger, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
url := canaryAPIBase + "/health"
log.Info("Waiting for canary API at %s ...", url)
for time.Now().Before(deadline) {
resp, err := http.Get(url)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == 200 {
log.Success("Canary API is ready")
return
}
}
time.Sleep(2 * time.Second)
}
t.Fatalf("Canary API not ready after %v", timeout)
}
// ── docker exec helpers ──────────────────────────────────────────────────────
// dockerExec runs a command inside the canary container with a 2-minute timeout.
func dockerExec(t *testing.T, log *TestLogger, args ...string) (string, error) {
t.Helper()
return dockerExecLong(t, log, 2*time.Minute, args...)
}
// dockerExecLong runs a command inside the canary container with a custom timeout.
func dockerExecLong(t *testing.T, log *TestLogger, timeout time.Duration, args ...string) (string, error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmdArgs := append([]string{"exec", canaryContainerName}, args...)
log.Debug("docker %s", strings.Join(cmdArgs, " "))
cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
output, err := cmd.CombinedOutput()
out := string(output)
if len(out) > 500 {
log.Debug("output (%d bytes): %s...", len(out), out[:500])
} else if out != "" {
log.Debug("output: %s", strings.TrimSpace(out))
}
return out, err
}
// ── filesystem checks inside container ───────────────────────────────────────
func fileExistsInContainer(t *testing.T, log *TestLogger, path string) bool {
t.Helper()
_, err := dockerExec(t, log, "test", "-f", path)
return err == nil
}
func dirExistsInContainer(t *testing.T, log *TestLogger, path string) bool {
t.Helper()
_, err := dockerExec(t, log, "test", "-d", path)
return err == nil
}
// findFilesInContainer returns files matching a pattern under dir.
func findFilesInContainer(t *testing.T, log *TestLogger, dir, pattern string) []string {
t.Helper()
out, err := dockerExec(t, log, "find", dir, "-name", pattern, "-type", "f")
if err != nil || strings.TrimSpace(out) == "" {
return nil
}
var files []string
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line != "" {
files = append(files, line)
}
}
return files
}
// ── API helpers ──────────────────────────────────────────────────────────────
// canaryAPIGet performs a GET request against the canary API and parses JSON.
func canaryAPIGet(t *testing.T, path string) map[string]any {
t.Helper()
resp, err := http.Get(canaryAPIBase + path)
require.NoError(t, err, "GET %s failed", path)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err, "failed to read response for %s", path)
var result map[string]any
err = json.Unmarshal(body, &result)
require.NoError(t, err, "failed to parse JSON from %s: %s", path, string(body))
return result
}
// getRunsForWorkspace returns runs filtered by workspace name.
func getRunsForWorkspace(t *testing.T, ws string) []any {
t.Helper()
result := canaryAPIGet(t, "/osm/api/runs?workspace="+ws)
data, _ := result["data"].([]any)
return data
}
// getAssetsForWorkspace returns assets filtered by workspace name.
func getAssetsForWorkspace(t *testing.T, ws string) []any {
t.Helper()
result := canaryAPIGet(t, "/osm/api/assets?workspace="+ws)
data, _ := result["data"].([]any)
return data
}
// getVulnsForWorkspace returns vulnerabilities filtered by workspace name.
func getVulnsForWorkspace(t *testing.T, ws string) []any {
t.Helper()
result := canaryAPIGet(t, "/osm/api/vulnerabilities?workspace="+ws)
data, _ := result["data"].([]any)
return data
}
// getWorkspaces returns all workspace records.
func getWorkspaces(t *testing.T) []map[string]any {
t.Helper()
result := canaryAPIGet(t, "/osm/api/workspaces")
raw, _ := result["data"].([]any)
var out []map[string]any
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// findWorkspaceByName searches all workspaces for one matching name (exact or substring).
func findWorkspaceByName(t *testing.T, name string) map[string]any {
t.Helper()
for _, ws := range getWorkspaces(t) {
wsName, _ := ws["name"].(string)
if wsName == name || strings.Contains(wsName, name) {
return ws
}
}
return nil
}
// ── main test entry points ───────────────────────────────────────────────────
// requireCanaryAPI checks that the canary container API is reachable.
// Individual tests (TestCanary_Repo, etc.) assume the container was started
// externally by the Makefile, so they only need to verify the API is up.
func requireCanaryAPI(t *testing.T, log *TestLogger) {
t.Helper()
waitForCanaryAPI(t, log, 60*time.Second)
}
// TestCanary_FullSuite manages the full lifecycle: build, start, run all 3 scans, cleanup.
// Use `make test-canary-all` to run this.
func TestCanary_FullSuite(t *testing.T) {
if testing.Short() {
t.Skip("skipping canary tests in short mode")
}
log := NewTestLogger(t)
log.Step("Starting canary test suite")
// Start the canary container (build + up)
cleanup := startCanaryContainer(t, log)
defer cleanup()
// Wait for the API server inside the container to be ready
waitForCanaryAPI(t, log, 90*time.Second)
// Run the three canary scans sequentially
t.Run("RepoScan", func(t *testing.T) {
testCanaryRepoScan(t)
})
t.Run("DomainLiteScan", func(t *testing.T) {
testCanaryDomainLiteScan(t)
})
t.Run("CIDRScan", func(t *testing.T) {
testCanaryCIDRScan(t)
})
log.Success("All canary tests completed")
}
// TestCanary_Repo runs only the repo scan canary test.
// Assumes the canary container is already running (e.g. via `make test-canary-repo`).
func TestCanary_Repo(t *testing.T) {
if testing.Short() {
t.Skip("skipping canary tests in short mode")
}
log := NewTestLogger(t)
requireCanaryAPI(t, log)
testCanaryRepoScan(t)
}
// TestCanary_Domain runs only the domain-lite scan canary test.
// Assumes the canary container is already running (e.g. via `make test-canary-domain`).
func TestCanary_Domain(t *testing.T) {
if testing.Short() {
t.Skip("skipping canary tests in short mode")
}
log := NewTestLogger(t)
requireCanaryAPI(t, log)
testCanaryDomainLiteScan(t)
}
// TestCanary_CIDR runs only the CIDR scan canary test.
// Assumes the canary container is already running (e.g. via `make test-canary-ip`).
func TestCanary_CIDR(t *testing.T) {
if testing.Short() {
t.Skip("skipping canary tests in short mode")
}
log := NewTestLogger(t)
requireCanaryAPI(t, log)
testCanaryCIDRScan(t)
}
// ── Test 1: Repo Scan ────────────────────────────────────────────────────────
func testCanaryRepoScan(t *testing.T) {
log := NewTestLogger(t)
log.Step("Canary: Repo Scan (juice-shop)")
targetURL := "https://github.com/juice-shop/juice-shop/archive/refs/heads/master.zip"
expectedWS := "github.com"
wsDir := canaryWorkspaceRoot + "/" + expectedWS
// Run the scan
log.Step("Running repo flow")
out, err := dockerExecLong(t, log, 25*time.Minute,
"osmedeus", "run", "-f", "repo", "-t", targetURL)
if err != nil {
log.Error("Repo scan command error: %v\nOutput: %s", err, out)
}
// Don't require.NoError — some steps may fail yet still produce results
// ── Filesystem checks ────────────────────────────────────────────────
log.Step("Verifying filesystem artifacts")
assert.True(t, dirExistsInContainer(t, log, wsDir),
"workspace directory %s should exist", wsDir)
sarifFiles := findFilesInContainer(t, log, wsDir, "*.sarif")
log.Info("Found %d SARIF files", len(sarifFiles))
assert.NotEmpty(t, sarifFiles, "expected SARIF output files in workspace")
mdFiles := findFilesInContainer(t, log, wsDir, "*.md")
log.Info("Found %d markdown reports", len(mdFiles))
// ── Database / API checks ────────────────────────────────────────────
log.Step("Verifying database records via API")
// Runs
runs := getRunsForWorkspace(t, expectedWS)
if len(runs) == 0 {
// Fallback: search all workspaces
ws := findWorkspaceByName(t, expectedWS)
if ws != nil {
wsName, _ := ws["name"].(string)
runs = getRunsForWorkspace(t, wsName)
log.Info("Fallback workspace name: %s", wsName)
}
}
assert.NotEmpty(t, runs, "expected at least 1 run for workspace %s", expectedWS)
if len(runs) > 0 {
firstRun, _ := runs[0].(map[string]any)
runUUID, _ := firstRun["run_uuid"].(string)
if runUUID != "" {
// Steps
stepsResp := canaryAPIGet(t, fmt.Sprintf("/osm/api/runs/%s/steps", runUUID))
stepsData, _ := stepsResp["data"].([]any)
assert.NotEmpty(t, stepsData, "expected step results for run %s", runUUID)
log.Info("Run %s has %d step results", runUUID, len(stepsData))
// Artifacts
artifactsResp := canaryAPIGet(t, fmt.Sprintf("/osm/api/runs/%s/artifacts", runUUID))
artifactsData, _ := artifactsResp["data"].([]any)
log.Info("Run %s has %d artifacts", runUUID, len(artifactsData))
}
}
// Vulnerabilities
vulns := getVulnsForWorkspace(t, expectedWS)
log.Info("Found %d vulnerabilities for workspace %s", len(vulns), expectedWS)
assert.NotEmpty(t, vulns, "juice-shop should produce vulnerability findings")
// Workspace record
ws := findWorkspaceByName(t, expectedWS)
assert.NotNil(t, ws, "workspace record should exist in DB")
if ws != nil {
totalVulns, _ := ws["total_vulns"].(float64)
log.Info("Workspace total_vulns: %.0f", totalVulns)
assert.Greater(t, totalVulns, float64(0), "workspace should have total_vulns > 0")
}
log.Success("Repo scan canary passed")
}
// ── Test 2: Domain-Lite Scan ─────────────────────────────────────────────────
func testCanaryDomainLiteScan(t *testing.T) {
log := NewTestLogger(t)
log.Step("Canary: Domain-Lite Scan (hackerone.com)")
target := "hackerone.com"
expectedWS := "hackerone.com"
wsDir := canaryWorkspaceRoot + "/" + expectedWS
// Run the scan
log.Step("Running domain-lite flow")
out, err := dockerExecLong(t, log, 20*time.Minute,
"osmedeus", "run", "-f", "domain-lite", "-t", target)
if err != nil {
log.Error("Domain-lite scan command error: %v\nOutput: %s", err, out)
}
// ── Filesystem checks ────────────────────────────────────────────────
log.Step("Verifying filesystem artifacts")
assert.True(t, dirExistsInContainer(t, log, wsDir),
"workspace directory %s should exist", wsDir)
txtFiles := findFilesInContainer(t, log, wsDir, "*.txt")
log.Info("Found %d .txt files (subdomains, etc.)", len(txtFiles))
assert.NotEmpty(t, txtFiles, "expected subdomain/output text files")
jsonlFiles := findFilesInContainer(t, log, wsDir, "*.jsonl")
log.Info("Found %d .jsonl files (fingerprints, etc.)", len(jsonlFiles))
// ── Database / API checks ────────────────────────────────────────────
log.Step("Verifying database records via API")
// Runs
runs := getRunsForWorkspace(t, expectedWS)
if len(runs) == 0 {
ws := findWorkspaceByName(t, expectedWS)
if ws != nil {
wsName, _ := ws["name"].(string)
runs = getRunsForWorkspace(t, wsName)
log.Info("Fallback workspace name: %s", wsName)
}
}
assert.NotEmpty(t, runs, "expected at least 1 run for workspace %s", expectedWS)
// Assets
assets := getAssetsForWorkspace(t, expectedWS)
log.Info("Found %d assets for workspace %s", len(assets), expectedWS)
assert.NotEmpty(t, assets, "expected discovered subdomains/HTTP endpoints")
// Workspace record
ws := findWorkspaceByName(t, expectedWS)
assert.NotNil(t, ws, "workspace record should exist in DB")
log.Success("Domain-lite scan canary passed")
}
// ── Test 3: CIDR Scan ────────────────────────────────────────────────────────
func testCanaryCIDRScan(t *testing.T) {
log := NewTestLogger(t)
log.Step("Canary: CIDR Scan (IP list)")
expectedWS := "list-of-ips-file"
wsDir := canaryWorkspaceRoot + "/" + expectedWS
// Create the IP list inside the container.
// NOTE: These are sample/public IP addresses used solely for testing the
// scan workflow pipeline (workspace creation, step execution, DB writes).
// There is no intent to attack or exploit any of these hosts.
log.Step("Creating target IP list")
ipList := strings.Join([]string{
"102.88.154.187",
"8.222.222.78",
"93.184.216.34",
"104.16.132.229",
}, "\\n")
_, err := dockerExec(t, log, "sh", "-c",
fmt.Sprintf("printf '%s\\n' > /tmp/list-of-ips.txt", ipList))
require.NoError(t, err, "failed to create IP list in container")
// Verify file was created
assert.True(t, fileExistsInContainer(t, log, "/tmp/list-of-ips.txt"),
"IP list file should exist in container")
// Run the scan
log.Step("Running cidr flow")
out, err := dockerExecLong(t, log, 25*time.Minute,
"osmedeus", "run", "-f", "cidr", "-t", "/tmp/list-of-ips.txt")
if err != nil {
log.Error("CIDR scan command error: %v\nOutput: %s", err, out)
}
// ── Filesystem checks ────────────────────────────────────────────────
log.Step("Verifying filesystem artifacts")
assert.True(t, dirExistsInContainer(t, log, wsDir),
"workspace directory %s should exist", wsDir)
outputFiles := findFilesInContainer(t, log, wsDir, "*")
log.Info("Found %d output files in workspace", len(outputFiles))
// ── Database / API checks ────────────────────────────────────────────
log.Step("Verifying database records via API")
// Runs
runs := getRunsForWorkspace(t, expectedWS)
if len(runs) == 0 {
ws := findWorkspaceByName(t, expectedWS)
if ws != nil {
wsName, _ := ws["name"].(string)
runs = getRunsForWorkspace(t, wsName)
log.Info("Fallback workspace name: %s", wsName)
}
}
assert.NotEmpty(t, runs, "expected at least 1 run for workspace %s", expectedWS)
// Workspace record
ws := findWorkspaceByName(t, expectedWS)
assert.NotNil(t, ws, "workspace record should exist in DB")
log.Success("CIDR scan canary passed")
}