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
15 changes: 15 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions .github/workflows/publish-container.yml
Original file line number Diff line number Diff line change
@@ -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 }}
19 changes: 18 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 }}
18 changes: 16 additions & 2 deletions .github/workflows/release-on-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

jobs:

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
35 changes: 35 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<version>` starts the BloodHound Enterprise scheduler.
- `ghcr.io/specterops/openhound-github:<version>-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:<version>
```

Set the BloodHound Enterprise destination URL, token ID, and token key in
`.dlt/secrets.toml`:

```toml
[destination.bloodhoundenterprise]
url = "https://<tenant>.bloodhoundenterprise.io"
token_id = "<token-id>"
token_key = "<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:<version>-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.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions src/scheduler.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading