Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Keep credentials and local state out of the build context entirely: the
# image is built by CI and published, so anything reachable here can leak.
provider-config.yaml
provider-config.yml
.env
.env.*
# Anchored: an unanchored 'secrets/' also matches internal/secrets/.
/secrets/
*.pem
*.key

.git/
.github/
dist/
bin/
results/
*.md
coverage.out
114 changes: 114 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: CI

on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:

permissions:
contents: read

jobs:
test:
name: Lint and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: go vet
run: go vet ./...

- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.12.2

- name: Test
run: go test -race -coverprofile=coverage.out ./...

# The runtime image is distroless/static, which has no dynamic loader. A
# dependency that reaches libc through dlopen makes the binary
# dynamically linked, and it then fails at exec time with a message that
# says nothing about the cause. Catch it here instead.
- name: Binary must stay statically linked
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/fastrecon ./cmd/fastrecon
if ! file /tmp/fastrecon | grep -q "statically linked"; then
file /tmp/fastrecon
echo "::error::binary is not statically linked; the distroless static image cannot exec it"
exit 1
fi

secrets:
name: Secret scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

# The published image is built from this repository, so a credential
# committed here is a credential shipped. A hit fails the build.
# The scan covers the full history, and .gitleaks.toml carries the
# exemptions for known-fake test fixtures — one literal at a time, never
# a path-wide hole.
- name: gitleaks
run: |
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \
detect --source=/repo --no-banner --redact --verbose

image:
name: Build image
runs-on: ubuntu-latest
needs: [test, secrets]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- name: 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=sha,format=long

# Pull requests build the image to prove the Dockerfile still works,
# but never publish it.
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
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 carry build identity only — never a credential, which
# would persist in the image history.
build-args: |
VERSION=${{ steps.meta.outputs.version }}
COMMIT=${{ github.sha }}
DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/dist/
/bin/
/results/
# Anchored: an unanchored 'fastrecon' would also ignore cmd/fastrecon/.
/fastrecon
*.test
coverage.out

# Never commit source credentials.
provider-config.yaml
provider-config.yml
.env
.env.*
# Anchored: an unanchored 'secrets/' also matches internal/secrets/.
/secrets/
24 changes: 24 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
title = "FastRecon"

# Start from the shipped rules; this file only ever subtracts from them.
[extend]
useDefault = true

# Known-fake values used as test fixtures.
#
# The scan covers the whole history, so annotating the current line would not
# clear a finding recorded against an earlier commit — the exemption has to
# live here.
#
# Entries are listed one literal at a time, on purpose. A path-wide exemption
# for test files would be a hole: a test file can hold a real credential just
# as easily as any other, and the point of this scan is that the published
# image is built from this repository.
[[allowlists]]
description = "Fixture credential in the redaction tests, which need a value shaped like a real key to be worth anything"
regexTargets = ["secret"]
regexes = [
# internal/secrets/secrets_test.go — asserts the redactor scrubs a key out
# of a request URL, the way c99 sends one.
'''^abcdef1234567890$''',
]
24 changes: 24 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
version: "2"

linters:
enable:
- bodyclose
- errorlint
- misspell
- revive
- unconvert
- usestdlibvars
settings:
revive:
rules:
- name: error-strings
- name: context-as-argument
exclusions:
rules:
# Table-driven tests read better without a comment on every helper.
- path: _test\.go
linters: [revive]

formatters:
enable:
- gofmt
81 changes: 45 additions & 36 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,36 +1,45 @@
# Build stage
FROM golang:alpine3.21 as builder

RUN apk add make gcc g++ zlib zlib-dev git wget

WORKDIR /app
COPY main.go .
RUN go build main.go

RUN git clone https://github.com/blechschmidt/massdns && \
cd massdns && \
make

RUN wget https://raw.githubusercontent.com/trickest/resolvers/main/resolvers.txt && \
wget https://raw.githubusercontent.com/trickest/resolvers/main/resolvers-trusted.txt

RUN go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest
RUN go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest
RUN go install github.com/d3mondev/puredns/v2@latest

# Run stage
FROM alpine:latest

# Create app directory
WORKDIR /app
COPY --from=builder /go/bin/subfinder /usr/local/bin/subfinder
COPY --from=builder /go/bin/httpx /usr/local/bin/httpx
COPY --from=builder /go/bin/puredns /usr/local/bin/puredns
COPY --from=builder /app/massdns/bin/massdns /usr/local/bin/massdns
COPY --from=builder /app/resolvers.txt /app/resolvers.txt
COPY --from=builder /app/resolvers-trusted.txt /app/resolvers-trusted.txt
COPY --from=builder /app/main /app/main
COPY subfinder.yaml .

# Run the binary
CMD ["sh", "-c", "./main"]
# syntax=docker/dockerfile:1

# Build stage. Nothing here may take a credential: build args are recorded in
# the image history, and this image is built by CI and published.
# The builder runs natively on the build machine and cross-compiles, which is
# far faster than emulating the target platform.
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build

WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .

ARG VERSION=dev
ARG COMMIT=""
ARG DATE=""

# Provided by buildx. The runtime stage below resolves to the same platform,
# so the image manifest and the binary inside it can never disagree.
ARG TARGETOS
ARG TARGETARCH

# Static build: no libc at runtime, so the final image can be distroless.
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build \
-trimpath \
-ldflags="-s -w \
-X github.com/JoshuaMart/FastRecon/internal/version.Version=${VERSION} \
-X github.com/JoshuaMart/FastRecon/internal/version.Commit=${COMMIT} \
-X github.com/JoshuaMart/FastRecon/internal/version.Date=${DATE}" \
-o /out/fastrecon ./cmd/fastrecon

# Runtime stage: static distroless, non-root, no shell, no package manager.
# The binary needs CA certificates to reach the enumeration sources; the
# static image ships them.
FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=build /out/fastrecon /usr/local/bin/fastrecon

USER nonroot:nonroot

# Configuration arrives as environment variables and arguments, so the same
# image serves a local `docker run` and a serverless job definition.
ENTRYPOINT ["/usr/local/bin/fastrecon"]
48 changes: 48 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
BINARY := fastrecon
PKG := github.com/JoshuaMart/FastRecon
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w \
-X $(PKG)/internal/version.Version=$(VERSION) \
-X $(PKG)/internal/version.Commit=$(COMMIT) \
-X $(PKG)/internal/version.Date=$(DATE)

.PHONY: build test lint fmt vet cover static docker clean

build:
go build -trimpath -ldflags="$(LDFLAGS)" -o bin/$(BINARY) ./cmd/fastrecon

test:
go test -race ./...

cover:
go test -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | tail -1

lint:
golangci-lint run ./...

fmt:
gofmt -w .

vet:
go vet ./...

# The distroless runtime image has no dynamic loader, so a dependency that
# dlopens libc would produce a binary that cannot start in it.
static:
@CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/$(BINARY)-static ./cmd/fastrecon
@file /tmp/$(BINARY)-static | grep -q "statically linked" \
&& echo "static: ok" \
|| { file /tmp/$(BINARY)-static; echo "static: FAILED"; exit 1; }

docker:
docker build \
--build-arg VERSION=$(VERSION) \
--build-arg COMMIT=$(COMMIT) \
--build-arg DATE=$(DATE) \
-t $(BINARY):$(VERSION) .

clean:
rm -rf bin coverage.out
Loading
Loading