From ef00c176e5799aa54599bbc8c1c526f42db4bc2f Mon Sep 17 00:00:00 2001 From: j3ssie Date: Tue, 10 Feb 2026 17:25:15 +0700 Subject: [PATCH] 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) --- Makefile | 47 ++- build/docker/Dockerfile.canary | 65 ++++ build/docker/canary-entrypoint.sh | 22 ++ build/docker/docker-compose.canary.yaml | 23 ++ test/e2e/canary_test.go | 492 ++++++++++++++++++++++++ 5 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 build/docker/Dockerfile.canary create mode 100755 build/docker/canary-entrypoint.sh create mode 100644 build/docker/docker-compose.canary.yaml create mode 100644 test/e2e/canary_test.go diff --git a/Makefile b/Makefile index 6aba11a..cd6a7be 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/build/docker/Dockerfile.canary b/build/docker/Dockerfile.canary new file mode 100644 index 0000000..51c97bc --- /dev/null +++ b/build/docker/Dockerfile.canary @@ -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"] diff --git a/build/docker/canary-entrypoint.sh b/build/docker/canary-entrypoint.sh new file mode 100755 index 0000000..988e457 --- /dev/null +++ b/build/docker/canary-entrypoint.sh @@ -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} diff --git a/build/docker/docker-compose.canary.yaml b/build/docker/docker-compose.canary.yaml new file mode 100644 index 0000000..3b88502 --- /dev/null +++ b/build/docker/docker-compose.canary.yaml @@ -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: diff --git a/test/e2e/canary_test.go b/test/e2e/canary_test.go new file mode 100644 index 0000000..ac394a6 --- /dev/null +++ b/test/e2e/canary_test.go @@ -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") +}