From c93604daacc89c5d82fbb6ca2440057fddedafcd Mon Sep 17 00:00:00 2001 From: Adam Magued Date: Sat, 17 Oct 2026 17:20:42 +0300 Subject: [PATCH] fix: resolve issue #59 - [Feature]: Add Docker Support Signed-off-by: Adam Magued --- .dockerignore | 9 ++ .github/workflows/docker.yaml | 68 ++++++++++ Dockerfile | 32 +++++ README.md | 32 +++++ cmd/docker_test.go | 231 ++++++++++++++++++++++++++++++++++ 5 files changed, 372 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yaml create mode 100644 Dockerfile create mode 100644 cmd/docker_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..892c070 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +.gitignore +.goreleaser.yaml +.DS_Store +._* +dist/ +*.tmp +*.log diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..8997968 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,68 @@ +name: Docker + +on: + push: + branches: + - main + tags: + - 'v*' + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + docker: + name: Build and push container image + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + VERSION=${{ steps.meta.outputs.version }} + BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d9aec4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +RUN apk add --no-cache git ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +ARG BUILD_DATE=unknown + +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildDate=${BUILD_DATE}" \ + -o /bin/nomore403 . + +# Runtime stage +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates curl + +WORKDIR /nomore403 + +COPY --from=builder /bin/nomore403 /usr/local/bin/nomore403 +COPY payloads/ /nomore403/payloads/ +RUN ln -s /nomore403/payloads /payloads + +ENTRYPOINT ["nomore403"] diff --git a/README.md b/README.md index e00c4ee..11f88d3 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,38 @@ go install github.com/devploit/nomore403@latest If you install with `go install`, the `payloads/` directory is not installed automatically. Clone the repository and point the tool to that directory with `-f` if needed. +### Docker + +Pull the pre-built image from GitHub Container Registry: + +```bash +docker pull ghcr.io/devploit/nomore403:latest +``` + +Or build the image locally: + +```bash +docker build -t nomore403 . +``` + +Run against a target: + +```bash +docker run --rm ghcr.io/devploit/nomore403 -u https://target.tld/admin +``` + +Pipe targets through stdin: + +```bash +cat urls.txt | docker run --rm -i ghcr.io/devploit/nomore403 +``` + +Mount a host directory for custom request files or saving output: + +```bash +docker run --rm -v $(pwd):/data ghcr.io/devploit/nomore403 -r /data/request.txt -o /data/results.json +``` + ## Requirements - Go 1.24 or later to build from source diff --git a/cmd/docker_test.go b/cmd/docker_test.go new file mode 100644 index 0000000..7a672f4 --- /dev/null +++ b/cmd/docker_test.go @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func findProjectRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("could not locate project root containing go.mod") + } + dir = parent + } +} + +func TestDockerfileStructure(t *testing.T) { + rootDir := findProjectRoot(t) + dockerfilePath := filepath.Join(rootDir, "Dockerfile") + + content, err := os.ReadFile(dockerfilePath) + if err != nil { + t.Fatalf("failed to read Dockerfile: %v", err) + } + text := string(content) + + checks := []struct { + name string + contains string + }{ + {"builder image", "FROM golang:1.24-alpine AS builder"}, + {"runtime image", "FROM alpine:3.21"}, + {"runtime dependencies", "apk add --no-cache ca-certificates curl"}, + {"copy payloads", "COPY payloads/ /nomore403/payloads/"}, + {"payloads symlink", "RUN ln -s /nomore403/payloads /payloads"}, + {"working directory", "WORKDIR /nomore403"}, + {"entrypoint", `ENTRYPOINT ["nomore403"]`}, + } + + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if !strings.Contains(text, check.contains) { + t.Errorf("Dockerfile missing expected directive %q", check.contains) + } + }) + } +} + +func TestDockerignoreRules(t *testing.T) { + rootDir := findProjectRoot(t) + dockerignorePath := filepath.Join(rootDir, ".dockerignore") + + content, err := os.ReadFile(dockerignorePath) + if err != nil { + t.Fatalf("failed to read .dockerignore: %v", err) + } + text := string(content) + + expectedEntries := []string{ + ".git", + ".github", + ".gitignore", + "dist/", + ".DS_Store", + "._*", + } + + for _, entry := range expectedEntries { + t.Run(entry, func(t *testing.T) { + if !strings.Contains(text, entry) { + t.Errorf(".dockerignore missing expected entry %q", entry) + } + }) + } +} + +func TestDockerWorkflowConfig(t *testing.T) { + rootDir := findProjectRoot(t) + workflowPath := filepath.Join(rootDir, ".github", "workflows", "docker.yaml") + + content, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("failed to read docker.yaml workflow: %v", err) + } + + var parsed map[string]interface{} + if err := yaml.Unmarshal(content, &parsed); err != nil { + t.Fatalf("failed to parse docker.yaml as YAML: %v", err) + } + + text := string(content) + checks := []struct { + name string + expected string + }{ + {"workflow name", "name: Docker"}, + {"push main trigger", "- main"}, + {"push tags trigger", "- 'v*'"}, + {"pull request trigger", "pull_request:"}, + {"workflow dispatch trigger", "workflow_dispatch:"}, + {"packages write permission", "packages: write"}, + {"ghcr registry", "registry: ghcr.io"}, + {"multi-platform support", "platforms: linux/amd64,linux/arm64"}, + {"docker metadata action", "uses: docker/metadata-action@v5"}, + {"docker build-push action", "uses: docker/build-push-action@v6"}, + } + + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if !strings.Contains(text, check.expected) { + t.Errorf("docker.yaml missing expected configuration %q", check.expected) + } + }) + } +} + +func isDockerAvailable() bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "info") + return cmd.Run() == nil +} + +func TestDockerBuildAndExecution(t *testing.T) { + if !isDockerAvailable() { + t.Skip("Docker daemon is not available; skipping live container execution test") + } + + rootDir := findProjectRoot(t) + imageTag := "nomore403-test:unit" + + buildCmd := exec.Command("docker", "build", + "--build-arg", "VERSION=test-v1.0.0", + "--build-arg", "BUILD_DATE=2026-10-17", + "-t", imageTag, rootDir) + buildOut, err := buildCmd.CombinedOutput() + if err != nil { + t.Fatalf("docker build failed: %v\nOutput: %s", err, string(buildOut)) + } + defer func() { + _ = exec.Command("docker", "rmi", "-f", imageTag).Run() + }() + + t.Run("HelpFlag", func(t *testing.T) { + cmd := exec.Command("docker", "run", "--rm", imageTag, "--help") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("docker run --help failed: %v\nOutput: %s", err, string(out)) + } + if !strings.Contains(string(out), "Tool to bypass 40X response codes.") { + t.Errorf("help output does not contain expected banner; got: %s", string(out)) + } + }) + + t.Run("VersionFlag", func(t *testing.T) { + cmd := exec.Command("docker", "run", "--rm", imageTag, "--version") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("docker run --version failed: %v\nOutput: %s", err, string(out)) + } + expectedVersion := "nomore403 version test-v1.0.0 (built 2026-10-17)" + if !strings.Contains(string(out), expectedVersion) { + t.Errorf("version output got %q; want containing %q", string(out), expectedVersion) + } + }) + + t.Run("PayloadsDirectoryPresence", func(t *testing.T) { + cmd := exec.Command("docker", "run", "--rm", "--entrypoint", "sh", imageTag, "-c", "ls /nomore403/payloads") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("docker run ls payloads failed: %v\nOutput: %s", err, string(out)) + } + output := string(out) + expectedPayloads := []string{ + "endpaths", + "headers", + "httpmethods", + "ips", + "midpaths", + "simpleheaders", + "useragents", + } + for _, p := range expectedPayloads { + if !strings.Contains(output, p) { + t.Errorf("missing expected payload file %q in /nomore403/payloads", p) + } + } + }) + + t.Run("PayloadsSymlinkPresence", func(t *testing.T) { + cmd := exec.Command("docker", "run", "--rm", "--entrypoint", "sh", imageTag, "-c", "readlink /payloads") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("docker run readlink /payloads failed: %v\nOutput: %s", err, string(out)) + } + if strings.TrimSpace(string(out)) != "/nomore403/payloads" { + t.Errorf("/payloads symlink target got %q; want /nomore403/payloads", strings.TrimSpace(string(out))) + } + }) + + t.Run("StdinTargetsProcessing", func(t *testing.T) { + input := bytes.NewBufferString("http://127.0.0.1:1\n") + runCmd := exec.Command("docker", "run", "--rm", "-i", imageTag, "-k", "verb-tampering", "--max-goroutines", "1", "--timeout", "1") + runCmd.Stdin = input + out, _ := runCmd.CombinedOutput() + outputText := string(out) + if strings.Contains(outputText, "panic") { + t.Fatalf("container crashed with panic: %s", outputText) + } + }) +}