diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f8b0bd1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Only application inputs belong in the build context. +** +!Dockerfile +!.dockerignore +!pyproject.toml +!uv.lock +!README.md +!LICENSE.md +!src/ +!src/** +**/__pycache__/ +**/*.pyc +**/.dlt/ +**/*.pem +**/*.key diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml new file mode 100644 index 0000000..6bc470c --- /dev/null +++ b/.github/workflows/publish-container.yml @@ -0,0 +1,128 @@ +name: Build and publish container + +on: + workflow_call: + inputs: + tag: + description: Existing release tag to build + required: true + type: string + +permissions: + contents: read + packages: write + +concurrency: + group: container-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + publish: + if: >- + github.repository == 'SpecterOps/openhound-github' && + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + strategy: + matrix: + target: [cli, enterprise] + steps: + - name: Validate release tag + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "Expected a release tag such as v0.20.0" >&2 + exit 1 + } + + - name: Checkout release + uses: actions/checkout@v4 + with: + ref: refs/tags/${{ inputs.tag }} + + - name: Set image metadata + id: image + env: + RELEASE_TAG: ${{ inputs.tag }} + REPOSITORY: ${{ github.repository }} + TARGET: ${{ matrix.target }} + run: | + VERSION=${RELEASE_TAG#v} + IMAGE="ghcr.io/${REPOSITORY,,}" + SUFFIX="" + if [[ "$TARGET" == cli ]]; then SUFFIX="-cli"; fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$IMAGE:$VERSION$SUFFIX" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build smoke-test image + uses: docker/build-push-action@v7 + with: + context: . + target: ${{ matrix.target }} + platforms: linux/amd64 + load: true + tags: openhound-github:smoke + build-args: | + COLLECTOR_VERSION=${{ steps.image.outputs.version }} + cache-from: type=gha,scope=${{ matrix.target }} + + - name: Verify runtime and installed collector + env: + EXPECTED_VERSION: ${{ steps.image.outputs.version }} + run: | + docker run --rm --entrypoint python -e EXPECTED_VERSION openhound-github:smoke -c ' + import importlib.metadata as metadata + import os + import runpy + from unittest.mock import patch + import openhound_github.main + assert os.getuid() != 0 + assert metadata.version("openhound-github") == os.environ["EXPECTED_VERSION"] + entries = metadata.entry_points(group="openhound.sources") + assert any(entry.name == "github" and entry.load() for entry in entries) + with patch.dict(os.environ, { + "DESTINATION__BLOODHOUNDENTERPRISE__URL": "https://example.invalid", + "DESTINATION__BLOODHOUNDENTERPRISE__TOKEN_ID": "smoke-test-id", + "DESTINATION__BLOODHOUNDENTERPRISE__TOKEN_KEY": "smoke-test-key", + }), patch("openhound.scheduler.service.Service", autospec=True) as service: + runpy.run_path("/app/src/scheduler.py", run_name="__main__") + assert service.call_args.kwargs["collector_name"] == "github" + service.return_value.start.assert_called_once_with() + ' + + - name: Verify CLI + if: matrix.target == 'cli' + run: docker run --rm openhound-github:smoke --help + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push multi-architecture image + uses: docker/build-push-action@v7 + with: + context: . + target: ${{ matrix.target }} + platforms: linux/amd64,linux/arm64 + push: true + sbom: true + tags: ${{ steps.image.outputs.tag }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ steps.image.outputs.version }} + build-args: | + COLLECTOR_VERSION=${{ steps.image.outputs.version }} + cache-from: type=gha,scope=${{ matrix.target }} + cache-to: type=gha,mode=max,scope=${{ matrix.target }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c12b2b6..1d7ab19 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,10 +14,12 @@ on: permissions: contents: read - id-token: write jobs: build: + permissions: + contents: read + id-token: write environment: pypi runs-on: ubuntu-latest @@ -44,3 +46,18 @@ jobs: - name: Publish package run: uv publish + + container: + needs: build + permissions: + contents: read + packages: write + if: >- + github.repository == 'SpecterOps/openhound-github' && + github.event_name == 'pull_request' && + github.event.action == 'closed' && + github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main' + uses: ./.github/workflows/publish-container.yml + with: + tag: ${{ inputs.tag || github.ref_name }} diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml index 20826fb..af8e864 100644 --- a/.github/workflows/release-on-merge.yml +++ b/.github/workflows/release-on-merge.yml @@ -11,10 +11,20 @@ on: - "pyproject.toml" - "uv.lock" - "README.md" + - "Dockerfile" + - ".dockerignore" + - ".github/workflows/publish.yml" + - ".github/workflows/publish-container.yml" permissions: contents: write id-token: write # This is required for requesting the JWT for the OIDC provider + packages: write + +concurrency: + group: release-main + queue: max + cancel-in-progress: false jobs: @@ -28,18 +38,23 @@ jobs: # needs: test-core if: github.event.pull_request.merged == true runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.versioning.outputs.new_version }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ github.event.pull_request.merge_commit_sha }} - name: Fetch tags run: git fetch --tags - name: Determine next version id: versioning + env: + BRANCH_NAME: ${{ github.event.pull_request.head.ref }} run: | # Get latest tag or fallback LATEST_TAG=$(git tag --sort=-v:refname | head -n 1) @@ -52,7 +67,6 @@ jobs: VERSION=${LATEST_TAG#v} IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" - BRANCH_NAME="${{ github.event.pull_request.head.ref }}" echo "Merged branch: $BRANCH_NAME" if [[ "$BRANCH_NAME" == fix/* || "$BRANCH_NAME" == patch/* || "$BRANCH_NAME" == chore/* ]]; then @@ -86,7 +100,7 @@ jobs: publish: needs: tag-release - if: github.event.pull_request.merged == true + if: needs.tag-release.outputs.new_version != '' uses: ./.github/workflows/publish.yml with: tag: ${{ needs.tag-release.outputs.new_version }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5757358 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 +FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS builder + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=0 + +ARG COLLECTOR_VERSION +ENV SETUPTOOLS_SCM_PRETEND_VERSION=${COLLECTOR_VERSION} + +WORKDIR /app +COPY pyproject.toml uv.lock README.md LICENSE.md ./ +COPY src ./src +RUN test -n "$COLLECTOR_VERSION" +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --no-dev --group container --frozen --no-editable + +FROM gcr.io/distroless/python3-debian13:nonroot AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/.venv/lib/python3.13/site-packages \ + LOG_CONTAINER=true \ + RUNTIME__LOG_PATH=/app/logs \ + DESTINATION__BLOODHOUNDENTERPRISE__COLLECTOR_NAME=github + +COPY --from=builder --chown=nonroot:nonroot /app /app +WORKDIR /app + +FROM base AS cli +ENTRYPOINT ["python", "-m", "openhound"] +CMD ["--help"] + +FROM base AS enterprise +ENTRYPOINT ["python", "src/scheduler.py"] diff --git a/README.md b/README.md index 313effc..c61a0b2 100644 --- a/README.md +++ b/README.md @@ -143,3 +143,78 @@ Follow the OpenHound GitHub collector docs to get started: - [Configure an organization GitHub App](https://bloodhound.specterops.io/openhound/collectors/github/configure-app) - [Configure an enterprise GitHub App](https://bloodhound.specterops.io/openhound/collectors/github/configure-enterprise-app) - [Configure a personal access token](https://bloodhound.specterops.io/openhound/collectors/github/configure-pat) + +## Container images + +After a release-triggering pull request merges into `main` in +`SpecterOps/openhound-github`, the release workflow publishes the PyPI package +first, then builds images for `linux/amd64` and `linux/arm64` in GitHub Container +Registry. Container builds are skipped for forks, other branches, and standalone +tag pushes. Both images use the same release version: + +- `ghcr.io/specterops/openhound-github:` starts the BloodHound Enterprise scheduler. +- `ghcr.io/specterops/openhound-github:-cli` runs the OpenHound CLI. + +For example, release tag `v0.20.0` produces image tags `0.20.0` and `0.20.0-cli`. +Use a version containing the container files; releases predating this workflow +cannot be built without backporting those files. No moving `latest` tag is published. + +### Run the scheduler + +Prepare your `.dlt/config.toml`, `.dlt/secrets.toml`, and GitHub App private key +following the collector configuration documentation above. Mount the directory +read-only and use the container path for the key (for example, +`/app/.dlt/github-app.pem`). The container runs as UID/GID `65532`; mounted +files must be readable by that user. + +```sh +docker run --rm --init \ + --mount type=bind,src="$(pwd)/.dlt",dst=/app/.dlt,readonly \ + ghcr.io/specterops/openhound-github: +``` + +Set the BloodHound Enterprise destination URL, token ID, and token key in +`.dlt/secrets.toml`: + +```toml +[destination.bloodhoundenterprise] +url = "https://.bloodhoundenterprise.io" +token_id = "" +token_key = "" +``` + +The image defaults the collector name to `github`. OpenHound manages the +scheduler polling interval internally. Collection state and output are stored +inside the container unless you mount persistent storage for them. + +To inspect the CLI: + +```sh +docker run --rm ghcr.io/specterops/openhound-github:-cli --help +``` + +### Build and publish + +To build locally without publishing: + +```sh +docker build --target cli --build-arg COLLECTOR_VERSION=0.20.0 \ + -t openhound-github:0.20.0-cli . +docker build --target enterprise --build-arg COLLECTOR_VERSION=0.20.0 \ + -t openhound-github:0.20.0 . +``` + +`COLLECTOR_VERSION` sets the GitHub collector package version; the OpenHound +framework version is resolved separately from `uv.lock`. The container-only +dependency group installs the framework without development tools. + +To retry a failed container publication, re-run the failed jobs in the original +release workflow run. The container workflow authenticates using `GITHUB_TOKEN` +with `packages: write`; AWS credentials and personal access tokens are not +needed. Runtime checks run before each image is pushed, and both architectures +include an SBOM. + +After the first publication, a package administrator should verify the GHCR +package's visibility and repository access. New packages default to private; +make the package public if anonymous pulls are intended. If the package +already exists, grant this repository's Actions workflows write access to it. diff --git a/pyproject.toml b/pyproject.toml index e9134ea..9ec02ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ local_scheme = "no-local-version" "src/openhound_github/extension.yaml" = "openhound_github/extension.yaml" [dependency-groups] +container = [ + "openhound>=0.4.0", +] dev = [ "openhound>=0.4.0", "pre-commit>=4.5.1", diff --git a/src/scheduler.py b/src/scheduler.py new file mode 100644 index 0000000..973ffc5 --- /dev/null +++ b/src/scheduler.py @@ -0,0 +1,25 @@ +"""Start the BloodHound Enterprise scheduler for the configured collector.""" + +import logging + +import dlt + +from openhound.scheduler.service import Service + +logger = logging.getLogger(__name__) + + +def start() -> None: + collector_name = dlt.config["destination.bloodhoundenterprise.collector_name"] + logger.info("Initializing service for collector '%s'.", collector_name) + service = Service( + bhe_uri=dlt.secrets["destination.bloodhoundenterprise.url"], + token_key=dlt.secrets["destination.bloodhoundenterprise.token_key"], + token_id=dlt.secrets["destination.bloodhoundenterprise.token_id"], + collector_name=collector_name, + ) + service.start() + + +if __name__ == "__main__": + start() diff --git a/uv.lock b/uv.lock index 3f2ca6e..74da548 100644 --- a/uv.lock +++ b/uv.lock @@ -879,6 +879,9 @@ dependencies = [ ] [package.dev-dependencies] +container = [ + { name = "openhound" }, +] dev = [ { name = "mypy" }, { name = "openhound" }, @@ -895,6 +898,9 @@ requires-dist = [ ] [package.metadata.requires-dev] +container = [ + { name = "openhound", specifier = ">=0.4.0" }, +] dev = [ { name = "mypy", specifier = ">=1.19.1" }, { name = "openhound", specifier = ">=0.4.0" },