Skip to content

Merge pull request #146 from jackby03/test/add-atomicwrite-tests-1157… #80

Merge pull request #146 from jackby03/test/add-atomicwrite-tests-1157…

Merge pull request #146 from jackby03/test/add-atomicwrite-tests-1157… #80

name: Quality Gates
permissions:
contents: read
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
name: Build and Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
- name: Download dependencies
run: go mod download
- name: Vet
run: go vet ./...
- name: Build
run: go build -v ./...
- name: Test
run: go test ./... -race -coverprofile=coverage.out
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
hardbox-audit:
name: Self-Audit
runs-on: ubuntu-latest
needs: build-and-test
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Build hardbox
run: go build -o hardbox ./cmd/hardbox
- name: Run audit (dry run, non-interactive)
run: |
./hardbox audit --profile cis-level1 --format json --output audit-report.json || true
- name: Validate audit report JSON structure
run: |
jq -e '
has("session_id") and
has("timestamp") and
has("profile") and
has("overall_score") and
(.modules | type == "array" and length > 0) and
all(.modules[]; has("name") and has("score") and has("findings") and (.findings | type == "array")) and
all(.modules[].findings[]?; has("check_id") and has("title") and has("status") and has("severity"))
' audit-report.json > /dev/null
- name: Assert audit returned findings
run: |
jq -e '[.modules[].findings[]] | length > 0' audit-report.json > /dev/null
- name: Assert audit score is non-zero
run: |
jq -e '.overall_score > 0' audit-report.json > /dev/null
- name: Upload audit report
uses: actions/upload-artifact@v4
with:
name: audit-report
path: audit-report.json
markdown-links:
name: Documentation Links
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check internal file links in README.md
run: |
FAILED=0
LINKS=$(grep -oP '\]\(\K[^)]+' README.md | grep -v '^https\?://' | grep -v '^#')
for link in $LINKS; do
filepath="${link%%#*}"
[ -z "$filepath" ] && continue
if [ ! -e "$filepath" ]; then
echo "::error file=README.md::Broken link — '$filepath' does not exist"
FAILED=1
else
echo "✓ $filepath"
fi
done
exit $FAILED
- name: Check internal file links in CONTRIBUTING.md
run: |
FAILED=0
LINKS=$(grep -oP '\]\(\K[^)]+' CONTRIBUTING.md | grep -v '^https\?://' | grep -v '^#')
for link in $LINKS; do
filepath="${link%%#*}"
[ -z "$filepath" ] && continue
if [ ! -e "$filepath" ]; then
echo "::error file=CONTRIBUTING.md::Broken link — '$filepath' does not exist"
FAILED=1
else
echo "✓ $filepath"
fi
done
exit $FAILED
profile-consistency:
name: Profile Documentation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Collect shipped profile names
id: shipped
run: |
PROFILES=$(ls configs/profiles/*.yaml 2>/dev/null \
| xargs -I{} basename {} .yaml \
| sort \
| tr '\n' ' ')
echo "list=${PROFILES}" >> "$GITHUB_OUTPUT"
echo "Shipped profiles: ${PROFILES}"
- name: Assert every shipped profile appears in README
run: |
FAILED=0
for profile in ${{ steps.shipped.outputs.list }}; do
if ! grep -q "\`${profile}\`" README.md; then
echo "::error file=README.md::Shipped profile '${profile}' is not documented in README.md"
FAILED=1
else
echo "✓ ${profile} found in README.md"
fi
done
exit $FAILED
- name: Assert every shipped profile appears in COMPLIANCE.md
run: |
FAILED=0
for profile in ${{ steps.shipped.outputs.list }}; do
if ! grep -q "\`${profile}\`" docs/COMPLIANCE.md; then
echo "::error file=docs/COMPLIANCE.md::Shipped profile '${profile}' is not documented in docs/COMPLIANCE.md"
FAILED=1
else
echo "✓ ${profile} found in COMPLIANCE.md"
fi
done
exit $FAILED
- name: Warn about profiles listed in README that are not yet shipped
run: |
DOCS_PROFILES=$(grep -oP '`[a-z][a-z0-9-]+`' README.md \
| tr -d '`' \
| grep -v '^hardbox$\|^cis$\|^json$\|^text$\|^markdown$\|^html$' \
| sort -u)
for doc_profile in ${DOCS_PROFILES}; do
if [ ! -f "configs/profiles/${doc_profile}.yaml" ]; then
echo "::notice file=README.md::Profile '${doc_profile}' is documented in README but not yet shipped (roadmap item)"
fi
done
# ── Distro parity — RHEL / Rocky Linux ──────────────────────────────────────
# Runs build + unit tests + smoke audit inside RPM-based containers to catch
# path assumptions, syscall differences, or package-name drift that only
# surface on non-Debian systems.
#
# Container limitations (documented in docs/DEVSECOPS.md):
# - systemd / systemctl is not available inside GitHub Actions containers.
# Modules that shell out to systemctl will return "inactive" gracefully;
# the audit is expected to complete and produce valid JSON regardless.
# - redhat/ubi9 is the freely available RHEL 9 Universal Base Image.
# It is ABI-compatible with RHEL 9 for our build/test/audit purposes.
distro-parity:
name: Distro Parity / ${{ matrix.distro }}
runs-on: ubuntu-latest
needs: build-and-test
container:
image: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- distro: Rocky Linux 9
image: rockylinux:9
slug: rocky-linux-9
- distro: RHEL UBI 9
image: redhat/ubi9
slug: rhel-ubi-9
steps:
- name: Install system dependencies
run: |
dnf install -y --nodocs git tar gzip ca-certificates jq
# Mark any directory as safe so Go's VCS stamping doesn't fail when
# the workspace UID (runner) differs from the container UID (root).
git config --global --add safe.directory '*'
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
- name: Download dependencies
run: go mod download
- name: Vet
run: go vet ./...
- name: Build
run: go build -v ./...
- name: Test
run: go test ./... -count=1 -timeout=120s
- name: Build hardbox binary
run: go build -o hardbox ./cmd/hardbox
- name: Prepare system stubs for container audit
# Containers lack the OS files hardbox reads during audit.
# Create the minimum stubs so every module can open its config
# files and produce findings rather than aborting with ENOENT.
run: |
mkdir -p /etc/ssh && touch /etc/ssh/sshd_config
mkdir -p /etc/audit/rules.d
mkdir -p /etc/sysctl.d
mkdir -p /etc/pam.d && touch /etc/pam.d/common-auth /etc/pam.d/common-password
mkdir -p /etc/chrony && touch /etc/chrony/chrony.conf
mkdir -p /etc/systemd/system
touch /etc/login.defs
touch /etc/rsyslog.conf
- name: Smoke audit (cis-level1)
run: |
./hardbox audit --profile cis-level1 --format json \
--output audit-${{ matrix.slug }}.json || true
- name: Validate audit report structure
run: |
jq -e '
has("session_id") and
has("overall_score") and
(.modules | type == "array" and length > 0) and
all(.modules[]; has("name") and has("findings"))
' "audit-${{ matrix.slug }}.json" > /dev/null
echo "✓ Audit report structure valid on ${{ matrix.distro }}"
- name: Assert audit produced findings
run: |
COUNT=$(jq '[.modules[].findings[]] | length' "audit-${{ matrix.slug }}.json")
echo "Findings on ${{ matrix.distro }}: ${COUNT}"
if [ "${COUNT}" -eq 0 ]; then
echo "::error::Audit returned 0 findings on ${{ matrix.distro }} — distro detection may be broken"
exit 1
fi
- name: Upload audit report
uses: actions/upload-artifact@v4
with:
name: audit-${{ matrix.slug }}
path: audit-${{ matrix.slug }}.json
# Single gate job that requires all matrix legs — add this check name to
# branch protection so the merge button blocks until every distro passes.
distro-parity-gate:
name: Distro Parity Gate
runs-on: ubuntu-latest
needs: distro-parity
if: always()
steps:
- name: Assert all parity legs passed
run: |
RESULT="${{ needs.distro-parity.result }}"
if [ "${RESULT}" != "success" ]; then
echo "::error::One or more distro parity checks failed (result: ${RESULT})"
exit 1
fi
echo "✓ All distro parity checks passed"
goreleaser-config:
name: Release Configuration
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract GoReleaser release target
id: goreleaser
run: |
OWNER=$(grep -A3 'release:' .goreleaser.yaml \
| grep -A2 'github:' \
| grep 'owner:' \
| awk '{print $2}')
REPO=$(grep -A3 'release:' .goreleaser.yaml \
| grep -A2 'github:' \
| grep 'name:' \
| awk '{print $2}')
echo "owner=${OWNER}" >> "$GITHUB_OUTPUT"
echo "repo=${REPO}" >> "$GITHUB_OUTPUT"
echo "Configured release target: ${OWNER}/${REPO}"
- name: Derive canonical owner/name from GITHUB_REPOSITORY
id: canonical
run: |
CANONICAL_OWNER="${GITHUB_REPOSITORY_OWNER}"
CANONICAL_REPO="${GITHUB_REPOSITORY#*/}"
echo "owner=${CANONICAL_OWNER}" >> "$GITHUB_OUTPUT"
echo "repo=${CANONICAL_REPO}" >> "$GITHUB_OUTPUT"
echo "Canonical repository: ${CANONICAL_OWNER}/${CANONICAL_REPO}"
- name: Assert owner matches
run: |
CONFIGURED="${{ steps.goreleaser.outputs.owner }}"
CANONICAL="${{ steps.canonical.outputs.owner }}"
if [ "${CONFIGURED}" != "${CANONICAL}" ]; then
echo "::error file=.goreleaser.yaml::release.github.owner is '${CONFIGURED}' but canonical owner is '${CANONICAL}'. Update .goreleaser.yaml."
exit 1
fi
echo "✓ owner matches: ${CONFIGURED}"
- name: Assert repo name matches
run: |
CONFIGURED="${{ steps.goreleaser.outputs.repo }}"
CANONICAL="${{ steps.canonical.outputs.repo }}"
if [ "${CONFIGURED}" != "${CANONICAL}" ]; then
echo "::error file=.goreleaser.yaml::release.github.name is '${CONFIGURED}' but canonical repo name is '${CANONICAL}'. Update .goreleaser.yaml."
exit 1
fi
echo "✓ repo name matches: ${CONFIGURED}"
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
install-only: true
version: "~> v2"
- name: Check GoReleaser config
run: goreleaser check --config .goreleaser.yaml