From 22e50b3f11758524c8325441bf93402e8c92695f Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Tue, 26 May 2026 09:01:08 -0700 Subject: [PATCH] Add probo-agent CLI and deviceagent library Introduce the standalone device agent binary and shared library for enrollment, posture checks, self-update, and OS service integration. Include build targets, module deps, and release workflow so the agent can ship independently of server changes. Signed-off-by: Bryan Frimin --- .github/workflows/release-probo-agent.yaml | 161 +++++ GNUmakefile | 34 +- cmd/probo-agent/CHANGELOG.md | 37 ++ cmd/probo-agent/VERSION | 1 + .../installer/macos/Distribution.xml.tmpl | 56 ++ .../installer/macos/Resources/conclusion.html | 62 ++ .../installer/macos/Resources/welcome.html | 62 ++ cmd/probo-agent/installer/macos/build.sh | 112 ++++ .../installer/macos/scripts/postinstall | 92 +++ cmd/probo-agent/main.go | 372 +++++++++++ go.mod | 71 ++- go.sum | 321 +++++++--- pkg/deviceagent/agent.go | 583 +++++++++++++++++ pkg/deviceagent/agent_test.go | 71 +++ pkg/deviceagent/checks/check.go | 34 + pkg/deviceagent/checks/checks_darwin.go | 440 +++++++++++++ pkg/deviceagent/checks/checks_freebsd.go | 147 +++++ pkg/deviceagent/checks/checks_linux.go | 399 ++++++++++++ pkg/deviceagent/checks/checks_windows.go | 421 +++++++++++++ pkg/deviceagent/checks/evidence.go | 31 + pkg/deviceagent/checks/registry.go | 55 ++ pkg/deviceagent/checks/runcmd.go | 101 +++ pkg/deviceagent/checks/runcmd_paths_darwin.go | 31 + .../checks/runcmd_paths_freebsd.go | 29 + pkg/deviceagent/checks/runcmd_paths_linux.go | 30 + pkg/deviceagent/checks/runcmd_paths_other.go | 21 + .../checks/runcmd_paths_windows.go | 48 ++ pkg/deviceagent/checks/shared.go | 84 +++ pkg/deviceagent/checks/status.go | 24 + pkg/deviceagent/client.go | 234 +++++++ pkg/deviceagent/config.go | 145 +++++ pkg/deviceagent/config_paths_other.go | 23 + pkg/deviceagent/config_paths_windows.go | 31 + pkg/deviceagent/config_test.go | 71 +++ pkg/deviceagent/hostinfo.go | 98 +++ pkg/deviceagent/hostinfo_darwin.go | 92 +++ pkg/deviceagent/hostinfo_freebsd.go | 57 ++ pkg/deviceagent/hostinfo_linux.go | 93 +++ pkg/deviceagent/hostinfo_other.go | 44 ++ pkg/deviceagent/hostinfo_windows.go | 70 +++ pkg/deviceagent/keystore.go | 84 +++ pkg/deviceagent/posture_queue.go | 144 +++++ pkg/deviceagent/posture_queue_test.go | 237 +++++++ pkg/deviceagent/service/service.go | 33 + pkg/deviceagent/service/service_darwin.go | 119 ++++ pkg/deviceagent/service/service_freebsd.go | 97 +++ pkg/deviceagent/service/service_linux.go | 97 +++ pkg/deviceagent/service/service_windows.go | 73 +++ pkg/deviceagent/update/archive.go | 151 +++++ pkg/deviceagent/update/asset.go | 92 +++ pkg/deviceagent/update/asset_test.go | 69 ++ pkg/deviceagent/update/install_unix.go | 101 +++ pkg/deviceagent/update/install_windows.go | 110 ++++ pkg/deviceagent/update/update.go | 588 ++++++++++++++++++ pkg/deviceagent/update/update_test.go | 460 ++++++++++++++ pkg/deviceagent/update/verify.go | 194 ++++++ 56 files changed, 7410 insertions(+), 127 deletions(-) create mode 100644 .github/workflows/release-probo-agent.yaml create mode 100644 cmd/probo-agent/CHANGELOG.md create mode 100644 cmd/probo-agent/VERSION create mode 100644 cmd/probo-agent/installer/macos/Distribution.xml.tmpl create mode 100644 cmd/probo-agent/installer/macos/Resources/conclusion.html create mode 100644 cmd/probo-agent/installer/macos/Resources/welcome.html create mode 100755 cmd/probo-agent/installer/macos/build.sh create mode 100755 cmd/probo-agent/installer/macos/scripts/postinstall create mode 100644 cmd/probo-agent/main.go create mode 100644 pkg/deviceagent/agent.go create mode 100644 pkg/deviceagent/agent_test.go create mode 100644 pkg/deviceagent/checks/check.go create mode 100644 pkg/deviceagent/checks/checks_darwin.go create mode 100644 pkg/deviceagent/checks/checks_freebsd.go create mode 100644 pkg/deviceagent/checks/checks_linux.go create mode 100644 pkg/deviceagent/checks/checks_windows.go create mode 100644 pkg/deviceagent/checks/evidence.go create mode 100644 pkg/deviceagent/checks/registry.go create mode 100644 pkg/deviceagent/checks/runcmd.go create mode 100644 pkg/deviceagent/checks/runcmd_paths_darwin.go create mode 100644 pkg/deviceagent/checks/runcmd_paths_freebsd.go create mode 100644 pkg/deviceagent/checks/runcmd_paths_linux.go create mode 100644 pkg/deviceagent/checks/runcmd_paths_other.go create mode 100644 pkg/deviceagent/checks/runcmd_paths_windows.go create mode 100644 pkg/deviceagent/checks/shared.go create mode 100644 pkg/deviceagent/checks/status.go create mode 100644 pkg/deviceagent/client.go create mode 100644 pkg/deviceagent/config.go create mode 100644 pkg/deviceagent/config_paths_other.go create mode 100644 pkg/deviceagent/config_paths_windows.go create mode 100644 pkg/deviceagent/config_test.go create mode 100644 pkg/deviceagent/hostinfo.go create mode 100644 pkg/deviceagent/hostinfo_darwin.go create mode 100644 pkg/deviceagent/hostinfo_freebsd.go create mode 100644 pkg/deviceagent/hostinfo_linux.go create mode 100644 pkg/deviceagent/hostinfo_other.go create mode 100644 pkg/deviceagent/hostinfo_windows.go create mode 100644 pkg/deviceagent/keystore.go create mode 100644 pkg/deviceagent/posture_queue.go create mode 100644 pkg/deviceagent/posture_queue_test.go create mode 100644 pkg/deviceagent/service/service.go create mode 100644 pkg/deviceagent/service/service_darwin.go create mode 100644 pkg/deviceagent/service/service_freebsd.go create mode 100644 pkg/deviceagent/service/service_linux.go create mode 100644 pkg/deviceagent/service/service_windows.go create mode 100644 pkg/deviceagent/update/archive.go create mode 100644 pkg/deviceagent/update/asset.go create mode 100644 pkg/deviceagent/update/asset_test.go create mode 100644 pkg/deviceagent/update/install_unix.go create mode 100644 pkg/deviceagent/update/install_windows.go create mode 100644 pkg/deviceagent/update/update.go create mode 100644 pkg/deviceagent/update/update_test.go create mode 100644 pkg/deviceagent/update/verify.go diff --git a/.github/workflows/release-probo-agent.yaml b/.github/workflows/release-probo-agent.yaml new file mode 100644 index 000000000..ec623c45c --- /dev/null +++ b/.github/workflows/release-probo-agent.yaml @@ -0,0 +1,161 @@ +name: "Release probo-agent" + +on: + push: + tags: + - "probo-agent/v*" + +permissions: + contents: "read" + +jobs: + build-binary: + name: "binary (${{ matrix.goos }}/${{ matrix.goarch }})" + runs-on: "runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache" + permissions: + contents: "read" + strategy: + fail-fast: false + matrix: + include: + - { goos: linux, goarch: amd64 } + - { goos: linux, goarch: arm64 } + - { goos: darwin, goarch: amd64 } + - { goos: darwin, goarch: arm64 } + - { goos: windows, goarch: amd64 } + - { goos: windows, goarch: arm64 } + - { goos: freebsd, goarch: amd64 } + - { goos: freebsd, goarch: arm64 } + steps: + - uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6 + with: + submodules: recursive + - uses: "runs-on/action@742bf56072eb4845a0f94b3394673e4903c90ff0" # v2 + - uses: "./.github/actions/setup" + with: + node: "false" + - name: "Build binary" + env: + CGO_ENABLED: "0" + GOOS: "${{ matrix.goos }}" + GOARCH: "${{ matrix.goarch }}" + run: | + VERSION="${GITHUB_REF_NAME##*/v}" + EXT="" + if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi + + go build -ldflags "-s -w -X 'main.version=${VERSION}'" \ + -gcflags="-e" -o "dist/probo-agent${EXT}" ./cmd/probo-agent/main.go + - name: "Create archive" + env: + GOOS: "${{ matrix.goos }}" + GOARCH: "${{ matrix.goarch }}" + run: | + case "$GOOS" in + linux) OS="Linux" ;; + darwin) OS="Darwin" ;; + windows) OS="Windows" ;; + freebsd) OS="Freebsd" ;; + esac + case "$GOARCH" in + amd64) ARCH="x86_64" ;; + *) ARCH="$GOARCH" ;; + esac + EXT="" + if [ "$GOOS" = "windows" ]; then EXT=".exe"; fi + + mkdir -p archives + AGENT_DIR="probo-agent_${OS}_${ARCH}" + mkdir -p "staging/${AGENT_DIR}" + cp "dist/probo-agent${EXT}" README.md LICENSE "staging/${AGENT_DIR}/" + if [ -f cmd/probo-agent/CHANGELOG.md ]; then + cp cmd/probo-agent/CHANGELOG.md "staging/${AGENT_DIR}/" + fi + if [ "$GOOS" = "windows" ]; then + (cd staging && zip -r "../archives/${AGENT_DIR}.zip" "${AGENT_DIR}") + else + tar -czf "archives/${AGENT_DIR}.tar.gz" -C staging "${AGENT_DIR}" + fi + - uses: "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v7 + with: + name: "archive-${{ matrix.goos }}-${{ matrix.goarch }}" + path: "archives/" + retention-days: 1 + + github-release: + name: "github-release" + needs: [build-binary] + runs-on: "runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/extras=s3-cache" + permissions: + contents: "write" + id-token: "write" + attestations: "write" + steps: + - uses: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd" # v6 + with: + fetch-depth: 0 + - uses: "runs-on/action@742bf56072eb4845a0f94b3394673e4903c90ff0" # v2 + - uses: "sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad" # v4.0.0 + - uses: "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" # v8 + with: + pattern: "archive-*" + path: "archives" + merge-multiple: true + - name: "Generate checksums and sign" + run: | + cd archives + sha256sum *.tar.gz *.zip > checksums.txt + cosign sign-blob --bundle="checksums.txt.bundle" checksums.txt --yes + - name: "Generate SBOM" + uses: "anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610" # v0.24.0 + with: + path: ./cmd/probo-agent + format: cyclonedx-json + output-file: sbom.json + - name: "Run vulnerability scan" + uses: "anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2" # v7.4.0 + with: + sbom: "sbom.json" + fail-build: true + severity-cutoff: critical + - name: "Attest SBOM for archives" + uses: "actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e" # v4 + with: + subject-path: "archives/*.tar.gz, archives/*.zip" + sbom-path: "sbom.json" + - name: "Attest build provenance for archives" + uses: "actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" # v4 + with: + subject-path: "archives/*.tar.gz, archives/*.zip" + - name: "Extract release notes" + run: | + VERSION="${GITHUB_REF_NAME##*/v}" + if [ -f cmd/probo-agent/CHANGELOG.md ]; then + awk -v ver="$VERSION" ' + /^## \[/ { if (found) exit; if ($0 ~ "\\[" ver "\\]") found=1 } + found + ' cmd/probo-agent/CHANGELOG.md > release-notes.md + else + echo "probo-agent ${VERSION}" > release-notes.md + fi + - name: "Create GitHub release" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PRERELEASE_FLAG="" + if echo "${GITHUB_REF_NAME}" | grep -qE '(alpha|beta|rc)'; then + PRERELEASE_FLAG="--prerelease" + fi + + gh release delete "${GITHUB_REF_NAME}" --yes 2>/dev/null || true + + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --notes-file release-notes.md \ + $PRERELEASE_FLAG \ + archives/* sbom.json + - uses: "actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f" # v7 + with: + name: "sbom" + path: "sbom.json" + retention-days: 30 diff --git a/GNUmakefile b/GNUmakefile index b8f43a6f1..1f8d112c4 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -25,12 +25,12 @@ DOCKER_COMPOSE= $(DOCKER) compose -f compose.yaml $(DOCKER_COMPOSE_FLAGS) PRB_VERSION= $(shell cat cmd/prb/VERSION) PROBOD_VERSION= $(shell cat cmd/probod/VERSION) PROBOD_BOOTSTRAP_VERSION=$(shell cat cmd/probod-bootstrap/VERSION) -PROBOCTL_VERSION= $(shell cat cmd/proboctl/VERSION) +PROBO_AGENT_VERSION= $(shell cat cmd/probo-agent/VERSION) PRB_LDFLAGS= -ldflags "-X 'main.version=$(PRB_VERSION)'" PROBOD_LDFLAGS= -ldflags "-X 'main.version=$(PROBOD_VERSION)' -X 'main.env=prod'" PROBOD_BOOTSTRAP_LDFLAGS=-ldflags "-X 'main.version=$(PROBOD_BOOTSTRAP_VERSION)'" -PROBOCTL_LDFLAGS= -ldflags "-X 'main.version=$(PROBOCTL_VERSION)'" +PROBO_AGENT_LDFLAGS= -ldflags "-X 'main.version=$(PROBO_AGENT_VERSION)'" GCFLAGS= -gcflags="-e" @@ -49,14 +49,7 @@ TEST_FLAGS?= -race -cover -coverprofile=coverage.out E2E_CONFIG ?= $(CURDIR)/e2e/console/testdata/config.yaml E2E_COVER_DIR ?= $(CURDIR)/coverage/e2e -DOCKER_REGISTRY= artifact.probo.inc -DOCKER_PROXY= $(DOCKER_REGISTRY)/dockerhub -DOCKER_BASE_DIGEST= sha256:c4a8d5503dfb2a3eb8ab5f807da5bc69a85730fb49b5cfca2330194ebcc41c7b -DOCKER_BASE_IMAGE= ubuntu:24.04@$(DOCKER_BASE_DIGEST) -# Harbor proxy resolves digest refs as library/@sha256:..., not library/:tag@sha256:... -DOCKER_PROXY_BASE_IMAGE= $(DOCKER_PROXY)/library/ubuntu@$(DOCKER_BASE_DIGEST) -DOCKER_IMAGE_NAME= $(DOCKER_REGISTRY)/probo/probo -HELM_CHART_OCI= oci://$(DOCKER_REGISTRY)/probo +DOCKER_IMAGE_NAME= ghcr.io/getprobo/probo DOCKER_TAG_NAME?= latest GENERATED= pkg/server/api/connect/v1/schema/schema.go \ @@ -82,8 +75,8 @@ PRB_SRC= cmd/prb/main.go PROBOD_BOOTSTRAP_BIN= bin/probod-bootstrap PROBOD_BOOTSTRAP_SRC= cmd/probod-bootstrap/main.go -PROBOCTL_BIN= bin/proboctl -PROBOCTL_SRC= cmd/proboctl/main.go +PROBO_AGENT_BIN= bin/probo-agent +PROBO_AGENT_SRC= cmd/probo-agent/main.go ifdef WITH_APPS GENERATED += relay @@ -184,7 +177,7 @@ coverage-combined: coverage-report test-e2e-coverage ## Generate combined covera $(GO) tool cover -html=coverage-combined.out -o=coverage-combined.html .PHONY: build -build: $(PROBOD_BIN) bin/prb bin/probod-bootstrap bin/proboctl +build: $(PROBOD_BIN) bin/prb bin/probod-bootstrap CFG_DEV_OAUTH2_KEY = cfg/.dev-oauth2-signing-key.pem DEV_ENV = .env @@ -264,9 +257,9 @@ bin/prb: $(PROBOD_BOOTSTRAP_BIN): $(GO_BUILD) $(PROBOD_BOOTSTRAP_LDFLAGS) -o $(PROBOD_BOOTSTRAP_BIN) $(PROBOD_BOOTSTRAP_SRC) -.PHONY: bin/proboctl -bin/proboctl: - $(GO_BUILD) $(PROBOCTL_LDFLAGS) -o $(PROBOCTL_BIN) $(PROBOCTL_SRC) +.PHONY: $(PROBO_AGENT_BIN) +$(PROBO_AGENT_BIN): + $(GO_BUILD) $(PROBO_AGENT_LDFLAGS) -o $(PROBO_AGENT_BIN) $(PROBO_AGENT_SRC) .PHONY: @probo/emails @probo/emails: @@ -340,15 +333,6 @@ genmodels: ## Refresh LLM model registry from OpenRouter help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' -.PHONY: fix -fix: fix-go ## Auto-fix Go code - -.PHONY: fix-go -fix-go: generate embed ## Auto-fix Go code (format, go fix, lint --fix) - gofmt -w apps cmd packages pkg e2e - $(GO_BASE) fix -omitzero=false ./apps/... ./cmd/... ./packages/... ./pkg/... ./e2e/... - $(GOLINTCMD) run --fix ./... - .PHONY: fmt fmt: fmt-go ## Format Go code diff --git a/cmd/probo-agent/CHANGELOG.md b/cmd/probo-agent/CHANGELOG.md new file mode 100644 index 000000000..3d3c4cb6f --- /dev/null +++ b/cmd/probo-agent/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to the `probo-agent` device posture agent will be +documented in this file. + +## Unreleased + +## [0.1.0] - 2026-05-17 + +### Added + +- Initial release of the Probo device posture agent. +- `probo-agent install`, `uninstall`, `run`, `status`, `collect` CLI + commands. +- Managed OS service installation for macOS (`launchd`), Linux + (`systemd`), FreeBSD (`rc.d`), and Windows (Service Control Manager). +- v1 posture check set per OS: disk encryption, screen lock, firewall, + time sync, OS version, auto update, password policy, remote login. +- Enrollment / heartbeat / posture reporting against the new + `/api/agent/v1` Probo REST API. +- Auto-update: the agent periodically checks GitHub Releases for a + newer `probo-agent/v*` tag and self-installs it. The running binary + is swapped atomically and the OS service supervisor is asked to + restart via a dedicated exit code (`75`). +- Cosign signature verification of every release before installation: + `checksums.txt.bundle` is verified with `sigstore-go` against the + Sigstore public-good trust root, pinned to the GitHub Actions OIDC + identity for `release-probo-agent.yaml` on a tagged commit. Releases + without a Sigstore bundle, with an invalid bundle, or signed by a + different workflow are rejected without touching the running + binary. +- `probo-agent update [--check]` command for manual one-shot upgrade. +- `probo-agent install --no-auto-update` flag to opt out of automatic + upgrades; the flag is persisted in `config.json` as + `updates_disabled`. +- `probo-agent status` now reports the configured update interval and + whether auto-update is enabled. \ No newline at end of file diff --git a/cmd/probo-agent/VERSION b/cmd/probo-agent/VERSION new file mode 100644 index 000000000..6e8bf73aa --- /dev/null +++ b/cmd/probo-agent/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/probo-agent/installer/macos/Distribution.xml.tmpl b/cmd/probo-agent/installer/macos/Distribution.xml.tmpl new file mode 100644 index 000000000..1b7f0d530 --- /dev/null +++ b/cmd/probo-agent/installer/macos/Distribution.xml.tmpl @@ -0,0 +1,56 @@ + + + + Probo Device Posture Agent @@VERSION@@ + + + + + + + + + + + + + + + + probo-agent-component.pkg + + + + + + + + + + + + diff --git a/cmd/probo-agent/installer/macos/Resources/conclusion.html b/cmd/probo-agent/installer/macos/Resources/conclusion.html new file mode 100644 index 000000000..ca783f068 --- /dev/null +++ b/cmd/probo-agent/installer/macos/Resources/conclusion.html @@ -0,0 +1,62 @@ + + + + + Installation complete + + + +

Installation complete

+ +

+ The probo-agent binary is installed and the launchd + unit is loaded. If the installer found a pre-staged + configuration file at /tmp/probo-agent.conf, the + device is already enrolled and the agent is running. +

+ +

Enroll this device manually

+

+ If you installed without a pre-staged configuration, finish the + setup from a Terminal: +

+
sudo probo-agent install \
+    --server https://app.getprobo.com \
+    --enrollment-token <TOKEN>
+ +

Inspect the agent

+
sudo probo-agent status
+sudo probo-agent collect
+ +

Uninstall

+
sudo probo-agent uninstall
+ +

+ Logs are written to /var/log/probo-agent.log. The + installer's own log lives at + /var/log/probo-agent-install.log. +

+ + diff --git a/cmd/probo-agent/installer/macos/Resources/welcome.html b/cmd/probo-agent/installer/macos/Resources/welcome.html new file mode 100644 index 000000000..36c45d7dd --- /dev/null +++ b/cmd/probo-agent/installer/macos/Resources/welcome.html @@ -0,0 +1,62 @@ + + + + + Probo Device Posture Agent + + + +

Welcome to the Probo Device Posture Agent

+

+ This installer adds probo-agent to your Mac and starts it + as a system service. The agent reports device posture — disk + encryption, screen lock, firewall, OS version, and similar + signals — back to your Probo workspace over HTTPS. +

+ +

What the installer does

+
    +
  • Installs the probo-agent binary to + /usr/local/bin/probo-agent.
  • +
  • Registers the launchd unit + com.getprobo.agent in + /Library/LaunchDaemons.
  • +
  • Creates the persistent state directory + /var/lib/probo-agent (root-owned, mode 0700).
  • +
  • Enrolls the device automatically when an admin has pre-staged + /tmp/probo-agent.conf (typically via an MDM).
  • +
+ +

What you will need

+
    +
  • Administrator privileges on this Mac.
  • +
  • The Probo server URL (e.g. + https://app.getprobo.com).
  • +
  • A device enrollment token issued by a workspace administrator.
  • +
+ +

+ Click Continue to review the license, then + Install to proceed. +

+ + diff --git a/cmd/probo-agent/installer/macos/build.sh b/cmd/probo-agent/installer/macos/build.sh new file mode 100755 index 000000000..8ca8104ce --- /dev/null +++ b/cmd/probo-agent/installer/macos/build.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# +# Build a Probo device posture agent macOS installer (.pkg) from a +# pre-built `probo-agent` binary. +# +# Required arguments: +# --binary PATH Path to a compiled probo-agent binary. +# --arch ARCH Target architecture: amd64 or arm64. +# --version VER Agent version, e.g. 0.1.0. Defaults to the +# content of cmd/probo-agent/VERSION. +# --output PATH Output .pkg path. Defaults to +# dist/probo-agent_${VER}_${OS}.pkg. +# +# The resulting flat distribution package is unsigned. Apple +# Developer ID signing + notarization are out of scope for this +# script; consumers can chain `productsign` and `xcrun notarytool` +# afterwards. +# +# Must run on macOS: pkgbuild and productbuild are Apple-only tools. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)" + +BINARY="" +ARCH="" +VERSION="" +OUTPUT="" +IDENTIFIER="com.getprobo.agent" + +usage() { + sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0" +} + +while [ $# -gt 0 ]; do + case "$1" in + --binary) BINARY="$2"; shift 2 ;; + --arch) ARCH="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --output) OUTPUT="$2"; shift 2 ;; + --identifier) IDENTIFIER="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown flag: $1" >&2; usage >&2; exit 2 ;; + esac +done + +if [ -z "${BINARY}" ] || [ ! -x "${BINARY}" ]; then + echo "error: --binary is required and must be executable" >&2 + exit 2 +fi +case "${ARCH}" in + amd64) PKG_ARCH="x86_64" ;; + arm64) PKG_ARCH="arm64" ;; + "") echo "error: --arch (amd64|arm64) is required" >&2; exit 2 ;; + *) echo "error: unsupported --arch '${ARCH}' (want amd64 or arm64)" >&2; exit 2 ;; +esac +if [ -z "${VERSION}" ]; then + VERSION="$(cat "${REPO_ROOT}/cmd/probo-agent/VERSION")" +fi +if [ -z "${OUTPUT}" ]; then + mkdir -p "${REPO_ROOT}/dist" + OUTPUT="${REPO_ROOT}/dist/probo-agent_${VERSION}_darwin_${PKG_ARCH}.pkg" +fi + +if ! command -v pkgbuild >/dev/null 2>&1 || ! command -v productbuild >/dev/null 2>&1; then + echo "error: pkgbuild and productbuild are required (run on macOS)" >&2 + exit 1 +fi + +STAGE="$(mktemp -d -t probo-agent-pkg)" +trap 'rm -rf "${STAGE}"' EXIT + +PAYLOAD="${STAGE}/payload" +SCRIPTS="${STAGE}/scripts" +RESOURCES="${STAGE}/Resources" +mkdir -p "${PAYLOAD}/usr/local/bin" "${SCRIPTS}" "${RESOURCES}" + +install -m 0755 "${BINARY}" "${PAYLOAD}/usr/local/bin/probo-agent" + +install -m 0755 "${SCRIPT_DIR}/scripts/postinstall" "${SCRIPTS}/postinstall" + +cp "${SCRIPT_DIR}/Resources/welcome.html" "${RESOURCES}/welcome.html" +cp "${SCRIPT_DIR}/Resources/conclusion.html" "${RESOURCES}/conclusion.html" +cp "${REPO_ROOT}/LICENSE" "${RESOURCES}/license.txt" + +# Component package: payload + scripts only. +COMPONENT_PKG="${STAGE}/probo-agent-component.pkg" +pkgbuild \ + --root "${PAYLOAD}" \ + --scripts "${SCRIPTS}" \ + --identifier "${IDENTIFIER}" \ + --version "${VERSION}" \ + --install-location "/" \ + "${COMPONENT_PKG}" + +# Render Distribution.xml from its template. +DISTRIBUTION="${STAGE}/Distribution.xml" +sed \ + -e "s|@@VERSION@@|${VERSION}|g" \ + -e "s|@@PKG_ARCH@@|${PKG_ARCH}|g" \ + -e "s|@@HOST_ARCHS@@|${PKG_ARCH}|g" \ + "${SCRIPT_DIR}/Distribution.xml.tmpl" > "${DISTRIBUTION}" + +mkdir -p "$(dirname "${OUTPUT}")" +productbuild \ + --distribution "${DISTRIBUTION}" \ + --package-path "${STAGE}" \ + --resources "${RESOURCES}" \ + "${OUTPUT}" + +echo "Built ${OUTPUT}" diff --git a/cmd/probo-agent/installer/macos/scripts/postinstall b/cmd/probo-agent/installer/macos/scripts/postinstall new file mode 100755 index 000000000..2b8112019 --- /dev/null +++ b/cmd/probo-agent/installer/macos/scripts/postinstall @@ -0,0 +1,92 @@ +#!/bin/bash +# +# probo-agent macOS PKG postinstall script. +# +# Runs as root inside the macOS Installer.app sandbox after the +# payload has been laid down. Standard pkgbuild positional args: +# +# $1 = full path to the component package +# $2 = full path to the install location (selected target) +# $3 = mountpoint of the destination volume +# $4 = root directory ("/" for the target volume) +# +# We intentionally do not abort the install if enrollment fails: +# the binary is laid down regardless, and the operator can finish +# enrollment with `sudo probo-agent install ...` from Terminal. + +set -u + +LOG_FILE="/var/log/probo-agent-install.log" +BINARY="/usr/local/bin/probo-agent" +STATE_DIR="/var/lib/probo-agent" +CONF_FILE="/tmp/probo-agent.conf" + +# Mirror everything to the install log. We keep stdout/stderr open +# too so failures still surface in macOS Installer.app's log pane. +mkdir -p "$(dirname "${LOG_FILE}")" +exec > >(tee -a "${LOG_FILE}") 2>&1 + +echo +echo "=== probo-agent postinstall $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" +echo "pkg=$1 target=$2 mount=$3 root=$4" + +if [ ! -x "${BINARY}" ]; then + echo "error: expected binary not found at ${BINARY}" + exit 1 +fi + +mkdir -p "${STATE_DIR}" +chown root:wheel "${STATE_DIR}" +chmod 0700 "${STATE_DIR}" + +# An admin (or MDM) may stage /tmp/probo-agent.conf to drive an +# unattended enrollment. Recognized keys (shell-style): +# +# PROBO_SERVER_URL=https://app.getprobo.com +# PROBO_ENROLLMENT_TOKEN= +# PROBO_NO_AUTO_UPDATE=true +# +# We source the file in a subshell so a malformed line can never +# leak variables into our env, then validate the values we care +# about. +if [ -f "${CONF_FILE}" ]; then + echo "Found ${CONF_FILE}, attempting unattended enrollment." + + eval "$( + set -e + # shellcheck source=/dev/null + . "${CONF_FILE}" + printf 'CONF_SERVER=%q\n' "${PROBO_SERVER_URL:-}" + printf 'CONF_TOKEN=%q\n' "${PROBO_ENROLLMENT_TOKEN:-}" + printf 'CONF_NOUPDATE=%q\n' "${PROBO_NO_AUTO_UPDATE:-}" + )" + + if [ -z "${CONF_SERVER}" ] || [ -z "${CONF_TOKEN}" ]; then + echo "warning: ${CONF_FILE} is missing PROBO_SERVER_URL or PROBO_ENROLLMENT_TOKEN; skipping enrollment." + else + EXTRA_FLAGS=() + case "${CONF_NOUPDATE}" in + 1|true|TRUE|yes|YES) EXTRA_FLAGS+=("--no-auto-update") ;; + esac + + if "${BINARY}" install \ + --server "${CONF_SERVER}" \ + --enrollment-token "${CONF_TOKEN}" \ + "${EXTRA_FLAGS[@]}"; then + echo "Device enrolled and service installed." + else + echo "warning: probo-agent install failed; the binary is in place and can be re-run by an admin." + fi + fi + + # The token in the conf file is sensitive; clear it whatever + # the outcome so a successful install does not leave secrets + # in /tmp. + rm -f "${CONF_FILE}" +else + echo "No ${CONF_FILE} found; skipping automatic enrollment." + echo "Finish setup with: sudo ${BINARY} install --server --enrollment-token " +fi + +echo "=== postinstall done ===" +exit 0 diff --git a/cmd/probo-agent/main.go b/cmd/probo-agent/main.go new file mode 100644 index 000000000..1d295b8ce --- /dev/null +++ b/cmd/probo-agent/main.go @@ -0,0 +1,372 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/deviceagent" + "go.probo.inc/probo/pkg/deviceagent/service" + "go.probo.inc/probo/pkg/deviceagent/update" + + // Side-effect import: registers per-OS posture checks. + _ "go.probo.inc/probo/pkg/deviceagent/checks" +) + +var version = "dev" + +// restartExitCode signals the OS service supervisor that the agent +// process exited because its binary was replaced and needs to be +// restarted. The value matches sysexits.h's EX_TEMPFAIL and is +// whitelisted in the systemd unit so the unit does not enter the +// "failed" state on a normal self-update. +const restartExitCode = 75 + +func main() { + // Best-effort cleanup of a previous-version binary left aside by + // a Windows self-update. No-op on Unix. + if exe, err := os.Executable(); err == nil { + update.CleanupAfterRestart(exe) + } + + if err := newRootCmd().Execute(); err != nil { + if errors.Is(err, deviceagent.ErrRestartRequired) { + os.Exit(restartExitCode) + } + fmt.Fprintf(os.Stderr, "probo-agent: %s\n", err) + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "probo-agent", + Short: "Probo device posture agent", + Long: "probo-agent runs as a managed OS service, reporting device posture to Probo.", + SilenceUsage: true, + SilenceErrors: true, + Version: version, + } + + root.PersistentFlags().StringP("dir", "d", "", "agent config / keystore directory (defaults to platform-specific path)") + + root.AddCommand(newInstallCmd()) + root.AddCommand(newUninstallCmd()) + root.AddCommand(newRunCmd()) + root.AddCommand(newStatusCmd()) + root.AddCommand(newCollectCmd()) + root.AddCommand(newUpdateCmd()) + return root +} + +// newUpdater returns an Updater scoped to the running binary, or nil +// when self-update cannot be performed (unresolvable binary path). +// +// dir is the agent state directory, used to host the Sigstore TUF +// metadata cache for cosign bundle verification. +func newUpdater(logger *log.Logger, dir string) *update.Updater { + exePath, err := os.Executable() + if err != nil || exePath == "" { + return nil + } + + return update.New( + version, + exePath, + fmt.Sprintf("probo-agent/%s", version), + filepath.Join(dir, "sigstore-cache"), + logger, + ) +} + +func resolveDir(cmd *cobra.Command) string { + dir, _ := cmd.Flags().GetString("dir") + if dir != "" { + return dir + } + + return deviceagent.DefaultConfigDir() +} + +func newAgentLogger() *log.Logger { + return log.NewLogger( + log.WithName("probo-agent"), + log.WithOutput(os.Stderr), + ) +} + +func newInstallCmd() *cobra.Command { + var ( + serverURL string + enrollmentToken string + skipService bool + noAutoUpdate bool + ) + + cmd := &cobra.Command{ + Use: "install", + Short: "Enroll this device and install the agent as a managed OS service", + RunE: func(cmd *cobra.Command, args []string) error { + if serverURL == "" { + return errors.New("--server is required") + } + + if enrollmentToken == "" { + if v := os.Getenv("PROBO_TOKEN"); v != "" { + enrollmentToken = v + } + } + + if enrollmentToken == "" { + return errors.New("--enrollment-token (or PROBO_TOKEN env var) is required") + } + + dir := resolveDir(cmd) + ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second) + defer cancel() + + agent := deviceagent.New(dir, version, newAgentLogger()) + resp, err := agent.EnrollNewDevice(ctx, strings.TrimRight(serverURL, "/"), enrollmentToken) + if err != nil { + return fmt.Errorf("enrollment failed: %w", err) + } + + fmt.Printf("Enrolled device %s (heartbeat %ds, posture %ds)\n", + resp.DeviceID, resp.HeartbeatSeconds, resp.PostureSeconds) + + if noAutoUpdate { + if err := persistAutoUpdate(dir, false); err != nil { + return fmt.Errorf("cannot persist auto-update preference: %w", err) + } + fmt.Println("Auto-update disabled.") + } + + if skipService { + fmt.Println("Service installation skipped (--skip-service).") + return nil + } + + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot resolve current executable path: %w", err) + } + + if err := service.Install( + service.Config{ + ExePath: exePath, + Dir: dir, + }, + ); err != nil { + return fmt.Errorf("cannot install OS service: %w", err) + } + + fmt.Println("Service installed and started.") + return nil + }, + } + + cmd.Flags().StringVar(&serverURL, "server", "", "Probo server base URL (e.g. https://app.getprobo.com)") + cmd.Flags().StringVar(&enrollmentToken, "enrollment-token", "", "device enrollment token issued by an admin") + cmd.Flags().BoolVar(&skipService, "skip-service", false, "register the device but do not install the OS service") + cmd.Flags().BoolVar(&noAutoUpdate, "no-auto-update", false, "disable automatic upgrades of the agent binary") + + return cmd +} + +// persistAutoUpdate flips the UpdatesDisabled flag in the agent's +// on-disk config without disturbing other fields. +func persistAutoUpdate(dir string, enabled bool) error { + cfg, err := deviceagent.LoadConfig(dir) + if err != nil { + return err + } + + cfg.UpdatesDisabled = !enabled + return deviceagent.SaveConfig(dir, cfg) +} + +func newUninstallCmd() *cobra.Command { + return &cobra.Command{ + Use: "uninstall", + Short: "Stop the service, unenroll this device, and remove local state", + RunE: func(cmd *cobra.Command, args []string) error { + dir := resolveDir(cmd) + + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + agent := deviceagent.New(dir, version, newAgentLogger()) + if err := agent.Unenroll(ctx); err != nil { + fmt.Fprintf(os.Stderr, "warning: unenroll failed: %v\n", err) + } + + if err := service.Uninstall(service.Config{Dir: dir}); err != nil { + fmt.Fprintf(os.Stderr, "warning: service uninstall failed: %v\n", err) + } + + _ = os.Remove(deviceagent.ConfigPath(dir)) + fmt.Println("Uninstalled.") + + return nil + }, + } +} + +func newRunCmd() *cobra.Command { + return &cobra.Command{ + Use: "run", + Short: "Run the agent in the foreground (used by the OS service unit)", + RunE: func(cmd *cobra.Command, args []string) error { + dir := resolveDir(cmd) + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + logger := newAgentLogger() + agent := deviceagent.New(dir, version, logger) + agent.Updater = newUpdater(logger, dir) + err := agent.Run(ctx) + if errors.Is(err, context.Canceled) { + return nil + } + + return err + }, + } +} + +func newStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Print the agent's local state", + RunE: func(cmd *cobra.Command, args []string) error { + dir := resolveDir(cmd) + cfg, err := deviceagent.LoadConfig(dir) + if err != nil { + return err + } + + haveKey := true + if _, err := deviceagent.LoadAPIKey(dir); err != nil { + haveKey = false + } + + fmt.Printf("Server URL: %s\n", cfg.ServerURL) + fmt.Printf("Device ID: %s\n", cfg.DeviceID) + fmt.Printf("Heartbeat interval: %s\n", cfg.HeartbeatInterval) + fmt.Printf("Posture interval: %s\n", cfg.PostureInterval) + fmt.Printf("Update interval: %s\n", cfg.UpdateInterval) + fmt.Printf("Auto-update enabled: %v\n", !cfg.UpdatesDisabled) + fmt.Printf("API key on disk: %v\n", haveKey) + fmt.Printf("Config directory: %s\n", dir) + + return nil + }, + } +} + +func newCollectCmd() *cobra.Command { + var ( + once bool + asJSON bool + printDir bool + ) + cmd := &cobra.Command{ + Use: "collect", + Short: "Run the posture check set once and print results (no server push)", + RunE: func(cmd *cobra.Command, args []string) error { + dir := resolveDir(cmd) + if printDir { + fmt.Println(dir) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + agent := deviceagent.New(dir, version, newAgentLogger()) + results := agent.CollectOnce(ctx) + + if asJSON { + return json.NewEncoder(os.Stdout).Encode(results) + } + + for _, r := range results { + fmt.Printf("%-20s %-15s %v\n", r.CheckKey, r.Status, r.Evidence) + } + + return nil + }, + } + cmd.Flags().BoolVar(&once, "once", true, "(default true) run the check set once and exit") + cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON instead of the human-readable table") + cmd.Flags().BoolVar(&printDir, "print-dir", false, "print the resolved agent dir before the results") + + return cmd +} + +func newUpdateCmd() *cobra.Command { + var checkOnly bool + cmd := &cobra.Command{ + Use: "update", + Short: "Check GitHub for a newer agent release and install it in place", + RunE: func(cmd *cobra.Command, args []string) error { + dir := resolveDir(cmd) + logger := newAgentLogger() + updater := newUpdater(logger, dir) + if updater == nil { + return errors.New("cannot resolve current executable path") + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) + defer cancel() + + rel, err := updater.CheckLatest(ctx) + if err != nil { + if errors.Is(err, update.ErrNoUpdateAvailable) { + fmt.Printf("probo-agent is up to date (version %s).\n", version) + return nil + } + return fmt.Errorf("cannot check for updates: %w", err) + } + + fmt.Printf("Update available: %s -> %s\n", version, rel.Version) + if checkOnly { + return nil + } + + if err := updater.Apply(ctx, rel); err != nil { + return fmt.Errorf("cannot apply update: %w", err) + } + + fmt.Printf("Installed probo-agent %s. Restart the service to use it.\n", rel.Version) + return nil + }, + } + + cmd.Flags().BoolVar(&checkOnly, "check", false, "only print the available version, do not install it") + + return cmd +} diff --git a/go.mod b/go.mod index e54812d8a..463fe7e58 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,6 @@ require ( github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 - github.com/go-git/go-git/v5 v5.19.1 github.com/jackc/pgx/v5 v5.9.2 github.com/jhillyerd/enmime v1.3.0 github.com/microcosm-cc/bluemonday v1.0.27 @@ -31,6 +30,7 @@ require ( github.com/pires/go-proxyproto v0.12.0 github.com/prometheus/client_golang v1.23.2 github.com/scim2/filter-parser/v2 v2.2.1 + github.com/sigstore/sigstore-go v1.1.4 github.com/stretchr/testify v1.11.1 github.com/vektah/gqlparser/v2 v2.5.33 github.com/vikstrous/dataloadgen v0.0.10 @@ -51,13 +51,12 @@ require ( ) require ( - dario.cat/mergo v1.0.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect @@ -68,35 +67,61 @@ require ( github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/cloudflare/circl v1.6.3 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.9.0 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/go-openapi/analysis v0.24.1 // indirect + github.com/go-openapi/errors v0.22.4 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.3 // indirect + github.com/go-openapi/loads v0.23.2 // indirect + github.com/go-openapi/runtime v0.29.2 // indirect + github.com/go-openapi/spec v0.22.1 // indirect + github.com/go-openapi/strfmt v0.25.0 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/cmdutils v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/fileutils v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/mangling v0.25.4 // indirect + github.com/go-openapi/swag/netutils v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-openapi/validate v0.25.1 // indirect + github.com/google/certificate-transparency-go v1.3.2 // indirect + github.com/google/go-containerregistry v0.20.7 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/in-toto/attestation v1.1.2 // indirect + github.com/in-toto/in-toto-golang v0.9.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect - github.com/skeema/knownhosts v1.3.1 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/protobuf-specs v0.5.0 // indirect + github.com/sigstore/rekor v1.4.3 // indirect + github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect + github.com/sigstore/sigstore v1.10.0 // indirect + github.com/sigstore/timestamp-authority/v2 v2.0.3 // indirect + github.com/theupdateframework/go-tuf/v2 v2.3.0 // indirect + github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.mongodb.org/mongo-driver v1.17.6 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect ) require ( @@ -122,10 +147,10 @@ require ( github.com/chromedp/sysutil v1.1.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/di-wu/parser v0.3.0 // indirect github.com/di-wu/xsd-datetime v1.0.0 // indirect - github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/dnephin/pflag v1.0.7 // indirect github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 github.com/fatih/color v1.19.0 // indirect @@ -165,7 +190,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/olekukonko/tablewriter v1.1.3 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect @@ -191,7 +216,7 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/mod v0.34.0 // indirect + golang.org/x/mod v0.34.0 golang.org/x/net v0.53.0 golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 diff --git a/go.sum b/go.sum index cb0208f1b..66f19afdc 100644 --- a/go.sum +++ b/go.sum @@ -1,40 +1,61 @@ +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/kms v1.23.2 h1:4IYDQL5hG4L+HzJBhzejUySoUOheh3Lk5YT4PCyyW6k= +cloud.google.com/go/kms v1.23.2/go.mod h1:rZ5kK0I7Kn9W4erhYVoIRPtpizjunlrfU4fUkumUp8g= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY= codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk= -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk= github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anthropics/anthropic-sdk-go v1.38.0 h1:bA4DcK+91gorIX+5VTONnynyt9LRU4nnN6rRQ+j/NIg= github.com/anthropics/anthropic-sdk-go v1.38.0/go.mod h1:d288C1L+m74OYuYBvc4UFtR1Q8J0gC55oYDh2t+XxdI= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= +github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= +github.com/aws/aws-sdk-go-v2/config v1.31.20 h1:/jWF4Wu90EhKCgjTdy1DGxcbcbNrjfBHvksEL79tfQc= +github.com/aws/aws-sdk-go-v2/config v1.31.20/go.mod h1:95Hh1Tc5VYKL9NJ7tAkDcqeKt+MCXQB1hQZaRdJIZE0= github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= @@ -43,6 +64,8 @@ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2c github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.6 h1:Wbo1WlWyGaAXlr6C7OGXq9avbdJhIV9cQ4M6E34b5x8= @@ -55,8 +78,16 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/ku github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= +github.com/aws/aws-sdk-go-v2/service/kms v1.48.2 h1:aL8Y/AbB6I+uw0MjLbdo68NQ8t5lNs3CY3S848HpETk= +github.com/aws/aws-sdk-go-v2/service/kms v1.48.2/go.mod h1:VJcNH6BLr+3VJwinRKdotLOMglHO8mIKlD3ea5c7hbw= github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1 h1:mxuT1xE+dI54NW3RkNjP8DUT5HXqbkiAFvfdyDFwE5c= github.com/aws/aws-sdk-go-v2/service/s3 v1.100.1/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -73,12 +104,16 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow= github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= @@ -125,26 +160,31 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= +github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/di-wu/parser v0.3.0 h1:NMOvy5ifswgt4gsdhySVcKOQtvjC43cHZIfViWctqQY= github.com/di-wu/parser v0.3.0/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo= github.com/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI= github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww= -github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c= github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c= github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= @@ -153,10 +193,6 @@ github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= -github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= @@ -167,20 +203,12 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3 h1:bn2ml0JxH4DtQqi+CZ+ZPUBo1i3K+4xD0rHczpvpqFk= github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3/go.mod h1:njybYNBd7EDyRMan05ticVQjPP6ThiuyCDbtRh9+e8A= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= -github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= -github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -188,8 +216,58 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= -github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-openapi/analysis v0.24.1 h1:Xp+7Yn/KOnVWYG8d+hPksOYnCYImE3TieBa7rBOesYM= +github.com/go-openapi/analysis v0.24.1/go.mod h1:dU+qxX7QGU1rl7IYhBC8bIfmWQdX4Buoea4TGtxXY84= +github.com/go-openapi/errors v0.22.4 h1:oi2K9mHTOb5DPW2Zjdzs/NIvwi2N3fARKaTJLdNabaM= +github.com/go-openapi/errors v0.22.4/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= +github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= +github.com/go-openapi/loads v0.23.2 h1:rJXAcP7g1+lWyBHC7iTY+WAF0rprtM+pm8Jxv1uQJp4= +github.com/go-openapi/loads v0.23.2/go.mod h1:IEVw1GfRt/P2Pplkelxzj9BYFajiWOtY2nHZNj4UnWY= +github.com/go-openapi/runtime v0.29.2 h1:UmwSGWNmWQqKm1c2MGgXVpC2FTGwPDQeUsBMufc5Yj0= +github.com/go-openapi/runtime v0.29.2/go.mod h1:biq5kJXRJKBJxTDJXAa00DOTa/anflQPhT0/wmjuy+0= +github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k= +github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA= +github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ= +github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= +github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= +github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= +github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= +github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= +github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= +github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= +github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= @@ -200,24 +278,30 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= +github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= +github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= +github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= @@ -228,16 +312,44 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8= github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0= github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= +github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM= +github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU= +github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= @@ -252,34 +364,32 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8= +github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw= github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/letsencrypt/boulder v0.20251110.0 h1:J8MnKICeilO91dyQ2n5eBbab24neHzUpYMUIOdOtbjc= +github.com/letsencrypt/boulder v0.20251110.0/go.mod h1:ogKCJQwll82m7OVHWyTuf8eeFCjuzdRQlgnZcCl0V+8= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -293,8 +403,12 @@ github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3Ry github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -305,24 +419,29 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pdfcpu/pdfcpu v0.12.0 h1:GonU1Ub45kKo/LdakJhaBA0NTTvBA7KGs3bfmEU1osU= github.com/pdfcpu/pdfcpu v0.12.0/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps= github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM= github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI= -github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -338,17 +457,44 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= github.com/scim2/filter-parser/v2 v2.2.1 h1:akm05YVRosO9sy68mTOfCXmvlqlGzFecA2GA0K8kFA4= github.com/scim2/filter-parser/v2 v2.2.1/go.mod h1:P8WG12+x8opr+QHsOqOXYl0dJ3ja2g+SuidZJWD0uPQ= +github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL5qTdn9lR8XKHf4RUyG1Sx3g= +github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY= +github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= +github.com/sigstore/rekor v1.4.3 h1:2+aw4Gbgumv8vYM/QVg6b+hvr4x4Cukur8stJrVPKU0= +github.com/sigstore/rekor v1.4.3/go.mod h1:o0zgY087Q21YwohVvGwV9vK1/tliat5mfnPiVI3i75o= +github.com/sigstore/rekor-tiles/v2 v2.0.1 h1:1Wfz15oSRNGF5Dzb0lWn5W8+lfO50ork4PGIfEKjZeo= +github.com/sigstore/rekor-tiles/v2 v2.0.1/go.mod h1:Pjsbhzj5hc3MKY8FfVTYHBUHQEnP0ozC4huatu4x7OU= +github.com/sigstore/sigstore v1.10.0 h1:lQrmdzqlR8p9SCfWIpFoGUqdXEzJSZT2X+lTXOMPaQI= +github.com/sigstore/sigstore v1.10.0/go.mod h1:Ygq+L/y9Bm3YnjpJTlQrOk/gXyrjkpn3/AEJpmk1n9Y= +github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= +github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.0 h1:UOHpiyezCj5RuixgIvCV3QyuxIGQT+N6nGZEXA7OTTY= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.0/go.mod h1:U0CZmA2psabDa8DdiV7yXab0AHODzfKqvD2isH7Hrvw= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.0 h1:fq4+8Y4YadxeF8mzhoMRPZ1mVvDYXmI3BfS0vlkPT7M= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.0/go.mod h1:u05nqPWY05lmcdHhv2lPaWTH3FGUhJzO7iW2hbboK3Q= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.0 h1:iUEf5MZYOuXGnXxdF/WrarJrk0DTVHqeIOjYdtpVXtc= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.0/go.mod h1:i6vg5JfEQix46R1rhQlrKmUtJoeH91drltyYOJEk1T4= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.0 h1:dUvPv/MP23ZPIXZUW45kvCIgC0ZRfYxEof57AB6bAtU= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.0/go.mod h1:fR/gDdPvJWGWL70/NgBBIL1O0/3Wma6JHs3tSSYg3s4= +github.com/sigstore/timestamp-authority/v2 v2.0.3 h1:sRyYNtdED/ttLCMdaYnwpf0zre1A9chvjTnCmWWxN8Y= +github.com/sigstore/timestamp-authority/v2 v2.0.3/go.mod h1:mDaHxkt3HmZYoIlwYj4QWo0RUr7VjYU52aVO5f5Qb3I= github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE= github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -361,13 +507,15 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.3.0 h1:gt3X8xT8qu/HT4w+n1jgv+p7koi5ad8XEkLXXZqG9AA= +github.com/theupdateframework/go-tuf/v2 v2.3.0/go.mod h1:xW8yNvgXRncmovMLvBxKwrKpsOwJZu/8x+aB0KtFcdw= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -379,6 +527,20 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go-hcvault/v2 v2.3.0 h1:6nAX1aRGnkg2SEUMwO5toB2tQkP0Jd6cbmZ/K5Le1V0= +github.com/tink-crypto/tink-go-hcvault/v2 v2.3.0/go.mod h1:HOC5NWW1wBI2Vke1FGcRBvDATkEYE7AUDiYbXqi2sBw= +github.com/tink-crypto/tink-go/v2 v2.5.0 h1:B8KLF6AofxdBIE4UJIaFbmoj5/1ehEtt7/MmzfI4Zpw= +github.com/tink-crypto/tink-go/v2 v2.5.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ2LiAUV+/RjckMyq9sXudfrPSuCY4FuPC1NyAw= +github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI= github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E= @@ -387,14 +549,14 @@ github.com/vikstrous/dataloadgen v0.0.10 h1:x07XAeEjIWXohvcjRvE72KY8pV5A3sTbKEFm github.com/vikstrous/dataloadgen v0.0.10/go.mod h1:8vuQVpBH0ODbMKAPUdCAPcOGezoTIhgAjgex51t4vbg= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= +github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712 h1:J5ccbcxFuwxe6Oa9fVi9FqQOo+n17ni4wbl9t4NuEzc= go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712/go.mod h1:fnIIvKO9QnsyLO3ZJLJT3r8KZv/p0FOeT5eZKilYWXg= go.gearno.de/kit v0.10.0 h1:hWQrGdQog5mJQTwNYOnMQDxwPnAZectedP1Xj3NvIH4= @@ -403,8 +565,12 @@ go.gearno.de/x/panicf v0.1.1 h1:E3Cr9NB8Ry2EsvEG/1eHr7kplP3tEjTf5d56dTX64VQ= go.gearno.de/x/panicf v0.1.1/go.mod h1:VnB8oF0UefMZcYeD4v+Wk4U5Z1uza7PHLlhT2CbNEbU= go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c h1:rIVWwnNxHYu9aZhHkptXlNYTBJbY4ccaIAYjztVeaDc= go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c/go.mod h1:k3GtgnI5X9dl8FlqaNYkCkil7/iACQdFOromU/H4u6I= +go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -425,49 +591,46 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.probo.inc/mcpgen v0.0.0-20260428172408-1496ba9b4619 h1:LHOdoF7kYRXFtSP97eWpF1dIf0dBLrunRLOeU/pXt9c= go.probo.inc/mcpgen v0.0.0-20260428172408-1496ba9b4619/go.mod h1:HunWQGqLdMocExJh4tWaX7p+uRZ9GlKvBvOXHaFW6vM= +go.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA= +go.step.sm/crypto v0.74.0/go.mod h1:UoXqCAJjjRgzPte0Llaqen7O9P7XjPmgjgTHQGkKCDk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -485,14 +648,10 @@ google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0= gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -504,5 +663,9 @@ gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo= gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/pkg/deviceagent/agent.go b/pkg/deviceagent/agent.go new file mode 100644 index 000000000..bb869e8de --- /dev/null +++ b/pkg/deviceagent/agent.go @@ -0,0 +1,583 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "context" + "errors" + "fmt" + "math/rand" + "time" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/deviceagent/checks" + "go.probo.inc/probo/pkg/deviceagent/update" +) + +const ( + hostInfoRefreshInterval = 6 * time.Hour + perCheckTimeout = 15 * time.Second + + pendingFlushBackoffMin = 15 * time.Second + pendingFlushBackoffMax = 30 * time.Minute + + updateCheckTimeout = 10 * time.Minute +) + +// ErrRestartRequired is returned by Agent.Run after a successful +// in-place upgrade of the agent binary. Callers should exit cleanly +// so the OS service supervisor relaunches the new binary. +var ErrRestartRequired = errors.New("agent: restart required after self-update") + +// Agent runs enrollment, heartbeat, and posture sync loops. +type Agent struct { + Dir string + Version string + UserAgent string + Logger *log.Logger + + // Updater performs binary self-update. When nil, auto-update is + // disabled (e.g. dev builds, --no-auto-update at install time). + Updater *update.Updater + + cfg *Config + client *Client + revoked bool + + collectHostInfo func() HostInfo + hostInfo HostInfo + hostInfoCollectedAt time.Time + + now func() time.Time + randInt63n func(int64) int64 + + pendingFlushBackoff time.Duration + pendingFlushRetryAt time.Time +} + +// New creates an agent instance. +func New(dir, version string, logger *log.Logger) *Agent { + if logger == nil { + logger = log.NewLogger(log.WithName("device-agent")) + } + return &Agent{ + Dir: dir, + Version: version, + UserAgent: fmt.Sprintf("probo-agent/%s", version), + Logger: logger, + collectHostInfo: func() HostInfo { + return CollectHostInfo() + }, + now: time.Now, + randInt63n: func(n int64) int64 { + return rand.Int63n(n) + }, + } +} + +// EnrollNewDevice enrolls and persists local config and key state. +func (a *Agent) EnrollNewDevice( + ctx context.Context, + serverURL, enrollmentToken string, +) (*EnrollResponse, error) { + if serverURL == "" { + return nil, errors.New("server URL is required") + } + if enrollmentToken == "" { + return nil, errors.New("enrollment token is required") + } + + host := a.currentHostInfo(time.Now()) + client := NewClient(serverURL, "", a.UserAgent) + + resp, err := client.Enroll( + ctx, + EnrollRequest{ + EnrollmentToken: enrollmentToken, + HardwareUUID: host.HardwareUUID, + SerialNumber: host.SerialNumber, + Hostname: host.Hostname, + Platform: host.Platform, + OSVersion: host.OSVersion, + AgentVersion: a.Version, + }, + ) + if err != nil { + return nil, fmt.Errorf("cannot enroll device: %w", err) + } + + cfg := &Config{ + ServerURL: serverURL, + DeviceID: resp.DeviceID, + HeartbeatInterval: time.Duration(resp.HeartbeatSeconds) * time.Second, + PostureInterval: time.Duration(resp.PostureSeconds) * time.Second, + } + if err := SaveConfig(a.Dir, cfg); err != nil { + return nil, fmt.Errorf("cannot save config: %w", err) + } + if err := SaveAPIKey(a.Dir, resp.APIKey); err != nil { + return nil, fmt.Errorf("cannot save api key: %w", err) + } + if err := clearPendingPostureBatches(a.Dir); err != nil { + a.Logger.Warn("cannot clear pending posture queue after enrollment", log.Error(err)) + } + + a.cfg = cfg + a.client = NewClient(serverURL, resp.APIKey, a.UserAgent) + return resp, nil +} + +// LoadLocalState loads persisted config and API key. +func (a *Agent) LoadLocalState() error { + cfg, err := LoadConfig(a.Dir) + if err != nil { + return err + } + key, err := LoadAPIKey(a.Dir) + if err != nil { + return err + } + if cfg.ServerURL == "" { + return errors.New("config has no server URL") + } + a.cfg = cfg + a.client = NewClient(cfg.ServerURL, key, a.UserAgent) + return nil +} + +// Run starts the long-running heartbeat and posture loops. It returns +// ErrRestartRequired after a successful self-update so the caller can +// exit cleanly and let the service supervisor restart the new binary. +func (a *Agent) Run(ctx context.Context) error { + if a.cfg == nil || a.client == nil { + if err := a.LoadLocalState(); err != nil { + return fmt.Errorf("cannot load agent state: %w", err) + } + } + + a.Logger = a.Logger.With(log.String("device_id", a.cfg.DeviceID)) + + a.Logger.InfoCtx( + ctx, + "agent starting", + log.String("server", a.cfg.ServerURL), + log.Duration("heartbeat_interval", a.cfg.HeartbeatInterval), + log.Duration("posture_interval", a.cfg.PostureInterval), + log.Duration("host_info_refresh_interval", hostInfoRefreshInterval), + log.Bool("auto_update_enabled", a.autoUpdateEnabled()), + log.Duration("update_interval", a.cfg.UpdateInterval), + ) + + _, _ = a.doHeartbeat(ctx) + a.doPostures(ctx) + + heartbeatTicker := time.NewTicker(a.cfg.HeartbeatInterval) + defer heartbeatTicker.Stop() + postureTicker := time.NewTicker(a.cfg.PostureInterval) + defer postureTicker.Stop() + + updateTicker, updateChan := a.newUpdateTicker() + if updateTicker != nil { + defer updateTicker.Stop() + } + + for { + select { + case <-ctx.Done(): + a.Logger.InfoCtx(ctx, "agent stopping", log.Error(ctx.Err())) + return ctx.Err() + case <-heartbeatTicker.C: + heartbeatIntervalChanged, postureIntervalChanged := a.doHeartbeat(ctx) + if heartbeatIntervalChanged { + heartbeatTicker.Reset(a.cfg.HeartbeatInterval) + } + if postureIntervalChanged { + postureTicker.Reset(a.cfg.PostureInterval) + } + case <-postureTicker.C: + a.doPostures(ctx) + case <-updateChan: + if a.tryAutoUpdate(ctx) { + return ErrRestartRequired + } + } + } +} + +// autoUpdateEnabled reports whether the agent should periodically +// self-update. Disabled when no Updater is wired in or the operator +// flipped UpdatesDisabled in config. +func (a *Agent) autoUpdateEnabled() bool { + if a.cfg == nil { + return false + } + if a.cfg.UpdatesDisabled { + return false + } + return a.Updater != nil +} + +// newUpdateTicker returns the periodic ticker used to drive +// auto-update checks. When auto-update is disabled we return a +// (nil, nil) channel pair so the select in Run never fires. +func (a *Agent) newUpdateTicker() (*time.Ticker, <-chan time.Time) { + if !a.autoUpdateEnabled() { + return nil, nil + } + + t := time.NewTicker(a.cfg.UpdateInterval) + return t, t.C +} + +// tryAutoUpdate runs one auto-update cycle and returns true when the +// agent binary was successfully replaced and the process should +// restart. +func (a *Agent) tryAutoUpdate(parent context.Context) bool { + if !a.autoUpdateEnabled() { + return false + } + + ctx, cancel := context.WithTimeout(parent, updateCheckTimeout) + defer cancel() + + rel, err := a.Updater.CheckLatest(ctx) + if err != nil { + if errors.Is(err, update.ErrNoUpdateAvailable) { + a.Logger.DebugCtx(ctx, "no agent update available") + return false + } + a.Logger.WarnCtx(ctx, "agent update check failed", log.Error(err)) + return false + } + + a.Logger.InfoCtx( + ctx, + "agent update available, applying", + log.String("from_version", a.Version), + log.String("to_version", rel.Version), + ) + + if err := a.Updater.Apply(ctx, rel); err != nil { + a.Logger.ErrorCtx(ctx, "cannot apply agent update", log.Error(err), log.String("to_version", rel.Version)) + return false + } + + return true +} + +// CollectOnce executes checks without pushing results to the server. +func (a *Agent) CollectOnce(ctx context.Context) []checks.Result { + now := time.Now() + results := make([]checks.Result, 0) + for _, c := range checks.All() { + select { + case <-ctx.Done(): + return results + default: + } + checkCtx, cancel := context.WithTimeout(ctx, perCheckTimeout) + r := c.Run(checkCtx) + cancel() + if r.ObservedAt.IsZero() { + r.ObservedAt = now + } + if r.CheckKey == "" { + r.CheckKey = c.Key() + } + results = append(results, r) + } + return results +} + +// Unenroll revokes server state best-effort and clears local credentials. +func (a *Agent) Unenroll(ctx context.Context) error { + if a.cfg == nil || a.client == nil { + if err := a.LoadLocalState(); err != nil { + return err + } + } + if err := a.client.Unenroll(ctx); err != nil { + a.Logger.WarnCtx( + ctx, + "unenroll server-side revocation failed, continuing with local wipe", + log.Error(err), + ) + } + if err := DeleteAPIKey(a.Dir); err != nil { + return err + } + if err := clearPendingPostureBatches(a.Dir); err != nil { + return err + } + return nil +} + +func (a *Agent) doHeartbeat(ctx context.Context) (bool, bool) { + if a.revoked { + return false, false + } + + oldHeartbeatInterval := a.cfg.HeartbeatInterval + oldPostureInterval := a.cfg.PostureInterval + + host := a.currentHostInfo(time.Now()) + resp, err := a.client.Heartbeat( + ctx, + HeartbeatRequest{ + AgentVersion: a.Version, + Hostname: host.Hostname, + OSVersion: host.OSVersion, + }, + ) + if err != nil { + a.Logger.ErrorCtx(ctx, "heartbeat failed", log.Error(err)) + if IsUnauthorized(err) { + a.handleUnauthorized() + } + return false, false + } + + if resp.HeartbeatSeconds > 0 { + next := normalizeHeartbeatInterval(time.Duration(resp.HeartbeatSeconds) * time.Second) + if next != a.cfg.HeartbeatInterval { + a.cfg.HeartbeatInterval = next + } + } + if resp.PostureSeconds > 0 { + next := normalizePostureInterval(time.Duration(resp.PostureSeconds) * time.Second) + if next != a.cfg.PostureInterval { + a.cfg.PostureInterval = next + } + } + a.flushQueuedPostures(ctx) + + heartbeatChanged := a.cfg.HeartbeatInterval != oldHeartbeatInterval + postureChanged := a.cfg.PostureInterval != oldPostureInterval + if heartbeatChanged || postureChanged { + if err := SaveConfig(a.Dir, a.cfg); err != nil { + a.Logger.WarnCtx( + ctx, + "cannot persist updated agent intervals", + log.Error(err), + ) + } + } + + return heartbeatChanged, postureChanged +} + +func (a *Agent) doPostures(ctx context.Context) { + if a.revoked { + return + } + + start := time.Now() + results := a.CollectOnce(ctx) + if len(results) == 0 { + return + } + var ( + passCount int + failCount int + unknownCount int + notApplicableCount int + ) + for _, r := range results { + switch r.Status { + case checks.StatusPass: + passCount++ + case checks.StatusFail: + failCount++ + case checks.StatusUnknown: + unknownCount++ + case checks.StatusNotApplicable: + notApplicableCount++ + } + } + a.Logger.InfoCtx( + ctx, + "posture checks completed", + log.Int("checks", len(results)), + log.Int("pass_count", passCount), + log.Int("fail_count", failCount), + log.Int("unknown_count", unknownCount), + log.Int("not_applicable_count", notApplicableCount), + log.Duration("elapsed", time.Since(start)), + log.Duration("per_check_timeout", perCheckTimeout), + ) + + payload := make([]PostureResultPayload, 0, len(results)) + for _, r := range results { + payload = append( + payload, + PostureResultPayload{ + CheckKey: r.CheckKey, + Status: string(r.Status), + Evidence: checks.EvidenceJSON(r.Evidence), + ObservedAt: r.ObservedAt, + }, + ) + } + a.flushQueuedPostures(ctx) + if a.revoked { + return + } + if err := a.client.PushPostures(ctx, payload); err != nil { + a.Logger.ErrorCtx(ctx, "posture push failed", log.Error(err)) + if IsUnauthorized(err) { + a.handleUnauthorized() + return + } + dropped, enqueueErr := enqueuePendingPostureBatch(a.Dir, payload, a.currentTime()) + if enqueueErr != nil { + a.Logger.ErrorCtx(ctx, "cannot queue posture batch after failed push", log.Error(enqueueErr)) + return + } + a.Logger.WarnCtx( + ctx, + "queued posture batch for retry", + log.Int("queued_results", len(payload)), + log.Int("dropped_old_batches", dropped), + ) + } +} + +func (a *Agent) flushQueuedPostures(ctx context.Context) { + if a.revoked || a.client == nil { + return + } + now := a.currentTime() + if !a.pendingFlushRetryAt.IsZero() && now.Before(a.pendingFlushRetryAt) { + return + } + + batches, err := loadPendingPostureBatches(a.Dir) + if err != nil { + a.Logger.WarnCtx(ctx, "cannot load pending posture batches", log.Error(err)) + return + } + if len(batches) == 0 { + a.resetPendingFlushRetry() + return + } + + for i, batch := range batches { + if err := a.client.PushPostures(ctx, batch.Results); err != nil { + if IsUnauthorized(err) { + a.handleUnauthorized() + return + } + if saveErr := savePendingPostureBatches(a.Dir, batches[i:]); saveErr != nil { + a.Logger.ErrorCtx(ctx, "cannot persist pending posture batches", log.Error(saveErr)) + } + retryIn := a.schedulePendingFlushRetry(now) + a.Logger.WarnCtx( + ctx, + "cannot flush pending posture batch", + log.Error(err), + log.Int("remaining_batches", len(batches)-i), + log.Duration("retry_in", retryIn), + ) + return + } + } + + if err := clearPendingPostureBatches(a.Dir); err != nil { + a.Logger.ErrorCtx(ctx, "cannot clear pending posture batches", log.Error(err)) + return + } + a.resetPendingFlushRetry() + a.Logger.InfoCtx(ctx, "flushed pending posture batches", log.Int("batches", len(batches))) +} + +func (a *Agent) currentHostInfo(now time.Time) HostInfo { + if a.hostInfoCollectedAt.IsZero() || now.Sub(a.hostInfoCollectedAt) >= hostInfoRefreshInterval { + collector := a.collectHostInfo + if collector == nil { + collector = CollectHostInfo + } + a.hostInfo = collector() + a.hostInfoCollectedAt = now + } + return a.hostInfo +} + +func (a *Agent) currentTime() time.Time { + if a.now != nil { + return a.now() + } + return time.Now() +} + +func (a *Agent) randomInt63n(n int64) int64 { + if n <= 1 { + return 0 + } + if a.randInt63n != nil { + return a.randInt63n(n) + } + return rand.Int63n(n) +} + +func (a *Agent) schedulePendingFlushRetry(now time.Time) time.Duration { + nextBase := a.pendingFlushBackoff + if nextBase <= 0 { + nextBase = pendingFlushBackoffMin + } else { + nextBase *= 2 + if nextBase > pendingFlushBackoffMax { + nextBase = pendingFlushBackoffMax + } + } + a.pendingFlushBackoff = nextBase + + jitterRange := nextBase / 5 + jitter := time.Duration(0) + if jitterRange > 0 { + jitter = time.Duration(a.randomInt63n(int64(jitterRange)*2+1)) - jitterRange + } + + retryIn := nextBase + jitter + if retryIn < time.Second { + retryIn = time.Second + } + a.pendingFlushRetryAt = now.Add(retryIn) + return retryIn +} + +func (a *Agent) resetPendingFlushRetry() { + a.pendingFlushBackoff = 0 + a.pendingFlushRetryAt = time.Time{} +} + +// handleUnauthorized wipes local auth state after a 401 response. +func (a *Agent) handleUnauthorized() { + if a.revoked { + return + } + a.revoked = true + if a.client != nil { + a.client.APIKey = "" + } + + a.Logger.Warn("agent API returned 401, wiping local key and requiring re-enrollment") + if err := DeleteAPIKey(a.Dir); err != nil { + a.Logger.Error("cannot delete local key after 401", log.Error(err)) + } + if err := clearPendingPostureBatches(a.Dir); err != nil { + a.Logger.Error("cannot delete pending posture queue after 401", log.Error(err)) + } + a.resetPendingFlushRetry() +} diff --git a/pkg/deviceagent/agent_test.go b/pkg/deviceagent/agent_test.go new file mode 100644 index 000000000..e8a6f0ede --- /dev/null +++ b/pkg/deviceagent/agent_test.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAgent_currentHostInfo(t *testing.T) { + t.Parallel() + + t.Run( + "reuses cached host info before refresh interval", + func(t *testing.T) { + t.Parallel() + + count := 0 + a := &Agent{ + collectHostInfo: func() HostInfo { + count++ + return HostInfo{Hostname: fmt.Sprintf("host-%d", count)} + }, + } + + now := time.Unix(1_000, 0) + first := a.currentHostInfo(now) + second := a.currentHostInfo(now.Add(2 * time.Hour)) + + assert.Equal(t, 1, count) + assert.Equal(t, first, second) + }, + ) + + t.Run( + "refreshes host info when cache expires", + func(t *testing.T) { + t.Parallel() + + count := 0 + a := &Agent{ + collectHostInfo: func() HostInfo { + count++ + return HostInfo{Hostname: fmt.Sprintf("host-%d", count)} + }, + } + + now := time.Unix(2_000, 0) + first := a.currentHostInfo(now) + second := a.currentHostInfo(now.Add(hostInfoRefreshInterval + time.Minute)) + + assert.Equal(t, 2, count) + assert.NotEqual(t, first, second) + }, + ) +} diff --git a/pkg/deviceagent/checks/check.go b/pkg/deviceagent/checks/check.go new file mode 100644 index 000000000..943c2f352 --- /dev/null +++ b/pkg/deviceagent/checks/check.go @@ -0,0 +1,34 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "context" + "time" +) + +// Result is the outcome of one posture check on the current host. +type Result struct { + CheckKey string + Status Status + Evidence map[string]any + ObservedAt time.Time +} + +// Check runs a single posture check. +type Check interface { + Key() string + Run(ctx context.Context) Result +} diff --git a/pkg/deviceagent/checks/checks_darwin.go b/pkg/deviceagent/checks/checks_darwin.go new file mode 100644 index 000000000..53f2eac0e --- /dev/null +++ b/pkg/deviceagent/checks/checks_darwin.go @@ -0,0 +1,440 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "context" + "os" + "strconv" + "strings" +) + +func init() { + Register(KeyDiskEncryption, darwinDiskEncryption) + Register(KeyScreenLock, darwinScreenLock) + Register(KeyFirewallEnabled, darwinFirewall) + Register(KeyTimeSync, darwinTimeSync) + Register(KeyOSVersion, darwinOSVersion) + Register(KeyAutoUpdate, darwinAutoUpdate) + Register(KeyPasswordPolicy, darwinPasswordPolicy) + Register(KeyRemoteLogin, darwinRemoteLogin) + Register(KeyMalwareProtection, darwinMalwareProtection) +} + +func darwinDiskEncryption(ctx context.Context) Result { + out := RunCommand(ctx, "fdesetup", "status") + if out.Err != nil { + return unknown( + map[string]any{ + "error": out.Err.Error(), + "stderr": out.Stderr, + }, + ) + } + on := strings.Contains(strings.ToLower(out.Stdout), "filevault is on") + ev := map[string]any{"raw": out.Stdout} + if on { + return pass(ev) + } + return fail(ev) +} + +func darwinScreenLock(ctx context.Context) Result { + if CommandExists("sysadminctl") { + status := RunCommand(ctx, "sysadminctl", "-screenLock", "status", "-password", "-") + rawCombined := strings.TrimSpace(status.Stdout + "\n" + status.Stderr) + ev := map[string]any{ + "backend": "sysadminctl", + "raw": rawCombined, + "raw_stdout": status.Stdout, + "raw_stderr": status.Stderr, + } + mode, seconds, ok := darwinScreenLockMode(rawCombined) + if ok { + ev["mode"] = mode + if mode == "seconds" && seconds >= 0 { + ev["seconds"] = seconds + } + if mode == "immediate" { + return pass(ev) + } + return fail(ev) + } + if status.Err != nil { + ev["error"] = status.Err.Error() + } + } + + ask, askSource := darwinReadScreenSaverDefault(ctx, "askForPassword") + ev := map[string]any{} + if askSource != "" { + ev["source"] = askSource + } + if ask.Err != nil { + if darwinDefaultsMissing(ask) { + ev["ask_for_password"] = "0" + ev["note"] = "askForPassword is unset or unavailable" + return fail(ev) + } + ev["error"] = ask.Err.Error() + ev["stderr"] = ask.Stderr + return unknown(ev) + } + enabled := strings.TrimSpace(ask.Stdout) == "1" + + delayCmd, delaySource := darwinReadScreenSaverDefault(ctx, "askForPasswordDelay") + ev["ask_for_password"] = ask.Stdout + if delayCmd.Err == nil { + ev["ask_for_password_delay"] = delayCmd.Stdout + if delaySource != "" && delaySource != askSource { + ev["delay_source"] = delaySource + } + } + if enabled { + return pass(ev) + } + return fail(ev) +} + +func darwinScreenLockMode(raw string) (string, int, bool) { + lower := strings.ToLower(raw) + if strings.Contains(lower, "immediate") { + return "immediate", 0, true + } + if strings.Contains(lower, "off") { + return "off", -1, true + } + if idx := strings.Index(lower, "seconds"); idx >= 0 { + prefix := strings.Fields(lower[:idx]) + if len(prefix) == 0 { + return "seconds", -1, true + } + n, err := strconv.Atoi(prefix[len(prefix)-1]) + if err != nil { + return "seconds", -1, true + } + return "seconds", n, true + } + return "", 0, false +} + +func darwinFirewall(ctx context.Context) Result { + out := RunCommand( + ctx, + "defaults", + "read", + "/Library/Preferences/com.apple.alf", + "globalstate", + ) + if out.Err == nil { + state := strings.TrimSpace(out.Stdout) + ev := map[string]any{"backend": "defaults", "global_state": state} + if state == "1" || state == "2" { + return pass(ev) + } + return fail(ev) + } + + fallback := RunCommand(ctx, "/usr/libexec/ApplicationFirewall/socketfilterfw", "--getglobalstate") + ev := map[string]any{ + "backend": "socketfilterfw", + "raw": fallback.Stdout, + "defaults_error": errString(out.Err), + "defaults_stderr": out.Stderr, + } + if fallback.Err != nil { + ev["error"] = fallback.Err.Error() + ev["stderr"] = fallback.Stderr + return unknown(ev) + } + if darwinStateIndicatesEnabled(fallback.Stdout) { + return pass(ev) + } + if darwinStateIndicatesDisabled(fallback.Stdout) { + return fail(ev) + } + return unknown(ev) +} + +// darwinReadScreenSaverDefault prefers console-user settings when running as root. +func darwinReadScreenSaverDefault(ctx context.Context, key string) (CmdResult, string) { + consoleUser := darwinConsoleUser(ctx) + if os.Geteuid() == 0 && consoleUser != "" { + var consoleMissing CmdResult + consoleMissingSource := "" + + if CommandExists("sudo") { + consoleUserCurrentHost := RunCommand( + ctx, + "sudo", + "-u", + consoleUser, + "defaults", + "-currentHost", + "read", + "com.apple.screensaver", + key, + ) + if consoleUserCurrentHost.Err == nil { + return consoleUserCurrentHost, "console_user_current_host:" + consoleUser + } + if !darwinDefaultsMissing(consoleUserCurrentHost) { + return consoleUserCurrentHost, "console_user_current_host:" + consoleUser + } + if consoleMissingSource == "" { + consoleMissing = consoleUserCurrentHost + consoleMissingSource = "console_user_current_host:" + consoleUser + } + + consoleUserDomain := RunCommand( + ctx, + "sudo", + "-u", + consoleUser, + "defaults", + "read", + "com.apple.screensaver", + key, + ) + if consoleUserDomain.Err == nil { + return consoleUserDomain, "console_user:" + consoleUser + } + if !darwinDefaultsMissing(consoleUserDomain) { + return consoleUserDomain, "console_user:" + consoleUser + } + if consoleMissingSource == "" { + consoleMissing = consoleUserDomain + consoleMissingSource = "console_user:" + consoleUser + } + } + + plistPath := "/Users/" + consoleUser + "/Library/Preferences/com.apple.screensaver.plist" + consoleUserOut := RunCommand(ctx, "defaults", "read", plistPath, key) + if consoleUserOut.Err == nil { + return consoleUserOut, "console_user_plist:" + consoleUser + } + if !darwinDefaultsMissing(consoleUserOut) { + return consoleUserOut, "console_user_plist:" + consoleUser + } + if consoleMissingSource == "" { + consoleMissing = consoleUserOut + consoleMissingSource = "console_user_plist:" + consoleUser + } + if consoleMissingSource != "" { + return consoleMissing, consoleMissingSource + } + } + + currentHost := RunCommand(ctx, "defaults", "-currentHost", "read", "com.apple.screensaver", key) + if currentHost.Err == nil { + return currentHost, "current_user_current_host" + } + + currentUser := RunCommand(ctx, "defaults", "read", "com.apple.screensaver", key) + if currentUser.Err == nil { + return currentUser, "current_user" + } + + if !darwinDefaultsMissing(currentUser) { + return currentUser, "current_user" + } + if !darwinDefaultsMissing(currentHost) { + return currentHost, "current_user_current_host" + } + return currentUser, "current_user" +} + +func darwinDefaultsMissing(out CmdResult) bool { + lower := strings.ToLower(out.Stderr + "\n" + out.Stdout) + return strings.Contains(lower, "does not exist") || + strings.Contains(lower, "could not find") || + strings.Contains(lower, "does not exist in domain") +} + +func darwinConsoleUser(ctx context.Context) string { + if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" && sudoUser != "root" { + return sudoUser + } + out := RunCommand(ctx, "stat", "-f", "%Su", "/dev/console") + if out.Err != nil { + return "" + } + user := strings.TrimSpace(out.Stdout) + if user == "" || user == "root" || user == "loginwindow" { + return "" + } + return user +} + +func darwinStateIndicatesEnabled(raw string) bool { + lower := strings.ToLower(raw) + return strings.Contains(lower, "enabled") || + strings.Contains(lower, "state = 1") || + strings.Contains(lower, "state = 2") +} + +func darwinStateIndicatesDisabled(raw string) bool { + lower := strings.ToLower(raw) + return strings.Contains(lower, "disabled") || strings.Contains(lower, "state = 0") +} + +func darwinTimeSync(ctx context.Context) Result { + out := RunCommand(ctx, "systemsetup", "-getusingnetworktime") + if out.Err != nil || needsAdmin(out.Stdout) { + return unknown( + map[string]any{ + "raw": out.Stdout, + "error": errString(out.Err), + }, + ) + } + on := strings.Contains(strings.ToLower(out.Stdout), "on") + ev := map[string]any{"raw": out.Stdout} + if on { + return pass(ev) + } + return fail(ev) +} + +func darwinOSVersion(ctx context.Context) Result { + out := RunCommand(ctx, "sw_vers", "-productVersion") + if out.Err != nil || out.Stdout == "" { + return unknown(map[string]any{"error": "sw_vers failed"}) + } + build := RunCommand(ctx, "sw_vers", "-buildVersion") + ev := map[string]any{ + "product_version": out.Stdout, + "build_version": build.Stdout, + } + return pass(ev) +} + +func darwinAutoUpdate(ctx context.Context) Result { + primary := RunCommand( + ctx, + "defaults", + "read", + "/Library/Preferences/com.apple.SoftwareUpdate", + "AutomaticCheckEnabled", + ) + if primary.Err == nil { + ev := map[string]any{ + "backend": "defaults", + "automatic_check_enabled": primary.Stdout, + } + if strings.TrimSpace(primary.Stdout) == "1" { + return pass(ev) + } + return fail(ev) + } + + fallback := RunCommand(ctx, "softwareupdate", "--schedule") + ev := map[string]any{ + "backend": "softwareupdate", + "raw": fallback.Stdout, + "defaults_error": errString(primary.Err), + "defaults_stderr": primary.Stderr, + } + if fallback.Err != nil || + needsAdmin(fallback.Stdout) || + needsAdmin(fallback.Stderr) { + ev["error"] = errString(fallback.Err) + ev["stderr"] = fallback.Stderr + return unknown(ev) + } + + lower := strings.ToLower(fallback.Stdout) + switch { + case strings.Contains(lower, "is turned on"), + strings.Contains(lower, "automatic check is on"): + return pass(ev) + case strings.Contains(lower, "is turned off"), + strings.Contains(lower, "automatic check is off"): + return fail(ev) + } + + return unknown(ev) +} + +func darwinPasswordPolicy(ctx context.Context) Result { + out := RunCommand(ctx, "pwpolicy", "-getaccountpolicies") + if out.Err != nil { + return unknown( + map[string]any{ + "error": out.Err.Error(), + "stderr": out.Stderr, + }, + ) + } + lower := strings.ToLower(out.Stdout) + ev := map[string]any{"raw_truncated": truncate(out.Stdout, 400)} + if strings.Contains(lower, "no account policies") || lower == "" { + return fail(ev) + } + return pass(ev) +} + +func darwinRemoteLogin(ctx context.Context) Result { + out := RunCommand(ctx, "systemsetup", "-getremotelogin") + if out.Err != nil || needsAdmin(out.Stdout) { + return unknown( + map[string]any{ + "raw": out.Stdout, + "error": errString(out.Err), + }, + ) + } + off := strings.Contains(strings.ToLower(out.Stdout), "off") + ev := map[string]any{"raw": out.Stdout} + if off { + return pass(ev) + } + return fail(ev) +} + +func darwinMalwareProtection(ctx context.Context) Result { + candidates := []string{ + "/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist", + "/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist", + } + for _, path := range candidates { + if _, err := os.Stat(path); err != nil { + continue + } + ev := map[string]any{"engine": "XProtect", "plist": path} + version := RunCommand( + ctx, + "defaults", + "read", + strings.TrimSuffix(path, ".plist"), + "Version", + ) + if version.Err == nil { + ev["version"] = version.Stdout + } + return pass(ev) + } + return fail( + map[string]any{ + "engine": "XProtect", + "note": "XProtect.meta.plist not found in expected locations", + }, + ) +} + +// needsAdmin checks systemsetup's stdout for privilege errors. +func needsAdmin(stdout string) bool { + return strings.Contains(strings.ToLower(stdout), "administrator access") +} diff --git a/pkg/deviceagent/checks/checks_freebsd.go b/pkg/deviceagent/checks/checks_freebsd.go new file mode 100644 index 000000000..a6fefd251 --- /dev/null +++ b/pkg/deviceagent/checks/checks_freebsd.go @@ -0,0 +1,147 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "context" + "os" + "strings" +) + +func init() { + Register(KeyDiskEncryption, freebsdDiskEncryption) + Register(KeyScreenLock, freebsdScreenLock) + Register(KeyFirewallEnabled, freebsdFirewall) + Register(KeyTimeSync, freebsdTimeSync) + Register(KeyOSVersion, freebsdOSVersion) + Register(KeyAutoUpdate, freebsdAutoUpdate) + Register(KeyPasswordPolicy, freebsdPasswordPolicy) + Register(KeyRemoteLogin, freebsdRemoteLogin) + Register(KeyMalwareProtection, freebsdMalwareProtection) +} + +func freebsdDiskEncryption(ctx context.Context) Result { + if !CommandExists("geli") { + return unknown(map[string]any{"note": "geli command not found"}) + } + out := RunCommand(ctx, "geli", "status") + ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr} + if out.Err != nil { + return unknown(ev) + } + if strings.Contains(out.Stdout, "ACTIVE") { + return pass(ev) + } + return fail(ev) +} + +func freebsdScreenLock(ctx context.Context) Result { + if CommandExists("xscreensaver-command") { + out := RunCommand(ctx, "xscreensaver-command", "-version") + if out.Err == nil { + return pass(map[string]any{"raw": out.Stdout}) + } + } + return notApplicable( + map[string]any{ + "note": "FreeBSD does not have a unified screen lock policy", + }, + ) +} + +func freebsdFirewall(ctx context.Context) Result { + if !CommandExists("pfctl") { + return unknown(map[string]any{"note": "pfctl not found"}) + } + out := RunCommand(ctx, "pfctl", "-si") + ev := map[string]any{"raw": truncate(out.Stdout, 400)} + if out.Err != nil { + return unknown(ev) + } + if strings.Contains(out.Stdout, "Status: Enabled") { + return pass(ev) + } + return fail(ev) +} + +func freebsdTimeSync(ctx context.Context) Result { + out := RunCommand(ctx, "service", "ntpd", "status") + ev := map[string]any{"raw": out.Stdout, "stderr": out.Stderr} + if out.Err != nil { + return fail(ev) + } + if strings.Contains(strings.ToLower(out.Stdout), "is running") { + return pass(ev) + } + return fail(ev) +} + +func freebsdOSVersion(ctx context.Context) Result { + out := RunCommand(ctx, "uname", "-r") + if out.Err != nil { + return unknown(map[string]any{"error": out.Err.Error()}) + } + return pass(map[string]any{"release": out.Stdout}) +} + +func freebsdAutoUpdate(ctx context.Context) Result { + return notApplicable( + map[string]any{ + "note": "FreeBSD relies on operator-driven freebsd-update", + }, + ) +} + +func freebsdPasswordPolicy(ctx context.Context) Result { + data, err := os.ReadFile("/etc/login.conf") + if err != nil { + return unknown(map[string]any{"error": err.Error()}) + } + body := string(data) + hasPolicy := strings.Contains(body, "minpasswordlen=") || + strings.Contains(body, "passwordtime=") + ev := map[string]any{ + "login_conf_snippet": truncate(body, 400), + } + if hasPolicy { + return pass(ev) + } + return fail(ev) +} + +func freebsdMalwareProtection(ctx context.Context) Result { + if !CommandExists("clamd") && !CommandExists("clamdscan") { + return notApplicable( + map[string]any{ + "note": "clamav not installed", + }, + ) + } + out := RunCommand(ctx, "service", "clamav_clamd", "status") + ev := map[string]any{"raw": out.Stdout} + if strings.Contains(strings.ToLower(out.Stdout), "is running") { + return pass(ev) + } + return fail(ev) +} + +func freebsdRemoteLogin(ctx context.Context) Result { + out := RunCommand(ctx, "service", "sshd", "status") + ev := map[string]any{"raw": out.Stdout} + if strings.Contains(strings.ToLower(out.Stdout), "is running") { + return fail(ev) + } + return pass(ev) +} diff --git a/pkg/deviceagent/checks/checks_linux.go b/pkg/deviceagent/checks/checks_linux.go new file mode 100644 index 000000000..ca88af83e --- /dev/null +++ b/pkg/deviceagent/checks/checks_linux.go @@ -0,0 +1,399 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "context" + "os" + "strconv" + "strings" +) + +func init() { + Register(KeyDiskEncryption, linuxDiskEncryption) + Register(KeyScreenLock, linuxScreenLock) + Register(KeyFirewallEnabled, linuxFirewall) + Register(KeyTimeSync, linuxTimeSync) + Register(KeyOSVersion, linuxOSVersion) + Register(KeyAutoUpdate, linuxAutoUpdate) + Register(KeyPasswordPolicy, linuxPasswordPolicy) + Register(KeyRemoteLogin, linuxRemoteLogin) + Register(KeyMalwareProtection, linuxMalwareProtection) +} + +func linuxDiskEncryption(ctx context.Context) Result { + ev := map[string]any{} + + if data, err := os.ReadFile("/etc/crypttab"); err == nil { + body := strings.TrimSpace(string(data)) + ev["crypttab_present"] = true + ev["crypttab_lines"] = nonCommentLines(body) + if len(nonCommentLines(body)) > 0 { + return pass(ev) + } + } else { + ev["crypttab_present"] = false + } + + lsblk := RunCommand(ctx, "lsblk", "-o", "NAME,TYPE,FSTYPE,MOUNTPOINT", "-r") + if lsblk.Err == nil { + ev["lsblk"] = truncate(lsblk.Stdout, 800) + lines := strings.Split(lsblk.Stdout, "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + if fields[1] == "crypt" { + return pass(ev) + } + } + } else { + ev["lsblk_error"] = lsblk.Err.Error() + } + + if lsblk.Err != nil { + return unknown(ev) + } + return fail(ev) +} + +func linuxScreenLock(ctx context.Context) Result { + if !CommandExists("gsettings") { + return notApplicable( + map[string]any{ + "note": "gsettings not installed (likely headless host)", + }, + ) + } + idle := RunCommand(ctx, "gsettings", "get", "org.gnome.desktop.screensaver", "lock-enabled") + if idle.Err != nil { + return unknown( + map[string]any{ + "error": idle.Err.Error(), + }, + ) + } + on := strings.TrimSpace(idle.Stdout) == "true" + ev := map[string]any{"lock_enabled": idle.Stdout} + if on { + return pass(ev) + } + return fail(ev) +} + +func linuxFirewall(ctx context.Context) Result { + if CommandExists("ufw") { + out := RunCommand(ctx, "ufw", "status") + if out.Err == nil { + active := strings.Contains(strings.ToLower(out.Stdout), "status: active") + ev := map[string]any{"backend": "ufw", "raw": out.Stdout} + if active { + return pass(ev) + } + return fail(ev) + } + } + if CommandExists("firewall-cmd") { + out := RunCommand(ctx, "firewall-cmd", "--state") + ev := map[string]any{"backend": "firewalld", "raw": out.Stdout} + if out.Err == nil && strings.Contains(strings.ToLower(out.Stdout), "running") { + return pass(ev) + } + return fail(ev) + } + + if CommandExists("nft") { + out := RunCommand(ctx, "nft", "list", "ruleset") + ev := map[string]any{ + "backend": "nftables", + "rules_excerpt": truncate(out.Stdout, 400), + } + if out.Err != nil { + ev["error"] = out.Err.Error() + return unknown(ev) + } + if strings.Contains(out.Stdout, "chain ") { + return pass(ev) + } + return fail(ev) + } + + if CommandExists("iptables") { + out := RunCommand(ctx, "iptables", "-S", "INPUT") + ev := map[string]any{"backend": "iptables"} + if out.Err != nil { + ev["error"] = out.Err.Error() + return unknown(ev) + } + policy, rules := parseIptablesInput(out.Stdout) + ev["input_policy"] = policy + ev["input_rules"] = rules + if policy == "DROP" || policy == "REJECT" { + return pass(ev) + } + + if rules == 0 { + return fail(ev) + } + // ACCEPT policy with some rules means the operator is filtering, + // but we cannot tell from -S whether the rules are restrictive + // or permissive without modelling the chain. + return unknown(ev) + } + return unknown( + map[string]any{ + "note": "no known firewall tool found", + }, + ) +} + +// parseIptablesInput extracts the INPUT chain policy and rule count from +// `iptables -S INPUT` output. +func parseIptablesInput(s string) (string, int) { + var ( + policy string + rules int + ) + + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "-P INPUT"): + fields := strings.Fields(line) + if len(fields) >= 3 { + policy = strings.ToUpper(fields[2]) + } + case strings.HasPrefix(line, "-A INPUT"): + rules++ + } + } + + return policy, rules +} + +func linuxTimeSync(ctx context.Context) Result { + if !CommandExists("timedatectl") { + return unknown( + map[string]any{ + "note": "timedatectl not installed", + }, + ) + } + out := RunCommand(ctx, "timedatectl", "show") + if out.Err != nil { + return unknown(map[string]any{"error": out.Err.Error()}) + } + ev := map[string]any{"raw": truncate(out.Stdout, 400)} + if strings.Contains(out.Stdout, "NTPSynchronized=yes") { + return pass(ev) + } + return fail(ev) +} + +func linuxOSVersion(ctx context.Context) Result { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return unknown(map[string]any{"error": err.Error()}) + } + body := string(data) + ev := map[string]any{ + "pretty_name": kvLookup(body, "PRETTY_NAME"), + "version_id": kvLookup(body, "VERSION_ID"), + "id": kvLookup(body, "ID"), + } + return pass(ev) +} + +func linuxAutoUpdate(ctx context.Context) Result { + if _, err := os.Stat("/etc/apt/apt.conf.d/20auto-upgrades"); err == nil { + data, _ := os.ReadFile("/etc/apt/apt.conf.d/20auto-upgrades") + body := string(data) + ev := map[string]any{ + "backend": "unattended-upgrades", + "raw": body, + } + if strings.Contains(body, `"1"`) { + return pass(ev) + } + return fail(ev) + } + if CommandExists("systemctl") { + out := RunCommand(ctx, "systemctl", "is-enabled", "dnf-automatic.timer") + if out.Err == nil { + ev := map[string]any{"backend": "dnf-automatic", "state": out.Stdout} + if strings.TrimSpace(out.Stdout) == "enabled" { + return pass(ev) + } + return fail(ev) + } + } + return notApplicable( + map[string]any{ + "note": "no known auto-update mechanism", + }, + ) +} + +func linuxPasswordPolicy(ctx context.Context) Result { + data, err := os.ReadFile("/etc/login.defs") + if err != nil { + return unknown(map[string]any{"error": err.Error()}) + } + body := string(data) + minLen := loginDefsLookup(body, "PASS_MIN_LEN") + maxDays := loginDefsLookup(body, "PASS_MAX_DAYS") + ev := map[string]any{ + "pass_min_len": minLen, + "pass_max_days": maxDays, + } + if minLen == "" { + ev["parse_error"] = "PASS_MIN_LEN not set" + return fail(ev) + } + + minLenValue, err := strconv.Atoi(minLen) + if err != nil { + ev["parse_error"] = "invalid PASS_MIN_LEN value" + return unknown(ev) + } + + if minLenValue >= 8 { + ev["pass_min_len_value"] = minLenValue + return pass(ev) + } + + ev["pass_min_len_value"] = minLenValue + + return fail(ev) +} + +func linuxRemoteLogin(ctx context.Context) Result { + if !CommandExists("systemctl") { + return unknown(map[string]any{"note": "systemctl unavailable"}) + } + state := RunCommand(ctx, "systemctl", "is-active", "ssh.service") + stateAlt := RunCommand(ctx, "systemctl", "is-active", "sshd.service") + merged := strings.TrimSpace(state.Stdout) + if merged == "" { + merged = strings.TrimSpace(stateAlt.Stdout) + } + ev := map[string]any{"is_active": merged} + switch merged { + case "active": + return fail(ev) + case "inactive", "failed": + return pass(ev) + case "": + return notApplicable(ev) + } + return unknown(ev) +} + +// linuxMalwareProtection tracks AV/EDR agent services, not MAC frameworks. +func linuxMalwareProtection(ctx context.Context) Result { + candidates := []struct { + unit string + name string + }{ + {"clamav-daemon.service", "ClamAV"}, + {"clamd.service", "ClamAV"}, + {"clamd@scan.service", "ClamAV"}, + {"falcon-sensor.service", "CrowdStrike Falcon"}, + {"sentinelone.service", "SentinelOne"}, + {"sentineld.service", "SentinelOne"}, + {"sav-protect.service", "Sophos"}, + {"sophos-spl.service", "Sophos"}, + {"esets.service", "ESET"}, + {"mdatp.service", "Microsoft Defender for Endpoint"}, + {"wazuh-agent.service", "Wazuh"}, + {"ossec.service", "OSSEC"}, + {"elastic-agent.service", "Elastic Agent"}, + {"osqueryd.service", "osquery"}, + } + + if !CommandExists("systemctl") { + return unknown( + map[string]any{ + "note": "systemctl not available; cannot enumerate endpoint agents", + }, + ) + } + + var active, installed []string + for _, c := range candidates { + state := strings.TrimSpace( + RunCommand(ctx, "systemctl", "is-active", c.unit).Stdout) + switch state { + case "active": + active = append(active, c.name) + case "inactive", "failed", "activating", "deactivating": + installed = append(installed, c.name) + } + } + + ev := map[string]any{ + "active": active, + "installed": installed, + } + if len(active) > 0 { + return pass(ev) + } + if len(installed) > 0 { + return fail(ev) + } + return unknown(ev) +} + +func nonCommentLines(s string) []string { + out := []string{} + for _, line := range strings.Split(s, "\n") { + t := strings.TrimSpace(line) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + out = append(out, t) + } + return out +} + +func kvLookup(body, key string) string { + for _, line := range strings.Split(body, "\n") { + eq := strings.IndexByte(line, '=') + if eq <= 0 { + continue + } + if strings.TrimSpace(line[:eq]) == key { + v := strings.TrimSpace(line[eq+1:]) + v = strings.Trim(v, `"`) + return v + } + } + return "" +} + +func loginDefsLookup(body, key string) string { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) >= 2 && fields[0] == key { + return fields[1] + } + } + return "" +} diff --git a/pkg/deviceagent/checks/checks_windows.go b/pkg/deviceagent/checks/checks_windows.go new file mode 100644 index 000000000..3c9531d86 --- /dev/null +++ b/pkg/deviceagent/checks/checks_windows.go @@ -0,0 +1,421 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "context" + "strings" +) + +func init() { + Register(KeyDiskEncryption, windowsDiskEncryption) + Register(KeyScreenLock, windowsScreenLock) + Register(KeyFirewallEnabled, windowsFirewall) + Register(KeyTimeSync, windowsTimeSync) + Register(KeyOSVersion, windowsOSVersion) + Register(KeyAutoUpdate, windowsAutoUpdate) + Register(KeyPasswordPolicy, windowsPasswordPolicy) + Register(KeyRemoteLogin, windowsRemoteLogin) + Register(KeyMalwareProtection, windowsMalwareProtection) +} + +const psNoProfile = "-NoProfile" + +func powershell(ctx context.Context, script string) CmdResult { + return RunCommand(ctx, "powershell.exe", psNoProfile, "-Command", script) +} + +func windowsDiskEncryption(ctx context.Context) Result { + if !CommandExists("manage-bde.exe") && !CommandExists("manage-bde") { + return unknown(map[string]any{"note": "manage-bde not found"}) + } + + out := RunCommand(ctx, "manage-bde", "-status") + ev := map[string]any{"raw": truncate(out.Stdout, 600)} + if out.Err != nil { + return unknown(ev) + } + + lower := strings.ToLower(out.Stdout) + if strings.Contains(lower, "percentage encrypted: 100") || + strings.Contains(lower, "fully encrypted") || + strings.Contains(lower, "protection on") { + return pass(ev) + } + + return fail(ev) +} + +func windowsScreenLock(ctx context.Context) Result { + // HKCU resolves to the SYSTEM hive when the agent runs as LocalSystem, + // so we first look for a machine-wide policy and then enumerate every + // loaded interactive user hive under HKU. + machine := powershell( + ctx, + `(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Control Panel\Desktop' `+ + `-ErrorAction SilentlyContinue).ScreenSaverIsSecure`, + ) + if machine.Err == nil { + v := strings.TrimSpace(machine.Stdout) + if v != "" { + ev := map[string]any{ + "backend": "machine_policy", + "screen_saver_is_secure": v, + } + if v == "1" { + return pass(ev) + } + return fail(ev) + } + } + + users := powershell( + ctx, + `Get-ChildItem 'Registry::HKEY_USERS' | `+ + `Where-Object { $_.PSChildName -match '^S-1-5-21-' } | `+ + `ForEach-Object { `+ + ` $path = "Registry::HKEY_USERS\$($_.PSChildName)\Control Panel\Desktop"; `+ + ` $key = Get-ItemProperty $path -ErrorAction SilentlyContinue; `+ + ` "$($_.PSChildName)=$($key.ScreenSaverIsSecure)" `+ + `}`, + ) + if users.Err != nil { + return unknown( + map[string]any{ + "backend": "hkey_users", + "error": users.Err.Error(), + "stderr": users.Stderr, + "machine_policy_error": errString(machine.Err), + }, + ) + } + + ev := map[string]any{ + "backend": "hkey_users", + "raw": truncate(users.Stdout, 400), + } + users_, anyDisabled, anyEnabled := parseWindowsUserScreenLock(users.Stdout) + ev["users"] = users_ + if len(users_) == 0 { + ev["note"] = "no interactive user hives loaded" + return unknown(ev) + } + + if anyEnabled && !anyDisabled { + return pass(ev) + } + + return fail(ev) +} + +// parseWindowsUserScreenLock parses one "SID=" line per user from +// the registry enumeration and reports whether each user has screen +// saver locking enabled. +func parseWindowsUserScreenLock(s string) (map[string]string, bool, bool) { + users := map[string]string{} + var anyEnabled, anyDisabled bool + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + idx := strings.LastIndex(line, "=") + if idx < 0 { + continue + } + + sid := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + if sid == "" { + continue + } + + users[sid] = value + switch value { + case "1": + anyEnabled = true + default: + anyDisabled = true + } + } + + return users, anyDisabled, anyEnabled +} + +func windowsFirewall(ctx context.Context) Result { + primary := powershell( + ctx, + `(Get-NetFirewallProfile -PolicyStore ActiveStore | `+ + `Sort-Object Name | `+ + `ForEach-Object { "$($_.Name)=$($_.Enabled)" }) -join ";"`, + ) + if primary.Err == nil && strings.TrimSpace(primary.Stdout) != "" { + ev := map[string]any{ + "backend": "Get-NetFirewallProfile", + "raw": primary.Stdout, + } + profiles, allEnabled := parseWindowsFirewallProfiles(primary.Stdout) + ev["profiles"] = profiles + if allEnabled { + return pass(ev) + } + return fail(ev) + } + + fallback := RunCommand(ctx, "netsh", "advfirewall", "show", "allprofiles", "state") + if fallback.Err != nil { + return unknown( + map[string]any{ + "error": errString(fallback.Err), + "stderr": fallback.Stderr, + "powershell_error": errString(primary.Err), + }, + ) + } + ev := map[string]any{ + "backend": "netsh", + "raw": truncate(fallback.Stdout, 600), + } + stateLines, anyOff := parseNetshFirewallStates(fallback.Stdout) + ev["state_lines"] = stateLines + if len(stateLines) > 0 && !anyOff { + return pass(ev) + } + + return fail(ev) +} + +// parseWindowsFirewallProfiles parses "Domain=True;Private=True;Public=True" +// from Get-NetFirewallProfile output, returning per-profile state and +// whether every profile is enabled. +func parseWindowsFirewallProfiles(s string) (map[string]string, bool) { + profiles := map[string]string{} + allEnabled := true + any := false + for _, profile := range strings.Split(s, ";") { + parts := strings.SplitN(strings.TrimSpace(profile), "=", 2) + if len(parts) != 2 { + continue + } + name := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if name == "" { + continue + } + profiles[name] = value + any = true + if !strings.EqualFold(value, "true") { + allEnabled = false + } + } + return profiles, any && allEnabled +} + +// parseNetshFirewallStates extracts per-profile "State " lines +// from `netsh advfirewall show allprofiles state`. It is whitespace- and +// case-insensitive. +func parseNetshFirewallStates(s string) ([]string, bool) { + var states []string + anyOff := false + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + if !strings.HasPrefix(lower, "state") { + continue + } + + fields := strings.Fields(lower) + if len(fields) < 2 { + continue + } + + value := fields[len(fields)-1] + states = append(states, value) + if value != "on" { + anyOff = true + } + } + + return states, anyOff +} + +func windowsTimeSync(ctx context.Context) Result { + out := RunCommand(ctx, "w32tm", "/query", "/status") + if out.Err != nil { + return unknown( + map[string]any{ + "error": out.Err.Error(), + "stderr": out.Stderr, + }, + ) + } + + ev := map[string]any{"raw": truncate(out.Stdout, 400)} + lower := strings.ToLower(out.Stdout) + if strings.Contains(lower, "source:") && !strings.Contains(lower, "local cmos clock") { + return pass(ev) + } + + return fail(ev) +} + +func windowsOSVersion(ctx context.Context) Result { + out := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Version`) + if out.Err != nil { + return unknown(map[string]any{"error": out.Err.Error()}) + } + + caption := powershell(ctx, `(Get-CimInstance Win32_OperatingSystem).Caption`) + return pass( + map[string]any{ + "version": out.Stdout, + "caption": caption.Stdout, + }, + ) +} + +func windowsAutoUpdate(ctx context.Context) Result { + out := powershell( + ctx, + `$au = Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `+ + `-ErrorAction SilentlyContinue; `+ + `"$($au.NoAutoUpdate);$($au.AUOptions)"`, + ) + ev := map[string]any{} + if out.Err != nil { + ev["error"] = out.Err.Error() + ev["stderr"] = out.Stderr + return unknown(ev) + } + + parts := strings.SplitN(strings.TrimSpace(out.Stdout), ";", 2) + var noAutoUpdate, auOptions string + if len(parts) >= 1 { + noAutoUpdate = strings.TrimSpace(parts[0]) + } + if len(parts) >= 2 { + auOptions = strings.TrimSpace(parts[1]) + } + ev["no_auto_update"] = noAutoUpdate + ev["au_options"] = auOptions + + // NoAutoUpdate=1 explicitly disables automatic updates via policy. + if noAutoUpdate == "1" { + return fail(ev) + } + // AUOptions semantics: + // 2 — notify before download (no auto-install) + // 3 — auto download, prompt to install + // 4 — auto download + auto install (target SOC posture) + // 5 — managed by local administrators + switch auOptions { + case "3", "4", "5": + return pass(ev) + case "2": + return fail(ev) + } + + // No managed policy. The Windows Update service must at least be + // running for the OS default of auto-install to take effect. + svc := RunCommand(ctx, "sc.exe", "query", "wuauserv") + if svc.Err != nil { + ev["wuauserv_error"] = svc.Err.Error() + return unknown(ev) + } + + if strings.Contains(svc.Stdout, "RUNNING") { + ev["wuauserv"] = "running" + return pass(ev) + } + ev["wuauserv"] = "stopped" + return fail(ev) +} + +func windowsPasswordPolicy(ctx context.Context) Result { + out := RunCommand(ctx, "net", "accounts") + if out.Err != nil { + return unknown( + map[string]any{ + "error": out.Err.Error(), + "stderr": out.Stderr, + }, + ) + } + + ev := map[string]any{"raw": truncate(out.Stdout, 400)} + lower := strings.ToLower(out.Stdout) + if strings.Contains(lower, "minimum password length") && !strings.Contains(lower, "length: 0") { + return pass(ev) + } + + return fail(ev) +} + +func windowsMalwareProtection(ctx context.Context) Result { + out := powershell( + ctx, + `$s = Get-MpComputerStatus; `+ + `"$($s.AntivirusEnabled);$($s.RealTimeProtectionEnabled);`+ + `$($s.AMServiceEnabled);$($s.AntivirusSignatureLastUpdated)"`, + ) + if out.Err != nil { + return unknown( + map[string]any{ + "error": out.Err.Error(), + "stderr": out.Stderr, + }, + ) + } + + parts := strings.Split(out.Stdout, ";") + ev := map[string]any{"raw": out.Stdout} + if len(parts) < 3 { + return unknown(ev) + } + + antivirusOn := strings.EqualFold(strings.TrimSpace(parts[0]), "True") + realtimeOn := strings.EqualFold(strings.TrimSpace(parts[1]), "True") + serviceOn := strings.EqualFold(strings.TrimSpace(parts[2]), "True") + ev["antivirus_enabled"] = antivirusOn + ev["real_time_protection"] = realtimeOn + ev["am_service_enabled"] = serviceOn + if len(parts) >= 4 { + ev["signatures_last_updated"] = strings.TrimSpace(parts[3]) + } + + if antivirusOn && (realtimeOn || serviceOn) { + return pass(ev) + } + + return fail(ev) +} + +func windowsRemoteLogin(ctx context.Context) Result { + out := powershell( + ctx, + `(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server').fDenyTSConnections`, + ) + if out.Err != nil { + return unknown(map[string]any{"error": out.Err.Error()}) + } + + ev := map[string]any{"fdeny_ts_connections": out.Stdout} + if strings.TrimSpace(out.Stdout) == "1" { + return pass(ev) + } + + return fail(ev) +} diff --git a/pkg/deviceagent/checks/evidence.go b/pkg/deviceagent/checks/evidence.go new file mode 100644 index 000000000..28094fb63 --- /dev/null +++ b/pkg/deviceagent/checks/evidence.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import "encoding/json" + +// EvidenceJSON encodes evidence for transport to the agent API. +func EvidenceJSON(ev map[string]any) json.RawMessage { + if len(ev) == 0 { + return json.RawMessage(`{}`) + } + + b, err := json.Marshal(ev) + if err != nil { + return json.RawMessage(`{}`) + } + + return json.RawMessage(b) +} diff --git a/pkg/deviceagent/checks/registry.go b/pkg/deviceagent/checks/registry.go new file mode 100644 index 000000000..cc436a2e7 --- /dev/null +++ b/pkg/deviceagent/checks/registry.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "context" + "sort" + "sync" +) + +var ( + registryMu sync.Mutex + registry []Check +) + +// Register adds a check implementation to the process registry. +func Register(key string, run func(context.Context) Result) { + registryMu.Lock() + defer registryMu.Unlock() + registry = append( + registry, + funcCheck{ + key: key, + run: run, + }, + ) +} + +// All returns a stable snapshot of registered checks. +func All() []Check { + registryMu.Lock() + defer registryMu.Unlock() + + out := make([]Check, len(registry)) + copy(out, registry) + sort.SliceStable( + out, + func(i, j int) bool { + return out[i].Key() < out[j].Key() + }, + ) + return out +} diff --git a/pkg/deviceagent/checks/runcmd.go b/pkg/deviceagent/checks/runcmd.go new file mode 100644 index 000000000..a366063d8 --- /dev/null +++ b/pkg/deviceagent/checks/runcmd.go @@ -0,0 +1,101 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +const defaultCommandTimeout = 5 * time.Second + +var commandExistsCache sync.Map + +// CmdResult captures the basic outcome of an OS subcommand. +type CmdResult struct { + Stdout string + Stderr string + Err error +} + +// RunCommand executes a command and returns trimmed stdout/stderr. +func RunCommand(ctx context.Context, name string, args ...string) CmdResult { + cmdCtx, cancel := context.WithTimeout(ctx, defaultCommandTimeout) + defer cancel() + + resolved, ok := resolveCommandPath(name) + if !ok { + return CmdResult{ + Err: fmt.Errorf("command %q not available at expected absolute path", name), + } + } + + cmd := exec.CommandContext(cmdCtx, resolved, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return CmdResult{ + Stdout: strings.TrimSpace(stdout.String()), + Stderr: strings.TrimSpace(stderr.String()), + Err: err, + } +} + +// CommandExists reports whether `cmd` exists at expected absolute path(s). +func CommandExists(cmd string) bool { + if cached, ok := commandExistsCache.Load(cmd); ok { + return cached.(bool) + } + + _, exists := resolveCommandPath(cmd) + commandExistsCache.Store(cmd, exists) + + return exists +} + +func resolveCommandPath(cmd string) (string, bool) { + if filepath.IsAbs(cmd) { + return cmd, isExecutableFile(cmd) + } + + for _, candidate := range commandCandidates(cmd) { + if isExecutableFile(candidate) { + return candidate, true + } + } + + return "", false +} + +func isExecutableFile(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + if runtime.GOOS == "windows" { + return true + } + + return info.Mode().Perm()&0o111 != 0 +} diff --git a/pkg/deviceagent/checks/runcmd_paths_darwin.go b/pkg/deviceagent/checks/runcmd_paths_darwin.go new file mode 100644 index 000000000..57716443f --- /dev/null +++ b/pkg/deviceagent/checks/runcmd_paths_darwin.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +var darwinCommandPaths = map[string][]string{ + "defaults": {"/usr/bin/defaults"}, + "fdesetup": {"/usr/bin/fdesetup"}, + "pwpolicy": {"/usr/bin/pwpolicy"}, + "softwareupdate": {"/usr/sbin/softwareupdate"}, + "stat": {"/usr/bin/stat"}, + "sudo": {"/usr/bin/sudo"}, + "sw_vers": {"/usr/bin/sw_vers"}, + "sysadminctl": {"/usr/sbin/sysadminctl"}, + "systemsetup": {"/usr/sbin/systemsetup"}, +} + +func commandCandidates(cmd string) []string { + return darwinCommandPaths[cmd] +} diff --git a/pkg/deviceagent/checks/runcmd_paths_freebsd.go b/pkg/deviceagent/checks/runcmd_paths_freebsd.go new file mode 100644 index 000000000..f167fb71b --- /dev/null +++ b/pkg/deviceagent/checks/runcmd_paths_freebsd.go @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +var freeBSDCommandPaths = map[string][]string{ + "clamd": {"/usr/local/sbin/clamd", "/usr/sbin/clamd"}, + "clamdscan": {"/usr/local/bin/clamdscan", "/usr/bin/clamdscan"}, + "geli": {"/sbin/geli"}, + "pfctl": {"/sbin/pfctl"}, + "service": {"/usr/sbin/service"}, + "uname": {"/usr/bin/uname"}, + "xscreensaver-command": {"/usr/local/bin/xscreensaver-command"}, +} + +func commandCandidates(cmd string) []string { + return freeBSDCommandPaths[cmd] +} diff --git a/pkg/deviceagent/checks/runcmd_paths_linux.go b/pkg/deviceagent/checks/runcmd_paths_linux.go new file mode 100644 index 000000000..c39e2f2ab --- /dev/null +++ b/pkg/deviceagent/checks/runcmd_paths_linux.go @@ -0,0 +1,30 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +var linuxCommandPaths = map[string][]string{ + "firewall-cmd": {"/usr/bin/firewall-cmd"}, + "gsettings": {"/usr/bin/gsettings"}, + "iptables": {"/usr/sbin/iptables", "/sbin/iptables", "/usr/bin/iptables"}, + "lsblk": {"/usr/bin/lsblk", "/bin/lsblk"}, + "nft": {"/usr/sbin/nft", "/sbin/nft"}, + "systemctl": {"/usr/bin/systemctl", "/bin/systemctl"}, + "timedatectl": {"/usr/bin/timedatectl", "/bin/timedatectl"}, + "ufw": {"/usr/sbin/ufw", "/sbin/ufw"}, +} + +func commandCandidates(cmd string) []string { + return linuxCommandPaths[cmd] +} diff --git a/pkg/deviceagent/checks/runcmd_paths_other.go b/pkg/deviceagent/checks/runcmd_paths_other.go new file mode 100644 index 000000000..ab943091e --- /dev/null +++ b/pkg/deviceagent/checks/runcmd_paths_other.go @@ -0,0 +1,21 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +//go:build !darwin && !linux && !freebsd && !windows + +package checks + +func commandCandidates(_ string) []string { + return nil +} diff --git a/pkg/deviceagent/checks/runcmd_paths_windows.go b/pkg/deviceagent/checks/runcmd_paths_windows.go new file mode 100644 index 000000000..9bf327f22 --- /dev/null +++ b/pkg/deviceagent/checks/runcmd_paths_windows.go @@ -0,0 +1,48 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "os" + "path/filepath" + "strings" +) + +func commandCandidates(cmd string) []string { + systemRoot := os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = `C:\Windows` + } + system32 := filepath.Join(systemRoot, "System32") + + switch strings.ToLower(cmd) { + case "powershell", "powershell.exe": + return []string{ + filepath.Join(system32, "WindowsPowerShell", "v1.0", "powershell.exe"), + } + case "manage-bde", "manage-bde.exe": + return []string{filepath.Join(system32, "manage-bde.exe")} + case "netsh", "netsh.exe": + return []string{filepath.Join(system32, "netsh.exe")} + case "w32tm", "w32tm.exe": + return []string{filepath.Join(system32, "w32tm.exe")} + case "sc", "sc.exe": + return []string{filepath.Join(system32, "sc.exe")} + case "net", "net.exe": + return []string{filepath.Join(system32, "net.exe")} + default: + return nil + } +} diff --git a/pkg/deviceagent/checks/shared.go b/pkg/deviceagent/checks/shared.go new file mode 100644 index 000000000..774ba8e82 --- /dev/null +++ b/pkg/deviceagent/checks/shared.go @@ -0,0 +1,84 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "context" + "time" +) + +// Check keys shared across OS implementations. +const ( + KeyDiskEncryption = "DISK_ENCRYPTION" + KeyScreenLock = "SCREEN_LOCK" + KeyFirewallEnabled = "FIREWALL_ENABLED" + KeyTimeSync = "TIME_SYNC" + KeyOSVersion = "OS_VERSION" + KeyAutoUpdate = "AUTO_UPDATE" + KeyPasswordPolicy = "PASSWORD_POLICY" + KeyRemoteLogin = "REMOTE_LOGIN" + KeyMalwareProtection = "MALWARE_PROTECTION" +) + +type funcCheck struct { + key string + run func(ctx context.Context) Result +} + +func (c funcCheck) Key() string { return c.key } + +func (c funcCheck) Run(ctx context.Context) Result { + r := c.run(ctx) + if r.CheckKey == "" { + r.CheckKey = c.key + } + if r.ObservedAt.IsZero() { + r.ObservedAt = time.Now().UTC() + } + return r +} + +func pass(ev map[string]any) Result { + return Result{Status: StatusPass, Evidence: ev} +} + +func fail(ev map[string]any) Result { + return Result{Status: StatusFail, Evidence: ev} +} + +func unknown(ev map[string]any) Result { + return Result{Status: StatusUnknown, Evidence: ev} +} + +func notApplicable(ev map[string]any) Result { + return Result{Status: StatusNotApplicable, Evidence: ev} +} + +// truncate limits oversized evidence values. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// errString returns "" for a nil error. +func errString(err error) string { + if err == nil { + return "" + } + + return err.Error() +} diff --git a/pkg/deviceagent/checks/status.go b/pkg/deviceagent/checks/status.go new file mode 100644 index 000000000..1631d46b3 --- /dev/null +++ b/pkg/deviceagent/checks/status.go @@ -0,0 +1,24 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. +package checks + +// Status is the posture status sent to the agent API. +type Status string + +const ( + StatusPass Status = "PASS" + StatusFail Status = "FAIL" + StatusUnknown Status = "UNKNOWN" + StatusNotApplicable Status = "NOT_APPLICABLE" +) diff --git a/pkg/deviceagent/client.go b/pkg/deviceagent/client.go new file mode 100644 index 000000000..4b9e5578b --- /dev/null +++ b/pkg/deviceagent/client.go @@ -0,0 +1,234 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "go.gearno.de/kit/httpclient" +) + +type ( + // Client calls the /api/agent/v1 REST API. + Client struct { + ServerURL string + APIKey string + UserAgent string + HTTP *http.Client + } +) + +// NewClient creates an API client. +func NewClient(serverURL, apiKey, userAgent string) *Client { + httpClient := httpclient.DefaultPooledClient() + httpClient.Timeout = 30 * time.Second + + return &Client{ + ServerURL: strings.TrimRight(serverURL, "/"), + APIKey: apiKey, + UserAgent: userAgent, + HTTP: httpClient, + } +} + +type ( + EnrollRequest struct { + EnrollmentToken string `json:"enrollment_token"` + HardwareUUID string `json:"hardware_uuid"` + SerialNumber *string `json:"serial_number,omitempty"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + OSVersion string `json:"os_version"` + AgentVersion string `json:"agent_version"` + } + + EnrollResponse struct { + DeviceID string `json:"device_id"` + APIKey string `json:"api_key"` + HeartbeatSeconds int `json:"heartbeat_interval_seconds"` + PostureSeconds int `json:"posture_interval_seconds"` + ServerTime string `json:"server_time"` + } + + HeartbeatRequest struct { + AgentVersion string `json:"agent_version,omitempty"` + Hostname string `json:"hostname,omitempty"` + OSVersion string `json:"os_version,omitempty"` + UptimeSec int64 `json:"uptime_seconds,omitempty"` + } + + HeartbeatResponse struct { + HeartbeatSeconds int `json:"heartbeat_interval_seconds"` + PostureSeconds int `json:"posture_interval_seconds"` + ServerTime string `json:"server_time"` + } + + PostureResultPayload struct { + CheckKey string `json:"check_key"` + Status string `json:"status"` + Evidence json.RawMessage `json:"evidence,omitempty"` + ObservedAt time.Time `json:"observed_at"` + } + + PosturesRequest struct { + Results []PostureResultPayload `json:"results"` + } +) + +// Enroll exchanges an enrollment token for a device key. +func (c *Client) Enroll(ctx context.Context, req EnrollRequest) (*EnrollResponse, error) { + var resp EnrollResponse + if err := c.do( + ctx, + http.MethodPost, + "/api/agent/v1/enroll", + false, + req, + &resp, + ); err != nil { + return nil, err + } + + return &resp, nil +} + +// Heartbeat sends a periodic device heartbeat. +func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) { + var resp HeartbeatResponse + if err := c.do( + ctx, + http.MethodPost, + "/api/agent/v1/heartbeat", + true, + req, + &resp, + ); err != nil { + return nil, err + } + + return &resp, nil +} + +// PushPostures sends posture check results. +func (c *Client) PushPostures(ctx context.Context, results []PostureResultPayload) error { + if len(results) == 0 { + return nil + } + + return c.do( + ctx, + http.MethodPost, + "/api/agent/v1/postures", + true, + PosturesRequest{Results: results}, + nil, + ) +} + +// Unenroll asks the server to revoke the device. +func (c *Client) Unenroll(ctx context.Context) error { + return c.do( + ctx, + http.MethodPost, + "/api/agent/v1/unenroll", + true, + nil, + nil, + ) +} + +// HTTPError captures a non-2xx API response. +type HTTPError struct { + StatusCode int + Body string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("agent api: %d %s", e.StatusCode, e.Body) +} + +// IsUnauthorized reports whether err is an API 401. +func IsUnauthorized(err error) bool { + var herr *HTTPError + if !errors.As(err, &herr) { + return false + } + return herr.StatusCode == http.StatusUnauthorized +} + +func (c *Client) do( + ctx context.Context, + method, path string, + authed bool, + in any, + out any, +) error { + url := c.ServerURL + path + + var body io.Reader + if in != nil { + buf, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("cannot marshal request: %w", err) + } + body = bytes.NewReader(buf) + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return fmt.Errorf("cannot build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", c.UserAgent) + + if authed { + if c.APIKey == "" { + return errors.New("agent client: no api key set") + } + + req.Header.Set("Authorization", "Bearer "+c.APIKey) + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("cannot perform request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + buf, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(buf))} + } + + if out == nil { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("cannot decode response: %w", err) + } + + return nil +} diff --git a/pkg/deviceagent/config.go b/pkg/deviceagent/config.go new file mode 100644 index 000000000..031e5ab11 --- /dev/null +++ b/pkg/deviceagent/config.go @@ -0,0 +1,145 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Package deviceagent implements the probo host agent. +package deviceagent + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + // ConfigFileName stores persisted agent config. + ConfigFileName = "config.json" + + // DefaultHeartbeatInterval is the default heartbeat cadence. + DefaultHeartbeatInterval = 5 * time.Minute + // MinHeartbeatInterval is the minimum heartbeat cadence. + MinHeartbeatInterval = 1 * time.Minute + + // DefaultPostureInterval is the default posture cadence. + DefaultPostureInterval = 1 * time.Hour + // MinPostureInterval is the minimum posture cadence. + MinPostureInterval = 15 * time.Minute + + // DefaultUpdateInterval is the default cadence at which the + // agent checks for new releases. + DefaultUpdateInterval = 4 * time.Hour + // MinUpdateInterval is the floor used when a smaller value is + // configured. Updates are network and disk heavy, so we cap + // frequency to once per hour. + MinUpdateInterval = 1 * time.Hour +) + +type ( + // Config is the persisted agent configuration. + Config struct { + ServerURL string `json:"server_url"` + DeviceID string `json:"device_id,omitempty"` + HeartbeatInterval time.Duration `json:"heartbeat_interval,omitempty"` + PostureInterval time.Duration `json:"posture_interval,omitempty"` + UpdateInterval time.Duration `json:"update_interval,omitempty"` + UpdatesDisabled bool `json:"updates_disabled,omitempty"` + } +) + +// ConfigPath returns the absolute path to the agent's config file. +func ConfigPath(dir string) string { + if dir == "" { + dir = DefaultConfigDir() + } + return filepath.Join(dir, ConfigFileName) +} + +// LoadConfig reads config from disk. +func LoadConfig(dir string) (*Config, error) { + data, err := os.ReadFile(ConfigPath(dir)) + if err != nil { + return nil, fmt.Errorf("cannot read config: %w", err) + } + cfg := &Config{} + if err := json.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("cannot decode config: %w", err) + } + cfg.applyDefaults() + return cfg, nil +} + +// SaveConfig writes config to disk with mode 0600. +func SaveConfig(dir string, cfg *Config) error { + if cfg == nil { + return errors.New("nil config") + } + + if dir == "" { + dir = DefaultConfigDir() + } + + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("cannot create config dir: %w", err) + } + + cfg.applyDefaults() + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("cannot encode config: %w", err) + } + + path := ConfigPath(dir) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("cannot write config: %w", err) + } + + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("cannot atomically replace config: %w", err) + } + + return nil +} + +func (c *Config) applyDefaults() { + c.HeartbeatInterval = normalizeHeartbeatInterval(c.HeartbeatInterval) + c.PostureInterval = normalizePostureInterval(c.PostureInterval) + c.UpdateInterval = normalizeUpdateInterval(c.UpdateInterval) +} + +func normalizeHeartbeatInterval(v time.Duration) time.Duration { + return normalizeInterval(v, DefaultHeartbeatInterval, MinHeartbeatInterval) +} + +func normalizePostureInterval(v time.Duration) time.Duration { + return normalizeInterval(v, DefaultPostureInterval, MinPostureInterval) +} + +func normalizeUpdateInterval(v time.Duration) time.Duration { + return normalizeInterval(v, DefaultUpdateInterval, MinUpdateInterval) +} + +func normalizeInterval(v, fallback, floor time.Duration) time.Duration { + if v <= 0 { + v = fallback + } + + if v < floor { + return floor + } + + return v +} diff --git a/pkg/deviceagent/config_paths_other.go b/pkg/deviceagent/config_paths_other.go new file mode 100644 index 000000000..7e442287f --- /dev/null +++ b/pkg/deviceagent/config_paths_other.go @@ -0,0 +1,23 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +//go:build !windows + +package deviceagent + +// DefaultConfigDir returns the directory under which the agent's config +// and keystore live on non-Windows hosts. +func DefaultConfigDir() string { + return "/var/lib/probo-agent" +} diff --git a/pkg/deviceagent/config_paths_windows.go b/pkg/deviceagent/config_paths_windows.go new file mode 100644 index 000000000..63ecdcf91 --- /dev/null +++ b/pkg/deviceagent/config_paths_windows.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "os" + "path/filepath" +) + +// DefaultConfigDir returns the directory under which the agent's config +// and keystore live on Windows. +func DefaultConfigDir() string { + programData := os.Getenv("ProgramData") + if programData == "" { + programData = `C:\ProgramData` + } + + return filepath.Join(programData, "Probo", "agent") +} diff --git a/pkg/deviceagent/config_test.go b/pkg/deviceagent/config_test.go new file mode 100644 index 000000000..8fd1c7f35 --- /dev/null +++ b/pkg/deviceagent/config_test.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestConfig_applyDefaults(t *testing.T) { + t.Parallel() + + t.Run( + "uses defaults when unset", + func(t *testing.T) { + t.Parallel() + + cfg := &Config{} + cfg.applyDefaults() + + assert.Equal(t, DefaultHeartbeatInterval, cfg.HeartbeatInterval) + assert.Equal(t, DefaultPostureInterval, cfg.PostureInterval) + }, + ) + + t.Run( + "clamps values below minimum floors", + func(t *testing.T) { + t.Parallel() + + cfg := &Config{ + HeartbeatInterval: 10 * time.Second, + PostureInterval: 1 * time.Minute, + } + cfg.applyDefaults() + + assert.Equal(t, MinHeartbeatInterval, cfg.HeartbeatInterval) + assert.Equal(t, MinPostureInterval, cfg.PostureInterval) + }, + ) + + t.Run( + "keeps values above floors", + func(t *testing.T) { + t.Parallel() + + cfg := &Config{ + HeartbeatInterval: 3 * time.Minute, + PostureInterval: 2 * time.Hour, + } + cfg.applyDefaults() + + assert.Equal(t, 3*time.Minute, cfg.HeartbeatInterval) + assert.Equal(t, 2*time.Hour, cfg.PostureInterval) + }, + ) +} diff --git a/pkg/deviceagent/hostinfo.go b/pkg/deviceagent/hostinfo.go new file mode 100644 index 000000000..aa2483407 --- /dev/null +++ b/pkg/deviceagent/hostinfo.go @@ -0,0 +1,98 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net" + "os" + "os/exec" + "strings" +) + +type ( + // HostInfo is the device identity reported by the agent. + HostInfo struct { + Hostname string + Platform string + OSVersion string + HardwareUUID string + SerialNumber *string + } +) + +// CollectHostInfo gathers host identity using best-effort probes. +func CollectHostInfo() HostInfo { + info := HostInfo{ + Platform: platformString(), + } + + if h, err := os.Hostname(); err == nil { + info.Hostname = h + } + + if info.Hostname == "" { + info.Hostname = "unknown-host" + } + + info.OSVersion = collectOSVersion() + info.HardwareUUID = collectHardwareUUID() + if sn := collectSerialNumber(); sn != "" { + info.SerialNumber = &sn + } + + return info +} + +// hashFallbackUUID derives a stable fallback from hostname and MAC. +func hashFallbackUUID() string { + hostname, _ := os.Hostname() + mac := firstStableMAC() + h := sha256.New() + h.Write([]byte(hostname)) + h.Write([]byte{0}) + h.Write([]byte(mac)) + return hex.EncodeToString(h.Sum(nil)) +} + +func firstStableMAC() string { + ifaces, err := net.Interfaces() + if err != nil { + return "" + } + + for _, ifc := range ifaces { + if ifc.Flags&net.FlagLoopback != 0 { + continue + } + + if len(ifc.HardwareAddr) == 0 { + continue + } + + return ifc.HardwareAddr.String() + } + + return "" +} + +// runQuiet runs a command and returns trimmed stdout. +func runQuiet(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.Output() + return strings.TrimSpace(string(out)), err +} diff --git a/pkg/deviceagent/hostinfo_darwin.go b/pkg/deviceagent/hostinfo_darwin.go new file mode 100644 index 000000000..2ed6ec963 --- /dev/null +++ b/pkg/deviceagent/hostinfo_darwin.go @@ -0,0 +1,92 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "context" + "strings" + "time" +) + +func platformString() string { + return "DARWIN" +} + +func collectOSVersion() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet(ctx, "sw_vers", "-productVersion") + if out != "" { + return out + } + + out, _ = runQuiet(ctx, "uname", "-sr") + return out +} + +func collectHardwareUUID() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet(ctx, "/usr/sbin/ioreg", "-d2", "-c", "IOPlatformExpertDevice") + if uuid := extractValue(out, "IOPlatformUUID"); uuid != "" { + return uuid + } + + return hashFallbackUUID() +} + +func collectSerialNumber() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet(ctx, "/usr/sbin/ioreg", "-d2", "-c", "IOPlatformExpertDevice") + return extractValue(out, "IOPlatformSerialNumber") +} + +// extractValue parses ioreg key/value output. +func extractValue(s, key string) string { + idx := strings.Index(s, "\""+key+"\"") + if idx < 0 { + return "" + } + + rest := s[idx:] + eq := strings.Index(rest, "=") + if eq < 0 { + return "" + } + + rest = strings.TrimSpace(rest[eq+1:]) + rest = strings.TrimPrefix(rest, "<") + rest = strings.TrimPrefix(rest, ">") + if strings.HasPrefix(rest, "\"") { + rest = rest[1:] + end := strings.Index(rest, "\"") + if end < 0 { + return "" + } + + return strings.TrimSpace(rest[:end]) + } + + end := strings.IndexAny(rest, "\r\n") + if end < 0 { + return strings.TrimSpace(rest) + } + + return strings.TrimSpace(rest[:end]) +} diff --git a/pkg/deviceagent/hostinfo_freebsd.go b/pkg/deviceagent/hostinfo_freebsd.go new file mode 100644 index 000000000..eee97efa8 --- /dev/null +++ b/pkg/deviceagent/hostinfo_freebsd.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "context" + "time" +) + +func platformString() string { + return "FREEBSD" +} + +func collectOSVersion() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet(ctx, "uname", "-r") + if out != "" { + return out + } + + out, _ = runQuiet(ctx, "uname", "-sr") + return out +} + +func collectHardwareUUID() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet(ctx, "kenv", "smbios.system.uuid") + if out != "" { + return out + } + + return hashFallbackUUID() +} + +func collectSerialNumber() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet(ctx, "kenv", "smbios.system.serial") + return out +} diff --git a/pkg/deviceagent/hostinfo_linux.go b/pkg/deviceagent/hostinfo_linux.go new file mode 100644 index 000000000..29757ace4 --- /dev/null +++ b/pkg/deviceagent/hostinfo_linux.go @@ -0,0 +1,93 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "bufio" + "bytes" + "context" + "os" + "strings" + "time" +) + +func platformString() string { + return "LINUX" +} + +func collectOSVersion() string { + if data, err := os.ReadFile("/etc/os-release"); err == nil { + if prettyName := parseOSReleasePrettyName(data); prettyName != "" { + return prettyName + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet(ctx, "uname", "-sr") + return out +} + +func collectHardwareUUID() string { + for _, path := range []string{ + "/sys/class/dmi/id/product_uuid", + "/etc/machine-id", + "/var/lib/dbus/machine-id", + } { + if data, err := os.ReadFile(path); err == nil { + if s := strings.TrimSpace(string(data)); s != "" { + return s + } + } + } + + return hashFallbackUUID() +} + +func collectSerialNumber() string { + if data, err := os.ReadFile("/sys/class/dmi/id/product_serial"); err == nil { + return strings.TrimSpace(string(data)) + } + + return "" +} + +func parseOSReleasePrettyName(data []byte) string { + sc := bufio.NewScanner(bytes.NewReader(data)) + for sc.Scan() { + line := sc.Text() + if k, v, ok := splitKV(line); ok { + if k == "PRETTY_NAME" { + return v + } + } + } + + return "" +} + +func splitKV(line string) (string, string, bool) { + eq := strings.IndexByte(line, '=') + if eq <= 0 { + return "", "", false + } + + k := strings.TrimSpace(line[:eq]) + v := strings.TrimSpace(line[eq+1:]) + v = strings.Trim(v, `"`) + + return k, v, true +} diff --git a/pkg/deviceagent/hostinfo_other.go b/pkg/deviceagent/hostinfo_other.go new file mode 100644 index 000000000..bfe03902c --- /dev/null +++ b/pkg/deviceagent/hostinfo_other.go @@ -0,0 +1,44 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +//go:build !darwin && !linux && !freebsd && !windows + +package deviceagent + +import ( + "context" + "runtime" + "strings" + "time" +) + +func platformString() string { + return strings.ToUpper(runtime.GOOS) +} + +func collectOSVersion() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet(ctx, "uname", "-sr") + return out +} + +func collectHardwareUUID() string { + return hashFallbackUUID() +} + +func collectSerialNumber() string { + return "" +} diff --git a/pkg/deviceagent/hostinfo_windows.go b/pkg/deviceagent/hostinfo_windows.go new file mode 100644 index 000000000..2e06622b8 --- /dev/null +++ b/pkg/deviceagent/hostinfo_windows.go @@ -0,0 +1,70 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "context" + "time" +) + +func platformString() string { + return "WINDOWS" +} + +func collectOSVersion() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet(ctx, "cmd", "/C", "ver") + if out != "" { + return out + } + + out, _ = runQuiet(ctx, "uname", "-sr") + return out +} + +func collectHardwareUUID() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // `wmic` is deprecated; `Get-CimInstance` requires PowerShell. + out, _ := runQuiet( + ctx, + "powershell", + "-NoProfile", + "-Command", + "(Get-CimInstance Win32_ComputerSystemProduct).UUID", + ) + if out != "" { + return out + } + + return hashFallbackUUID() +} + +func collectSerialNumber() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + out, _ := runQuiet( + ctx, + "powershell", + "-NoProfile", + "-Command", + "(Get-CimInstance Win32_BIOS).SerialNumber", + ) + return out +} diff --git a/pkg/deviceagent/keystore.go b/pkg/deviceagent/keystore.go new file mode 100644 index 000000000..bd7d15874 --- /dev/null +++ b/pkg/deviceagent/keystore.go @@ -0,0 +1,84 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// KeyFileName stores the device API key on disk. +const KeyFileName = "agent.key" + +// ErrKeyNotFound is returned when no key file exists. +var ErrKeyNotFound = errors.New("agent key not found") + +// KeyPath returns the absolute path of the device API key file. +func KeyPath(dir string) string { + if dir == "" { + dir = DefaultConfigDir() + } + + return filepath.Join(dir, KeyFileName) +} + +// SaveAPIKey writes the API key to disk with mode 0600. +func SaveAPIKey(dir, key string) error { + if dir == "" { + dir = DefaultConfigDir() + } + + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("cannot create keystore dir: %w", err) + } + + path := KeyPath(dir) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(strings.TrimSpace(key)+"\n"), 0o600); err != nil { + return fmt.Errorf("cannot write key: %w", err) + } + + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("cannot atomically replace key: %w", err) + } + + return nil +} + +// LoadAPIKey reads the API key from disk. +func LoadAPIKey(dir string) (string, error) { + data, err := os.ReadFile(KeyPath(dir)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", ErrKeyNotFound + } + + return "", fmt.Errorf("cannot read agent key: %w", err) + } + + return strings.TrimSpace(string(data)), nil +} + +// DeleteAPIKey removes the API key file. +func DeleteAPIKey(dir string) error { + if err := os.Remove(KeyPath(dir)); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cannot delete agent key: %w", err) + } + + return nil +} diff --git a/pkg/deviceagent/posture_queue.go b/pkg/deviceagent/posture_queue.go new file mode 100644 index 000000000..1d09de471 --- /dev/null +++ b/pkg/deviceagent/posture_queue.go @@ -0,0 +1,144 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + pendingPosturesFileName = "pending-postures.json" + maxPendingPostureBatches = 96 +) + +type pendingPostureBatch struct { + QueuedAt time.Time `json:"queued_at"` + Results []PostureResultPayload `json:"results"` +} + +func pendingPosturesPath(dir string) string { + if dir == "" { + dir = DefaultConfigDir() + } + + return filepath.Join(dir, pendingPosturesFileName) +} + +func loadPendingPostureBatches(dir string) ([]pendingPostureBatch, error) { + data, err := os.ReadFile(pendingPosturesPath(dir)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + + return nil, fmt.Errorf("cannot read pending postures: %w", err) + } + + var batches []pendingPostureBatch + if err := json.Unmarshal(data, &batches); err != nil { + return nil, fmt.Errorf("cannot decode pending postures: %w", err) + } + + filtered := make([]pendingPostureBatch, 0, len(batches)) + for _, batch := range batches { + if len(batch.Results) == 0 { + continue + } + filtered = append(filtered, batch) + } + + return filtered, nil +} + +func savePendingPostureBatches(dir string, batches []pendingPostureBatch) error { + if dir == "" { + dir = DefaultConfigDir() + } + + path := pendingPosturesPath(dir) + if len(batches) == 0 { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cannot delete pending postures: %w", err) + } + return nil + } + + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("cannot create pending posture dir: %w", err) + } + + data, err := json.MarshalIndent(batches, "", " ") + if err != nil { + return fmt.Errorf("cannot encode pending postures: %w", err) + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("cannot write pending postures: %w", err) + } + + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("cannot atomically replace pending postures: %w", err) + } + + return nil +} + +func enqueuePendingPostureBatch( + dir string, + results []PostureResultPayload, + queuedAt time.Time, +) (int, error) { + if len(results) == 0 { + return 0, nil + } + + batches, err := loadPendingPostureBatches(dir) + if err != nil { + return 0, err + } + + clonedResults := make([]PostureResultPayload, len(results)) + copy(clonedResults, results) + + batches = append( + batches, + pendingPostureBatch{ + QueuedAt: queuedAt.UTC(), + Results: clonedResults, + }, + ) + + dropped := 0 + if len(batches) > maxPendingPostureBatches { + dropped = len(batches) - maxPendingPostureBatches + batches = batches[dropped:] + } + + if err := savePendingPostureBatches(dir, batches); err != nil { + return 0, err + } + + return dropped, nil +} + +func clearPendingPostureBatches(dir string) error { + return savePendingPostureBatches(dir, nil) +} diff --git a/pkg/deviceagent/posture_queue_test.go b/pkg/deviceagent/posture_queue_test.go new file mode 100644 index 000000000..a03d84777 --- /dev/null +++ b/pkg/deviceagent/posture_queue_test.go @@ -0,0 +1,237 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package deviceagent + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPendingPostureQueue_EnqueueTrimsOldestBatches(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + for i := range maxPendingPostureBatches + 3 { + results := []PostureResultPayload{ + { + CheckKey: fmt.Sprintf("check-%d", i), + Status: "pass", + ObservedAt: time.Unix(int64(i), 0).UTC(), + }, + } + dropped, err := enqueuePendingPostureBatch( + dir, + results, + time.Unix(int64(i), 0), + ) + require.NoError(t, err) + if i < maxPendingPostureBatches { + assert.Equal(t, 0, dropped) + continue + } + assert.Equal(t, 1, dropped) + } + + batches, err := loadPendingPostureBatches(dir) + require.NoError(t, err) + require.Len(t, batches, maxPendingPostureBatches) + assert.Equal(t, "check-3", batches[0].Results[0].CheckKey) + assert.Equal(t, "check-98", batches[len(batches)-1].Results[0].CheckKey) +} + +func TestAgent_flushQueuedPostures(t *testing.T) { + t.Parallel() + + t.Run( + "clears queue when all batches are flushed", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + _, err := enqueuePendingPostureBatch( + dir, + []PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}}, + time.Now().UTC(), + ) + require.NoError(t, err) + _, err = enqueuePendingPostureBatch( + dir, + []PostureResultPayload{{CheckKey: "second", Status: "pass", ObservedAt: time.Now().UTC()}}, + time.Now().UTC(), + ) + require.NoError(t, err) + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/agent/v1/postures", r.URL.Path) + calls.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a := New(dir, "test", nil) + a.client = NewClient(srv.URL, "api-key", "test-agent") + a.flushQueuedPostures(context.Background()) + + batches, err := loadPendingPostureBatches(dir) + require.NoError(t, err) + assert.Len(t, batches, 0) + assert.Equal(t, int32(2), calls.Load()) + }, + ) + + t.Run( + "keeps unsent tail when a later flush request fails", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + _, err := enqueuePendingPostureBatch( + dir, + []PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}}, + time.Now().UTC(), + ) + require.NoError(t, err) + _, err = enqueuePendingPostureBatch( + dir, + []PostureResultPayload{{CheckKey: "second", Status: "pass", ObservedAt: time.Now().UTC()}}, + time.Now().UTC(), + ) + require.NoError(t, err) + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/agent/v1/postures", r.URL.Path) + call := calls.Add(1) + if call == 2 { + http.Error(w, "temporary error", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a := New(dir, "test", nil) + a.client = NewClient(srv.URL, "api-key", "test-agent") + a.flushQueuedPostures(context.Background()) + + batches, err := loadPendingPostureBatches(dir) + require.NoError(t, err) + require.Len(t, batches, 1) + assert.Equal(t, "second", batches[0].Results[0].CheckKey) + assert.Equal(t, int32(2), calls.Load()) + }, + ) + + t.Run( + "applies retry backoff with jitter gate after failures", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + _, err := enqueuePendingPostureBatch( + dir, + []PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}}, + time.Now().UTC(), + ) + require.NoError(t, err) + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/agent/v1/postures", r.URL.Path) + calls.Add(1) + http.Error(w, "temporary error", http.StatusServiceUnavailable) + })) + defer srv.Close() + + now := time.Unix(10_000, 0).UTC() + a := New(dir, "test", nil) + a.client = NewClient(srv.URL, "api-key", "test-agent") + a.now = func() time.Time { return now } + a.randInt63n = func(n int64) int64 { return n / 2 } + + a.flushQueuedPostures(context.Background()) + assert.Equal(t, int32(1), calls.Load()) + assert.Equal(t, pendingFlushBackoffMin, a.pendingFlushBackoff) + firstRetryAt := a.pendingFlushRetryAt + require.True(t, firstRetryAt.After(now)) + + a.flushQueuedPostures(context.Background()) + assert.Equal(t, int32(1), calls.Load()) + + now = firstRetryAt.Add(time.Second) + a.flushQueuedPostures(context.Background()) + assert.Equal(t, int32(2), calls.Load()) + assert.Equal(t, pendingFlushBackoffMin*2, a.pendingFlushBackoff) + }, + ) + + t.Run( + "resets retry backoff after successful flush", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + _, err := enqueuePendingPostureBatch( + dir, + []PostureResultPayload{{CheckKey: "first", Status: "pass", ObservedAt: time.Now().UTC()}}, + time.Now().UTC(), + ) + require.NoError(t, err) + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/agent/v1/postures", r.URL.Path) + call := calls.Add(1) + if call == 1 { + http.Error(w, "temporary error", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + now := time.Unix(20_000, 0).UTC() + a := New(dir, "test", nil) + a.client = NewClient(srv.URL, "api-key", "test-agent") + a.now = func() time.Time { return now } + a.randInt63n = func(n int64) int64 { return n / 2 } + + a.flushQueuedPostures(context.Background()) + require.Equal(t, pendingFlushBackoffMin, a.pendingFlushBackoff) + retryAt := a.pendingFlushRetryAt + require.True(t, retryAt.After(now)) + + now = retryAt.Add(time.Second) + a.flushQueuedPostures(context.Background()) + assert.Equal(t, int32(2), calls.Load()) + assert.Zero(t, a.pendingFlushBackoff) + assert.True(t, a.pendingFlushRetryAt.IsZero()) + + batches, err := loadPendingPostureBatches(dir) + require.NoError(t, err) + assert.Len(t, batches, 0) + }, + ) +} diff --git a/pkg/deviceagent/service/service.go b/pkg/deviceagent/service/service.go new file mode 100644 index 000000000..cfd699588 --- /dev/null +++ b/pkg/deviceagent/service/service.go @@ -0,0 +1,33 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Package service installs and uninstalls OS service units for probo-agent. +package service + +// Config carries installation parameters shared across platforms. +type Config struct { + // ExePath is the agent binary path. + ExePath string + // Dir is the agent state directory. + Dir string + // Label is the service identifier. + Label string +} + +// Default service identifiers by platform. +const ( + DefaultLabel = "com.getprobo.agent" + DefaultUnixName = "probo-agent" + DefaultWindowsName = "ProboAgent" +) diff --git a/pkg/deviceagent/service/service_darwin.go b/pkg/deviceagent/service/service_darwin.go new file mode 100644 index 000000000..cd6202247 --- /dev/null +++ b/pkg/deviceagent/service/service_darwin.go @@ -0,0 +1,119 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package service + +import ( + "encoding/xml" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" +) + +const plistPath = "/Library/LaunchDaemons/com.getprobo.agent.plist" + +const launchdPlistTmpl = ` + + + + Label + {{xml .Label}} + ProgramArguments + + {{xml .ExePath}} + run + --dir + {{xml .Dir}} + + RunAtLoad + + KeepAlive + + StandardOutPath + /var/log/probo-agent.log + StandardErrorPath + /var/log/probo-agent.log + UserName + root + GroupName + wheel + + +` + +func xmlEscape(v string) (string, error) { + var sb strings.Builder + if err := xml.EscapeText(&sb, []byte(v)); err != nil { + return "", err + } + + return sb.String(), nil +} + +// Install writes and boots the launchd plist. +func Install(cfg Config) error { + if cfg.ExePath == "" { + return errors.New("executable path is required") + } + + if cfg.Dir == "" { + return errors.New("state directory is required") + } + + if cfg.Label == "" { + cfg.Label = DefaultLabel + } + + tmpl, err := template.New("plist").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchdPlistTmpl) + if err != nil { + return fmt.Errorf("cannot parse plist template: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { + return fmt.Errorf("cannot ensure launch daemons directory: %w", err) + } + + f, err := os.OpenFile(plistPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("cannot write plist (need root?): %w", err) + } + defer func() { _ = f.Close() }() + + if err := tmpl.Execute(f, cfg); err != nil { + return fmt.Errorf("cannot render plist: %w", err) + } + + // `bootout` first keeps install idempotent. + _ = exec.Command("launchctl", "bootout", "system", plistPath).Run() + if out, err := exec.Command("launchctl", "bootstrap", "system", plistPath).CombinedOutput(); err != nil { + return fmt.Errorf("cannot run launchctl bootstrap: %w: %s", err, strings.TrimSpace(string(out))) + } + + return nil +} + +// Uninstall bootouts and removes the launchd plist. +func Uninstall(cfg Config) error { + _ = exec.Command("launchctl", "bootout", "system", plistPath).Run() + if err := os.Remove(plistPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cannot remove plist: %w", err) + } + + return nil +} diff --git a/pkg/deviceagent/service/service_freebsd.go b/pkg/deviceagent/service/service_freebsd.go new file mode 100644 index 000000000..a0115d0a3 --- /dev/null +++ b/pkg/deviceagent/service/service_freebsd.go @@ -0,0 +1,97 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package service + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + "text/template" +) + +const ( + rcScriptPath = "/usr/local/etc/rc.d/probo_agent" +) + +// FreeBSD rc.d script template. +const rcScriptTmpl = `#!/bin/sh +# +# PROVIDE: probo_agent +# REQUIRE: NETWORKING +# KEYWORD: shutdown + +. /etc/rc.subr + +name=probo_agent +rcvar=probo_agent_enable +desc="Probo device posture agent" +pidfile="/var/run/${name}.pid" +procname="{{.ExePath}}" +command=/usr/sbin/daemon +command_args="-r -P ${pidfile} -- \"{{.ExePath}}\" run --dir \"{{.Dir}}\"" + +load_rc_config $name +: ${probo_agent_enable:=YES} + +run_rc_command "$1" +` + +func Install(cfg Config) error { + if cfg.ExePath == "" { + return errors.New("executable path is required") + } + + if cfg.Dir == "" { + return errors.New("state directory is required") + } + + rcTmpl, err := template.New("rc").Parse(rcScriptTmpl) + if err != nil { + return fmt.Errorf("cannot parse rc.d template: %w", err) + } + + sf, err := os.OpenFile(rcScriptPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("cannot write rc.d script (need root?): %w", err) + } + + defer func() { _ = sf.Close() }() + if err := rcTmpl.Execute(sf, cfg); err != nil { + return fmt.Errorf("cannot render rc.d script: %w", err) + } + + if out, err := exec.Command("service", "probo_agent", "enable").CombinedOutput(); err != nil { + return fmt.Errorf("cannot run service probo_agent enable: %w: %s", err, strings.TrimSpace(string(out))) + } + + if out, err := exec.Command("service", "probo_agent", "start").CombinedOutput(); err != nil { + return fmt.Errorf("cannot run service probo_agent start: %w: %s", err, strings.TrimSpace(string(out))) + } + + return nil +} + +func Uninstall(cfg Config) error { + _ = exec.Command("service", "probo_agent", "stop").Run() + _ = exec.Command("service", "probo_agent", "disable").Run() + + if err := os.Remove(rcScriptPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cannot remove rc.d script: %w", err) + } + + return nil +} diff --git a/pkg/deviceagent/service/service_linux.go b/pkg/deviceagent/service/service_linux.go new file mode 100644 index 000000000..5c46ac852 --- /dev/null +++ b/pkg/deviceagent/service/service_linux.go @@ -0,0 +1,97 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package service + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + "text/template" +) + +const ( + systemdUnitPath = "/etc/systemd/system/probo-agent.service" +) + +const systemdUnitTmpl = `[Unit] +Description=Probo device posture agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart={{.ExePath}} run --dir {{.Dir}} +Restart=always +RestartSec=10 +# 75 is the exit code emitted after a successful self-update. +# Treat it as a normal exit so the unit restarts without entering +# the "failed" state. +SuccessExitStatus=75 +User=root +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full + +[Install] +WantedBy=multi-user.target +` + +func Install(cfg Config) error { + if cfg.ExePath == "" { + return errors.New("executable path is required") + } + + if cfg.Dir == "" { + return errors.New("state directory is required") + } + + tmpl, err := template.New("unit").Parse(systemdUnitTmpl) + if err != nil { + return fmt.Errorf("cannot parse unit template: %w", err) + } + + f, err := os.OpenFile(systemdUnitPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("cannot write systemd unit (need root?): %w", err) + } + + defer func() { _ = f.Close() }() + if err := tmpl.Execute(f, cfg); err != nil { + return fmt.Errorf("cannot render systemd unit: %w", err) + } + + if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { + return fmt.Errorf("cannot run systemctl daemon-reload: %w: %s", err, strings.TrimSpace(string(out))) + } + + if out, err := exec.Command("systemctl", "enable", "--now", "probo-agent.service").CombinedOutput(); err != nil { + return fmt.Errorf("cannot run systemctl enable --now: %w: %s", err, strings.TrimSpace(string(out))) + } + + return nil +} + +func Uninstall(cfg Config) error { + _ = exec.Command("systemctl", "disable", "--now", "probo-agent.service").Run() + if err := os.Remove(systemdUnitPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("cannot remove systemd unit: %w", err) + } + + _ = exec.Command("systemctl", "daemon-reload").Run() + + return nil +} diff --git a/pkg/deviceagent/service/service_windows.go b/pkg/deviceagent/service/service_windows.go new file mode 100644 index 000000000..027a4a132 --- /dev/null +++ b/pkg/deviceagent/service/service_windows.go @@ -0,0 +1,73 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package service + +import ( + "errors" + "fmt" + "os/exec" + "strings" +) + +// Install registers and starts the Windows service via sc.exe. +func Install(cfg Config) error { + if cfg.ExePath == "" { + return errors.New("executable path is required") + } + if cfg.Dir == "" { + return errors.New("state directory is required") + } + name := DefaultWindowsName + + bin := fmt.Sprintf(`"%s" run --dir "%s"`, cfg.ExePath, cfg.Dir) + if out, err := exec.Command( + "sc.exe", + "create", + name, + "binPath=", + bin, + "start=", + "auto", + "DisplayName=", + "Probo Device Posture Agent", + ).CombinedOutput(); err != nil { + return fmt.Errorf("cannot run sc.exe create: %w: %s", err, strings.TrimSpace(string(out))) + } + // Restart on failure. + if out, err := exec.Command( + "sc.exe", + "failure", + name, + "reset=", + "86400", + "actions=", + "restart/1000/restart/1000/restart/1000", + ).CombinedOutput(); err != nil { + return fmt.Errorf("cannot run sc.exe failure: %w: %s", err, strings.TrimSpace(string(out))) + } + if out, err := exec.Command("sc.exe", "start", name).CombinedOutput(); err != nil { + return fmt.Errorf("cannot run sc.exe start: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func Uninstall(cfg Config) error { + name := DefaultWindowsName + _ = exec.Command("sc.exe", "stop", name).Run() + if out, err := exec.Command("sc.exe", "delete", name).CombinedOutput(); err != nil { + return fmt.Errorf("cannot run sc.exe delete: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/pkg/deviceagent/update/archive.go b/pkg/deviceagent/update/archive.go new file mode 100644 index 000000000..cee0db07a --- /dev/null +++ b/pkg/deviceagent/update/archive.go @@ -0,0 +1,151 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package update + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" +) + +const ( + maxExtractedFileSize = 200 * 1024 * 1024 // 200 MiB hard cap per file +) + +// extractBinary extracts the agent binary at +// `/` from archivePath into workDir and +// returns the absolute path of the written binary. +func extractBinary(archivePath string, layout AssetLayout, workDir string) (string, error) { + wantPath := path.Join(layout.ArchiveDir, layout.BinaryName) + dest := filepath.Join(workDir, "probo-agent.new") + + if layout.IsZip { + if err := extractZipFile(archivePath, wantPath, dest); err != nil { + return "", err + } + } else { + if err := extractTarGzFile(archivePath, wantPath, dest); err != nil { + return "", err + } + } + + if _, err := os.Stat(dest); err != nil { + return "", fmt.Errorf("update: extracted binary missing: %w", err) + } + + return dest, nil +} + +func extractTarGzFile(archivePath, wantPath, dest string) error { + f, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("cannot open archive: %w", err) + } + defer func() { _ = f.Close() }() + + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("cannot read gzip: %w", err) + } + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return fmt.Errorf("cannot read tar entry: %w", err) + } + + if path.Clean(hdr.Name) != wantPath { + continue + } + + if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { + return fmt.Errorf("update: %s is not a regular file", wantPath) + } + + return writeStream(dest, tr, 0o755) + } + + return fmt.Errorf("update: %s missing from archive", wantPath) +} + +func extractZipFile(archivePath, wantPath, dest string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return fmt.Errorf("cannot open zip: %w", err) + } + defer func() { _ = r.Close() }() + + for _, f := range r.File { + if path.Clean(f.Name) != wantPath { + continue + } + + if f.FileInfo().IsDir() { + return fmt.Errorf("update: %s is a directory", wantPath) + } + + rc, err := f.Open() + if err != nil { + return fmt.Errorf("cannot open %s in zip: %w", wantPath, err) + } + + err = writeStream(dest, rc, 0o755) + _ = rc.Close() + + return err + } + + return fmt.Errorf("update: %s missing from archive", wantPath) +} + +func writeStream(dest string, src io.Reader, mode os.FileMode) error { + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("cannot create %s: %w", dest, err) + } + + if _, err := io.Copy(out, io.LimitReader(src, maxExtractedFileSize+1)); err != nil { + _ = out.Close() + return fmt.Errorf("cannot write %s: %w", dest, err) + } + + if err := out.Close(); err != nil { + return fmt.Errorf("cannot close %s: %w", dest, err) + } + + stat, err := os.Stat(dest) + if err != nil { + return fmt.Errorf("cannot stat %s: %w", dest, err) + } + + if stat.Size() > maxExtractedFileSize { + _ = os.Remove(dest) + return fmt.Errorf("update: extracted file exceeds %d bytes", maxExtractedFileSize) + } + + return nil +} diff --git a/pkg/deviceagent/update/asset.go b/pkg/deviceagent/update/asset.go new file mode 100644 index 000000000..994f0dc23 --- /dev/null +++ b/pkg/deviceagent/update/asset.go @@ -0,0 +1,92 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package update + +import ( + "fmt" + "strings" +) + +// AssetLayout describes the names used by the release pipeline for a +// (goos, goarch) combination. The fields mirror what the +// release-probo-agent.yaml workflow produces. +type AssetLayout struct { + // ArchiveName is the file name of the published archive + // (e.g. probo-agent_Linux_x86_64.tar.gz). + ArchiveName string + // ArchiveDir is the top-level directory inside the archive + // (e.g. probo-agent_Linux_x86_64). + ArchiveDir string + // BinaryName is the agent binary file name inside the archive + // (e.g. probo-agent or probo-agent.exe). + BinaryName string + // IsZip is true for Windows builds, which ship as zip archives. + // Other platforms ship as gzipped tar. + IsZip bool +} + +// LayoutFor returns the asset layout for a given (goos, goarch). +// +// The mapping is the inverse of the case statements in the release +// workflow: linux/Linux, darwin/Darwin, windows/Windows, freebsd/Freebsd +// and amd64 -> x86_64 (others kept as-is). +func LayoutFor(goos, goarch string) (AssetLayout, error) { + osLabel, err := osLabel(goos) + if err != nil { + return AssetLayout{}, err + } + + archLabel := archLabel(goarch) + dir := fmt.Sprintf("probo-agent_%s_%s", osLabel, archLabel) + + binary := "probo-agent" + isZip := false + ext := "tar.gz" + if goos == "windows" { + binary += ".exe" + isZip = true + ext = "zip" + } + + return AssetLayout{ + ArchiveName: fmt.Sprintf("%s.%s", dir, ext), + ArchiveDir: dir, + BinaryName: binary, + IsZip: isZip, + }, nil +} + +func osLabel(goos string) (string, error) { + switch strings.ToLower(goos) { + case "linux": + return "Linux", nil + case "darwin": + return "Darwin", nil + case "windows": + return "Windows", nil + case "freebsd": + return "Freebsd", nil + } + + return "", fmt.Errorf("unsupported GOOS %q for auto-update", goos) +} + +func archLabel(goarch string) string { + if goarch == "amd64" { + return "x86_64" + } + + return goarch +} diff --git a/pkg/deviceagent/update/asset_test.go b/pkg/deviceagent/update/asset_test.go new file mode 100644 index 000000000..82233e9f9 --- /dev/null +++ b/pkg/deviceagent/update/asset_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package update + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLayoutFor(t *testing.T) { + t.Parallel() + + cases := []struct { + goos, goarch string + archive string + dir string + binary string + isZip bool + }{ + {"linux", "amd64", "probo-agent_Linux_x86_64.tar.gz", "probo-agent_Linux_x86_64", "probo-agent", false}, + {"linux", "arm64", "probo-agent_Linux_arm64.tar.gz", "probo-agent_Linux_arm64", "probo-agent", false}, + {"darwin", "amd64", "probo-agent_Darwin_x86_64.tar.gz", "probo-agent_Darwin_x86_64", "probo-agent", false}, + {"darwin", "arm64", "probo-agent_Darwin_arm64.tar.gz", "probo-agent_Darwin_arm64", "probo-agent", false}, + {"windows", "amd64", "probo-agent_Windows_x86_64.zip", "probo-agent_Windows_x86_64", "probo-agent.exe", true}, + {"windows", "arm64", "probo-agent_Windows_arm64.zip", "probo-agent_Windows_arm64", "probo-agent.exe", true}, + {"freebsd", "amd64", "probo-agent_Freebsd_x86_64.tar.gz", "probo-agent_Freebsd_x86_64", "probo-agent", false}, + {"freebsd", "arm64", "probo-agent_Freebsd_arm64.tar.gz", "probo-agent_Freebsd_arm64", "probo-agent", false}, + } + + for _, tc := range cases { + t.Run( + tc.goos+"/"+tc.goarch, + func(t *testing.T) { + t.Parallel() + + layout, err := LayoutFor(tc.goos, tc.goarch) + require.NoError(t, err) + assert.Equal(t, tc.archive, layout.ArchiveName) + assert.Equal(t, tc.dir, layout.ArchiveDir) + assert.Equal(t, tc.binary, layout.BinaryName) + assert.Equal(t, tc.isZip, layout.IsZip) + }, + ) + } + + t.Run( + "unsupported GOOS", + func(t *testing.T) { + t.Parallel() + + _, err := LayoutFor("plan9", "amd64") + require.Error(t, err) + }, + ) +} diff --git a/pkg/deviceagent/update/install_unix.go b/pkg/deviceagent/update/install_unix.go new file mode 100644 index 000000000..7e9d5d8dc --- /dev/null +++ b/pkg/deviceagent/update/install_unix.go @@ -0,0 +1,101 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +//go:build !windows + +package update + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// replaceBinary replaces the file at dst with src. +// +// On Unix the rename is atomic: the kernel keeps the running +// executable mapped via its inode, while the destination path now +// points at the new binary on disk. The next exec (after the +// supervisor restarts the process) loads the new code. +// +// We try a same-directory rename first, then fall back to a +// copy + atomic rename when src and dst live on different +// filesystems (e.g. when /tmp is a tmpfs separate from /usr/local/bin). +func replaceBinary(dst, src string) error { + if err := os.Chmod(src, 0o755); err != nil { + return fmt.Errorf("cannot chmod new binary: %w", err) + } + + if err := os.Rename(src, dst); err == nil { + return nil + } + + // Cross-filesystem fallback: copy into .new, fsync, + // then rename within the destination directory. + staging := dst + ".new" + if err := copyFile(src, staging); err != nil { + return err + } + if err := os.Chmod(staging, 0o755); err != nil { + _ = os.Remove(staging) + return fmt.Errorf("cannot chmod staged binary: %w", err) + } + + if err := os.Rename(staging, dst); err != nil { + _ = os.Remove(staging) + return fmt.Errorf("cannot atomically replace %s: %w", dst, err) + } + + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("cannot open %s: %w", src, err) + } + defer func() { _ = in.Close() }() + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("cannot ensure %s: %w", filepath.Dir(dst), err) + } + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("cannot create %s: %w", dst, err) + } + + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + _ = os.Remove(dst) + return fmt.Errorf("cannot copy to %s: %w", dst, err) + } + if err := out.Sync(); err != nil { + _ = out.Close() + _ = os.Remove(dst) + return fmt.Errorf("cannot fsync %s: %w", dst, err) + } + + if err := out.Close(); err != nil { + _ = os.Remove(dst) + return fmt.Errorf("cannot close %s: %w", dst, err) + } + + return nil +} + +// CleanupAfterRestart removes any leftover .old binary from a +// previous Windows-style swap. On Unix this is a no-op. +func CleanupAfterRestart(_ string) {} diff --git a/pkg/deviceagent/update/install_windows.go b/pkg/deviceagent/update/install_windows.go new file mode 100644 index 000000000..ab75e9bff --- /dev/null +++ b/pkg/deviceagent/update/install_windows.go @@ -0,0 +1,110 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +//go:build windows + +package update + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +const oldSuffix = ".old" + +// replaceBinary swaps dst with src on Windows. +// +// Windows blocks deletion / replacement of the running .exe but does +// allow renaming a locked .exe out of the way. We: +// +// 1. Stage src as `.new` (same directory, so the final rename is +// just a metadata update and won't cross volumes). +// 2. Move the running binary to `.old` (NTFS lets us rename a +// locked exe). +// 3. Move `.new` into place at ``. +// +// On the next start the agent's main() calls CleanupAfterRestart to +// best-effort delete `.old`. +func replaceBinary(dst, src string) error { + staging := dst + ".new" + if err := copyFile(src, staging); err != nil { + return err + } + + oldPath := dst + oldSuffix + _ = os.Remove(oldPath) + + if err := os.Rename(dst, oldPath); err != nil && !errors.Is(err, os.ErrNotExist) { + _ = os.Remove(staging) + return fmt.Errorf("cannot move running binary aside: %w", err) + } + + if err := os.Rename(staging, dst); err != nil { + // Try to roll back the running binary swap. + _ = os.Rename(oldPath, dst) + _ = os.Remove(staging) + return fmt.Errorf("cannot install new binary at %s: %w", dst, err) + } + + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("cannot open %s: %w", src, err) + } + defer func() { _ = in.Close() }() + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("cannot ensure %s: %w", filepath.Dir(dst), err) + } + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return fmt.Errorf("cannot create %s: %w", dst, err) + } + + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + _ = os.Remove(dst) + return fmt.Errorf("cannot copy to %s: %w", dst, err) + } + + if err := out.Sync(); err != nil { + _ = out.Close() + _ = os.Remove(dst) + return fmt.Errorf("cannot fsync %s: %w", dst, err) + } + + if err := out.Close(); err != nil { + _ = os.Remove(dst) + return fmt.Errorf("cannot close %s: %w", dst, err) + } + + return nil +} + +// CleanupAfterRestart removes the previous-version binary left behind +// by replaceBinary. Best-effort: callers ignore errors, so a still-locked +// `.old` is fine and will be retried on the next boot. +func CleanupAfterRestart(exePath string) { + if exePath == "" { + return + } + _ = os.Remove(exePath + oldSuffix) +} diff --git a/pkg/deviceagent/update/update.go b/pkg/deviceagent/update/update.go new file mode 100644 index 000000000..be4935b65 --- /dev/null +++ b/pkg/deviceagent/update/update.go @@ -0,0 +1,588 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Package update self-updates the probo-agent binary from GitHub +// Releases. The update flow is: +// +// 1. List the latest releases for the configured repo, filtering on a +// tag prefix (`probo-agent/v` by default). +// 2. Pick the highest semver newer than the agent's current version. +// 3. Download the matching archive plus checksums.txt, verify SHA-256. +// 4. Extract the archive and atomically replace the running binary. +// +// The caller is responsible for restarting the process so the OS +// service supervisor re-execs the new binary. +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "go.gearno.de/kit/httpclient" + "go.gearno.de/kit/log" + "golang.org/x/mod/semver" +) + +const ( + // DefaultRepo is the GitHub repository hosting probo-agent + // releases. + DefaultRepo = "getprobo/probo" + // DefaultTagPrefix is the tag prefix used by the agent's release + // pipeline. Releases look like `probo-agent/v0.1.0`. + DefaultTagPrefix = "probo-agent/v" + + defaultAPIBaseURL = "https://api.github.com" + defaultAssetBaseURL = "https://github.com" + defaultPageSize = 30 + defaultDownloadLimit = 200 * 1024 * 1024 // 200 MiB cap on archive size + checksumFileName = "checksums.txt" + checksumBundleFileName = "checksums.txt.bundle" +) + +// ErrNoUpdateAvailable is returned by CheckLatest when no release +// newer than the current version exists. +var ErrNoUpdateAvailable = errors.New("no update available") + +type ( + // Updater self-updates the agent binary on the local host. + Updater struct { + Repo string + TagPrefix string + APIBaseURL string + AssetBaseURL string + CurrentVersion string + ExePath string + UserAgent string + HTTP *http.Client + Logger *log.Logger + + // SigstoreCacheDir is the on-disk directory used by the + // default cosign Verifier to cache Sigstore TUF metadata. + // Required when Verifier is nil. + SigstoreCacheDir string + + // Verifier validates the Sigstore bundle that accompanies + // every release. When nil, the default cosign Verifier is + // constructed lazily on first Apply, pinned to the + // probo-agent release workflow. + Verifier Verifier + + // GOOS/GOARCH override the values used to compute the + // archive name. They default to runtime.GOOS/GOARCH and + // exist for tests. + GOOS string + GOARCH string + } + + // Release describes a candidate update. + Release struct { + Version string + Tag string + AssetName string + AssetURL string + ChecksumURL string + ChecksumBundleURL string + } + + githubRelease struct { + TagName string `json:"tag_name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []githubAsset `json:"assets"` + PublishedAt jsonTimestamp `json:"published_at"` + } + + githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } + + jsonTimestamp time.Time +) + +func (j *jsonTimestamp) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), `"`) + if s == "" || s == "null" { + return nil + } + + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return err + } + + *j = jsonTimestamp(t) + return nil +} + +// New returns an Updater with sane defaults for production use. +// +// sigstoreCacheDir is the on-disk directory used to cache Sigstore +// TUF metadata for cosign bundle verification. It MUST be writable by +// the agent. A typical value is `/sigstore-cache`. +func New(currentVersion, exePath, userAgent, sigstoreCacheDir string, logger *log.Logger) *Updater { + if logger == nil { + logger = log.NewLogger(log.WithName("agent-update")) + } + + return &Updater{ + Repo: DefaultRepo, + TagPrefix: DefaultTagPrefix, + APIBaseURL: defaultAPIBaseURL, + AssetBaseURL: defaultAssetBaseURL, + CurrentVersion: currentVersion, + ExePath: exePath, + UserAgent: userAgent, + Logger: logger, + HTTP: defaultHTTPClient(logger), + SigstoreCacheDir: sigstoreCacheDir, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + } +} + +func defaultHTTPClient(logger *log.Logger) *http.Client { + return &http.Client{ + Transport: httpclient.DefaultPooledTransport( + httpclient.WithLogger(logger), + httpclient.WithSSRFProtection(), + ), + Timeout: 5 * time.Minute, + } +} + +// CheckLatest queries GitHub for the highest semver release whose tag +// matches u.TagPrefix and is newer than u.CurrentVersion. It returns +// ErrNoUpdateAvailable when nothing newer exists. +func (u *Updater) CheckLatest(ctx context.Context) (*Release, error) { + layout, err := LayoutFor(u.goos(), u.goarch()) + if err != nil { + return nil, err + } + + releases, err := u.listReleases(ctx) + if err != nil { + return nil, err + } + + current := normalizeSemver(u.CurrentVersion) + + var best *Release + for i := range releases { + rel := &releases[i] + if rel.Draft || rel.Prerelease { + continue + } + + ver, ok := parseTag(rel.TagName, u.TagPrefix) + if !ok { + continue + } + + // Skip anything that is not strictly newer than the running + // version. When running a dev build (`current` is empty) + // every published release is considered newer. + if current != "" && semver.Compare(normalizeSemver(ver), current) <= 0 { + continue + } + + if best != nil && semver.Compare(normalizeSemver(ver), normalizeSemver(best.Version)) <= 0 { + continue + } + + assetURL, ok := findAssetURL(rel.Assets, layout.ArchiveName) + if !ok { + continue + } + checksumURL, ok := findAssetURL(rel.Assets, checksumFileName) + if !ok { + continue + } + bundleURL, ok := findAssetURL(rel.Assets, checksumBundleFileName) + if !ok { + // Releases without a Sigstore bundle predate the + // signed-release pipeline and cannot be verified. + // Skip them so the agent never auto-installs an + // unsigned artifact. + continue + } + + best = &Release{ + Version: ver, + Tag: rel.TagName, + AssetName: layout.ArchiveName, + AssetURL: assetURL, + ChecksumURL: checksumURL, + ChecksumBundleURL: bundleURL, + } + } + + if best == nil { + return nil, ErrNoUpdateAvailable + } + + return best, nil +} + +// Apply downloads the release archive, verifies the Sigstore bundle +// covering checksums.txt, verifies the SHA-256 of the archive against +// the now-trusted checksums.txt, extracts the binary to a temp +// directory, and atomically replaces u.ExePath with the new binary. +// +// Failure at *any* verification step aborts the update without +// touching the running binary. +func (u *Updater) Apply(ctx context.Context, rel *Release) error { + if rel == nil { + return errors.New("nil release") + } + + if u.ExePath == "" { + return errors.New("agent executable path is empty") + } + + if rel.ChecksumBundleURL == "" { + return errors.New("release is not signed (no checksums.txt.bundle)") + } + + layout, err := LayoutFor(u.goos(), u.goarch()) + if err != nil { + return err + } + + verifier, err := u.resolveVerifier() + if err != nil { + return err + } + + workDir, err := os.MkdirTemp("", "probo-agent-update-") + if err != nil { + return fmt.Errorf("cannot create update workdir: %w", err) + } + defer func() { _ = os.RemoveAll(workDir) }() + + archivePath := filepath.Join(workDir, layout.ArchiveName) + if err := u.downloadFile(ctx, rel.AssetURL, archivePath); err != nil { + return fmt.Errorf("cannot download archive: %w", err) + } + + checksumPath := filepath.Join(workDir, checksumFileName) + if err := u.downloadFile(ctx, rel.ChecksumURL, checksumPath); err != nil { + return fmt.Errorf("cannot download checksums: %w", err) + } + + bundlePath := filepath.Join(workDir, checksumBundleFileName) + if err := u.downloadFile(ctx, rel.ChecksumBundleURL, bundlePath); err != nil { + return fmt.Errorf("cannot download sigstore bundle: %w", err) + } + + // Anchor the trust chain: verify that the bundle attests + // checksums.txt was signed by the pinned release workflow, + // before reading anything from checksums.txt. + if err := verifier.Verify(ctx, checksumPath, bundlePath); err != nil { + return fmt.Errorf("cannot verify sigstore bundle: %w", err) + } + + if err := verifyChecksum(archivePath, checksumPath, layout.ArchiveName); err != nil { + return err + } + + extractedBinary, err := extractBinary(archivePath, layout, workDir) + if err != nil { + return fmt.Errorf("cannot extract archive: %w", err) + } + + if err := replaceBinary(u.ExePath, extractedBinary); err != nil { + return fmt.Errorf("cannot replace agent binary: %w", err) + } + + u.Logger.InfoCtx( + ctx, + "agent binary updated", + log.String("version", rel.Version), + log.String("tag", rel.Tag), + log.String("asset", rel.AssetName), + ) + + return nil +} + +// resolveVerifier returns a non-nil Verifier, lazily building the +// default cosign-backed verifier when none was injected. +func (u *Updater) resolveVerifier() (Verifier, error) { + if u.Verifier != nil { + return u.Verifier, nil + } + + if u.SigstoreCacheDir == "" { + return nil, errors.New("SigstoreCacheDir must be set for default cosign verifier") + } + + v, err := NewCosignVerifier( + CosignVerifierConfig{ + Repo: u.Repo, + WorkflowPath: expectedWorkflowPath, + TagPrefix: u.TagPrefix, + CacheDir: u.SigstoreCacheDir, + }, + ) + if err != nil { + return nil, err + } + + u.Verifier = v + return v, nil +} + +// listReleases returns the most recent page of releases from GitHub. +func (u *Updater) listReleases(ctx context.Context) ([]githubRelease, error) { + apiBase := u.APIBaseURL + if apiBase == "" { + apiBase = defaultAPIBaseURL + } + + endpoint, err := url.JoinPath(apiBase, "repos", u.Repo, "releases") + if err != nil { + return nil, fmt.Errorf("cannot build releases URL: %w", err) + } + + parsed, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("cannot parse releases URL: %w", err) + } + + q := parsed.Query() + q.Set("per_page", fmt.Sprintf("%d", defaultPageSize)) + parsed.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, fmt.Errorf("cannot build releases request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", u.userAgent()) + + resp, err := u.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot fetch releases: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("cannot fetch releases: %d %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var out []githubRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, 4*1024*1024)).Decode(&out); err != nil { + return nil, fmt.Errorf("cannot decode releases: %w", err) + } + + return out, nil +} + +func (u *Updater) downloadFile(ctx context.Context, src, dst string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, src, nil) + if err != nil { + return fmt.Errorf("cannot build download request: %w", err) + } + req.Header.Set("Accept", "application/octet-stream") + req.Header.Set("User-Agent", u.userAgent()) + + resp, err := u.HTTP.Do(req) + if err != nil { + return fmt.Errorf("cannot fetch %s: %w", src, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("cannot fetch %s: %d %s", src, resp.StatusCode, strings.TrimSpace(string(body))) + } + + tmp := dst + ".part" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("cannot create %s: %w", tmp, err) + } + + if _, err := io.Copy(f, io.LimitReader(resp.Body, defaultDownloadLimit+1)); err != nil { + _ = f.Close() + return fmt.Errorf("cannot stream %s: %w", src, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("cannot close %s: %w", tmp, err) + } + + stat, err := os.Stat(tmp) + if err != nil { + return fmt.Errorf("cannot stat %s: %w", tmp, err) + } + if stat.Size() > defaultDownloadLimit { + _ = os.Remove(tmp) + return fmt.Errorf("download %s exceeds %d bytes", src, defaultDownloadLimit) + } + + if err := os.Rename(tmp, dst); err != nil { + return fmt.Errorf("cannot move %s into place: %w", tmp, err) + } + + return nil +} + +func (u *Updater) userAgent() string { + if u.UserAgent != "" { + return u.UserAgent + } + + return "probo-agent-updater" +} + +func (u *Updater) goos() string { + if u.GOOS != "" { + return u.GOOS + } + + return runtime.GOOS +} + +func (u *Updater) goarch() string { + if u.GOARCH != "" { + return u.GOARCH + } + + return runtime.GOARCH +} + +// parseTag returns the version (e.g. "0.2.0") for a tag whose value +// starts with prefix (e.g. "probo-agent/v"). +func parseTag(tag, prefix string) (string, bool) { + if !strings.HasPrefix(tag, prefix) { + return "", false + } + + v := strings.TrimPrefix(tag, prefix) + if v == "" { + return "", false + } + + if !semver.IsValid("v" + v) { + return "", false + } + + return v, true +} + +// normalizeSemver returns the canonical form expected by golang.org/x/mod/semver +// (a "v" prefix), or "" when the input is empty / invalid. +func normalizeSemver(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + + if !semver.IsValid(v) { + return "" + } + + return v +} + +func findAssetURL(assets []githubAsset, name string) (string, bool) { + for _, a := range assets { + if a.Name == name { + return a.BrowserDownloadURL, true + } + } + + return "", false +} + +// verifyChecksum checks that the SHA-256 digest of archivePath +// matches the entry for archiveName in the checksums.txt file +// produced by `sha256sum *.tar.gz *.zip`. +func verifyChecksum(archivePath, checksumPath, archiveName string) error { + expected, err := readChecksum(checksumPath, archiveName) + if err != nil { + return err + } + + f, err := os.Open(archivePath) + if err != nil { + return fmt.Errorf("cannot open archive: %w", err) + } + defer func() { _ = f.Close() }() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("cannot hash archive: %w", err) + } + + actual := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(actual, expected) { + return fmt.Errorf( + "update: checksum mismatch for %s (expected %s, got %s)", + archiveName, + expected, + actual, + ) + } + + return nil +} + +func readChecksum(path, archiveName string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("cannot read checksums: %w", err) + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // `sha256sum` output is ` `; the GNU tool also + // supports a single-space separator and a leading `*` flag + // for binary mode. Handle both. + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + name := strings.TrimPrefix(fields[1], "*") + if name != archiveName { + continue + } + + return strings.ToLower(fields[0]), nil + } + + return "", fmt.Errorf("update: %s missing from checksums file", archiveName) +} diff --git a/pkg/deviceagent/update/update_test.go b/pkg/deviceagent/update/update_test.go new file mode 100644 index 000000000..55c5a0ca5 --- /dev/null +++ b/pkg/deviceagent/update/update_test.go @@ -0,0 +1,460 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package update + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/httpclient" + "go.gearno.de/kit/log" +) + +func TestParseTag(t *testing.T) { + t.Parallel() + + cases := []struct { + tag string + prefix string + want string + ok bool + }{ + {"probo-agent/v0.1.0", "probo-agent/v", "0.1.0", true}, + {"probo-agent/v1.2.3", "probo-agent/v", "1.2.3", true}, + {"v1.2.3", "probo-agent/v", "", false}, + {"probo-agent/vlatest", "probo-agent/v", "", false}, + {"probo-agent/v", "probo-agent/v", "", false}, + {"unrelated/v0.1.0", "probo-agent/v", "", false}, + } + + for _, tc := range cases { + got, ok := parseTag(tc.tag, tc.prefix) + assert.Equal(t, tc.ok, ok, tc.tag) + assert.Equal(t, tc.want, got, tc.tag) + } +} + +func TestNormalizeSemver(t *testing.T) { + t.Parallel() + + assert.Equal(t, "v0.1.0", normalizeSemver("0.1.0")) + assert.Equal(t, "v1.2.3", normalizeSemver("v1.2.3")) + assert.Equal(t, "v1.2.3-alpha.1", normalizeSemver("1.2.3-alpha.1")) + assert.Equal(t, "", normalizeSemver("")) + assert.Equal(t, "", normalizeSemver("not-a-version")) +} + +func TestReadChecksum(t *testing.T) { + t.Parallel() + + t.Run( + "plain sha256sum output", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + file := filepath.Join(dir, "checksums.txt") + content := "" + + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef probo-agent_Linux_x86_64.tar.gz\n" + + "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd probo-agent_Darwin_arm64.tar.gz\n" + require.NoError(t, os.WriteFile(file, []byte(content), 0o600)) + + got, err := readChecksum(file, "probo-agent_Darwin_arm64.tar.gz") + require.NoError(t, err) + assert.Equal(t, "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abcd", got) + }, + ) + + t.Run( + "binary-mode flag is stripped", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + file := filepath.Join(dir, "checksums.txt") + content := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef *probo-agent_Linux_x86_64.tar.gz\n" + require.NoError(t, os.WriteFile(file, []byte(content), 0o600)) + + got, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz") + require.NoError(t, err) + assert.Equal(t, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", got) + }, + ) + + t.Run( + "missing entry returns error", + func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + file := filepath.Join(dir, "checksums.txt") + require.NoError(t, os.WriteFile(file, []byte("deadbeef other.tar.gz\n"), 0o600)) + + _, err := readChecksum(file, "probo-agent_Linux_x86_64.tar.gz") + require.Error(t, err) + }, + ) +} + +// fakeReleaseServer simulates the GitHub releases API and the +// browser_download_url asset endpoints. +type fakeReleaseServer struct { + t *testing.T + server *httptest.Server + + // release plumbing + tag string + prerelease bool + draft bool + + // archive plumbing + binaryContent []byte + archiveBytes []byte + checksumLine string + bundleBytes []byte + + // when true, the release does not advertise a checksums.txt.bundle asset + omitBundle bool +} + +func newFakeReleaseServer(t *testing.T, tag, version string, layout AssetLayout, binary []byte) *fakeReleaseServer { + t.Helper() + + archive := buildArchive(t, layout, binary) + sum := sha256.Sum256(archive) + checksum := fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), layout.ArchiveName) + + frs := &fakeReleaseServer{ + t: t, + tag: tag, + binaryContent: binary, + archiveBytes: archive, + checksumLine: checksum, + bundleBytes: []byte("dummy-sigstore-bundle"), + } + + mux := http.NewServeMux() + mux.HandleFunc("/repos/getprobo/probo/releases", func(w http.ResponseWriter, r *http.Request) { + base := "http://" + r.Host + assets := []map[string]any{ + { + "name": layout.ArchiveName, + "browser_download_url": base + "/download/" + layout.ArchiveName, + }, + { + "name": checksumFileName, + "browser_download_url": base + "/download/" + checksumFileName, + }, + } + if !frs.omitBundle { + assets = append(assets, map[string]any{ + "name": checksumBundleFileName, + "browser_download_url": base + "/download/" + checksumBundleFileName, + }) + } + body := []map[string]any{ + { + "tag_name": frs.tag, + "draft": frs.draft, + "prerelease": frs.prerelease, + "assets": assets, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + _ = version + }) + mux.HandleFunc("/download/"+layout.ArchiveName, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(frs.archiveBytes) + }) + mux.HandleFunc("/download/"+checksumFileName, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(frs.checksumLine)) + }) + mux.HandleFunc("/download/"+checksumBundleFileName, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(frs.bundleBytes) + }) + + frs.server = httptest.NewServer(mux) + t.Cleanup(frs.server.Close) + return frs +} + +func (f *fakeReleaseServer) URL() string { return f.server.URL } + +func buildArchive(t *testing.T, layout AssetLayout, binary []byte) []byte { + t.Helper() + if layout.IsZip { + return buildZip(t, layout, binary) + } + return buildTarGz(t, layout, binary) +} + +func buildTarGz(t *testing.T, layout AssetLayout, binary []byte) []byte { + t.Helper() + + dir := t.TempDir() + out := filepath.Join(dir, layout.ArchiveName) + + f, err := os.Create(out) + require.NoError(t, err) + + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: path.Join(layout.ArchiveDir, layout.BinaryName), + Mode: 0o755, + Size: int64(len(binary)), + Typeflag: tar.TypeReg, + })) + _, err = tw.Write(binary) + require.NoError(t, err) + + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + require.NoError(t, f.Close()) + + data, err := os.ReadFile(out) + require.NoError(t, err) + return data +} + +func buildZip(t *testing.T, layout AssetLayout, binary []byte) []byte { + t.Helper() + + dir := t.TempDir() + out := filepath.Join(dir, layout.ArchiveName) + + f, err := os.Create(out) + require.NoError(t, err) + + zw := zip.NewWriter(f) + w, err := zw.Create(path.Join(layout.ArchiveDir, layout.BinaryName)) + require.NoError(t, err) + _, err = w.Write(binary) + require.NoError(t, err) + require.NoError(t, zw.Close()) + require.NoError(t, f.Close()) + + data, err := os.ReadFile(out) + require.NoError(t, err) + return data +} + +func newTestUpdater(server *fakeReleaseServer, currentVersion, exePath, goos, goarch string) *Updater { + return &Updater{ + Repo: "getprobo/probo", + TagPrefix: DefaultTagPrefix, + APIBaseURL: server.URL(), + AssetBaseURL: server.URL(), + CurrentVersion: currentVersion, + ExePath: exePath, + UserAgent: "probo-agent-test/0.0.0", + Logger: log.NewLogger(log.WithName("update-test")), + HTTP: &http.Client{ + Transport: httpclient.DefaultPooledTransport( + httpclient.WithSSRFProtection(), + httpclient.WithSSRFAllowLoopback(), + ), + }, + // Tests bypass the cosign verifier; production code wires + // CosignVerifier in via Updater.SigstoreCacheDir. + Verifier: AllowAllVerifier{}, + GOOS: goos, + GOARCH: goarch, + } +} + +func TestUpdater_CheckLatest(t *testing.T) { + t.Parallel() + + t.Run( + "returns release when newer version is available", + func(t *testing.T) { + t.Parallel() + + layout, err := LayoutFor("linux", "amd64") + require.NoError(t, err) + fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new")) + + u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64") + rel, err := u.CheckLatest(context.Background()) + require.NoError(t, err) + assert.Equal(t, "0.2.0", rel.Version) + assert.Equal(t, layout.ArchiveName, rel.AssetName) + }, + ) + + t.Run( + "returns ErrNoUpdateAvailable when running latest", + func(t *testing.T) { + t.Parallel() + + layout, err := LayoutFor("darwin", "arm64") + require.NoError(t, err) + fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("same")) + + u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "darwin", "arm64") + _, err = u.CheckLatest(context.Background()) + assert.ErrorIs(t, err, ErrNoUpdateAvailable) + }, + ) + + t.Run( + "skips draft and prerelease tags", + func(t *testing.T) { + t.Parallel() + + layout, err := LayoutFor("linux", "amd64") + require.NoError(t, err) + fake := newFakeReleaseServer(t, "probo-agent/v0.2.0-rc.1", "0.2.0-rc.1", layout, []byte("rc")) + fake.prerelease = true + + u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64") + _, err = u.CheckLatest(context.Background()) + assert.ErrorIs(t, err, ErrNoUpdateAvailable) + }, + ) + + t.Run( + "dev build always sees update available", + func(t *testing.T) { + t.Parallel() + + layout, err := LayoutFor("linux", "amd64") + require.NoError(t, err) + fake := newFakeReleaseServer(t, "probo-agent/v0.1.0", "0.1.0", layout, []byte("rel")) + + u := newTestUpdater(fake, "dev", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64") + rel, err := u.CheckLatest(context.Background()) + require.NoError(t, err) + assert.Equal(t, "0.1.0", rel.Version) + }, + ) +} + +func TestUpdater_Apply(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("apply test exercises the unix swap path; windows has its own .old shuffle") + } + + dir := t.TempDir() + exePath := filepath.Join(dir, "probo-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755)) + + layout, err := LayoutFor("linux", "amd64") + require.NoError(t, err) + fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary")) + + u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64") + rel, err := u.CheckLatest(context.Background()) + require.NoError(t, err) + + require.NoError(t, u.Apply(context.Background(), rel)) + + got, err := os.ReadFile(exePath) + require.NoError(t, err) + assert.Equal(t, []byte("new-binary"), got) + + stat, err := os.Stat(exePath) + require.NoError(t, err) + assert.NotZero(t, stat.Mode().Perm()&0o100, "new binary should be executable") +} + +func TestUpdater_CheckLatest_SkipsUnsignedRelease(t *testing.T) { + t.Parallel() + + layout, err := LayoutFor("linux", "amd64") + require.NoError(t, err) + fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new")) + fake.omitBundle = true + + u := newTestUpdater(fake, "0.1.0", filepath.Join(t.TempDir(), "probo-agent"), "linux", "amd64") + _, err = u.CheckLatest(context.Background()) + assert.ErrorIs(t, err, ErrNoUpdateAvailable, "release without a sigstore bundle must be ignored") +} + +func TestUpdater_Apply_RejectsBadSignature(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + exePath := filepath.Join(dir, "probo-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755)) + + layout, err := LayoutFor("linux", "amd64") + require.NoError(t, err) + fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary")) + + u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64") + u.Verifier = rejectAllVerifier{err: fmt.Errorf("test: signer identity mismatch")} + + rel, err := u.CheckLatest(context.Background()) + require.NoError(t, err) + + err = u.Apply(context.Background(), rel) + require.Error(t, err) + assert.Contains(t, err.Error(), "sigstore") + + got, err := os.ReadFile(exePath) + require.NoError(t, err) + assert.Equal(t, []byte("old-binary"), got, "rejected signature must not touch the running binary") +} + +func TestUpdater_Apply_RejectsCorruptedArchive(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + exePath := filepath.Join(dir, "probo-agent") + require.NoError(t, os.WriteFile(exePath, []byte("old-binary"), 0o755)) + + layout, err := LayoutFor("linux", "amd64") + require.NoError(t, err) + fake := newFakeReleaseServer(t, "probo-agent/v0.2.0", "0.2.0", layout, []byte("new-binary")) + + // Corrupt the archive without updating checksums. + fake.archiveBytes = append(fake.archiveBytes, 0xff) + + u := newTestUpdater(fake, "0.1.0", exePath, "linux", "amd64") + rel, err := u.CheckLatest(context.Background()) + require.NoError(t, err) + + err = u.Apply(context.Background(), rel) + require.Error(t, err) + assert.Contains(t, err.Error(), "checksum mismatch") + + got, err := os.ReadFile(exePath) + require.NoError(t, err) + assert.Equal(t, []byte("old-binary"), got, "corrupted update must not touch the running binary") +} diff --git a/pkg/deviceagent/update/verify.go b/pkg/deviceagent/update/verify.go new file mode 100644 index 000000000..eae95fc73 --- /dev/null +++ b/pkg/deviceagent/update/verify.go @@ -0,0 +1,194 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package update + +import ( + "context" + "fmt" + "os" + "regexp" + + "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/root" + "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/sigstore/sigstore-go/pkg/verify" +) + +const ( + // expectedSignerIssuer is the OIDC issuer Fulcio embeds in the + // signing certificate when the workflow uses GitHub Actions' + // OIDC token. This is the public-good Sigstore configuration. + expectedSignerIssuer = "https://token.actions.githubusercontent.com" + + // expectedWorkflowPath is the path of the release workflow that + // is allowed to produce signed probo-agent artifacts. Anything + // signed by a different workflow (or a workflow run outside of + // a tagged commit) is rejected. + expectedWorkflowPath = ".github/workflows/release-probo-agent.yaml" +) + +// Verifier verifies that a Sigstore bundle (`checksums.txt.bundle`) +// attests an artifact (`checksums.txt`) was produced by the expected +// signer identity. Implementations MUST hard-fail on any error; +// callers do not interpret the error type. +type Verifier interface { + // Verify returns nil iff bundlePath is a valid Sigstore bundle + // for the artifact at artifactPath, and the signer identity + // matches the verifier's pinned issuer / SAN regex. + Verify(ctx context.Context, artifactPath, bundlePath string) error +} + +// AllowAllVerifier accepts every input. It exists strictly for tests +// of the surrounding download / extract pipeline. Production callers +// must wire a real Verifier (e.g. CosignVerifier). +type AllowAllVerifier struct{} + +// Verify always returns nil. +func (AllowAllVerifier) Verify(_ context.Context, _, _ string) error { return nil } + +// rejectAllVerifier is exposed for tests that need to assert Apply +// hard-fails on signature problems. +type rejectAllVerifier struct{ err error } + +func (v rejectAllVerifier) Verify(_ context.Context, _, _ string) error { return v.err } + +// CosignVerifier verifies cosign sign-blob bundles using sigstore-go +// against the Sigstore public-good trust root. +// +// The verifier pins the signer identity to the probo-agent release +// workflow on a tagged commit: +// +// issuer: https://token.actions.githubusercontent.com +// SAN: https://github.com//@refs/tags/ +// +// where , and default to the values +// hard-coded in the release pipeline. Both fields can be overridden +// for testing or for repository forks. +type CosignVerifier struct { + Issuer string + SANRegex string + trustedRoot *root.TrustedRoot +} + +// CosignVerifierConfig configures a CosignVerifier. +type CosignVerifierConfig struct { + // Repo identifies the GitHub repository (e.g. "getprobo/probo"). + Repo string + // WorkflowPath is the path within the repo to the workflow file + // allowed to produce signed releases. + WorkflowPath string + // TagPrefix is the tag prefix the release workflow signs against + // (e.g. "probo-agent/v"). The verifier matches anything after + // this prefix. + TagPrefix string + // CacheDir is the on-disk directory used to cache the Sigstore + // TUF metadata. Required. + CacheDir string +} + +// NewCosignVerifier loads the Sigstore public-good trust root via +// TUF (cached under cfg.CacheDir) and returns a Verifier that pins +// signatures to the configured GitHub Actions workflow on a tagged +// commit. +func NewCosignVerifier(cfg CosignVerifierConfig) (*CosignVerifier, error) { + if cfg.Repo == "" { + return nil, fmt.Errorf("update: cosign verifier requires Repo") + } + if cfg.WorkflowPath == "" { + cfg.WorkflowPath = expectedWorkflowPath + } + if cfg.TagPrefix == "" { + cfg.TagPrefix = DefaultTagPrefix + } + if cfg.CacheDir == "" { + return nil, fmt.Errorf("update: cosign verifier requires CacheDir") + } + + if err := os.MkdirAll(cfg.CacheDir, 0o700); err != nil { + return nil, fmt.Errorf("cannot create sigstore cache dir: %w", err) + } + + opts := tuf.DefaultOptions() + opts.CachePath = cfg.CacheDir + + tufClient, err := tuf.New(opts) + if err != nil { + return nil, fmt.Errorf("cannot init sigstore TUF client: %w", err) + } + + trustedRoot, err := root.GetTrustedRoot(tufClient) + if err != nil { + return nil, fmt.Errorf("cannot load sigstore trusted root: %w", err) + } + + sanRegex := buildSANRegex(cfg.Repo, cfg.WorkflowPath, cfg.TagPrefix) + + return &CosignVerifier{ + Issuer: expectedSignerIssuer, + SANRegex: sanRegex, + trustedRoot: trustedRoot, + }, nil +} + +// Verify validates that bundlePath attests artifactPath was signed by +// the expected GitHub Actions workflow on a tagged release. +func (v *CosignVerifier) Verify(_ context.Context, artifactPath, bundlePath string) error { + b, err := bundle.LoadJSONFromPath(bundlePath) + if err != nil { + return fmt.Errorf("cannot load sigstore bundle: %w", err) + } + + identity, err := verify.NewShortCertificateIdentity(v.Issuer, "", "", v.SANRegex) + if err != nil { + return fmt.Errorf("cannot build signer identity: %w", err) + } + + sev, err := verify.NewVerifier( + v.trustedRoot, + verify.WithSignedCertificateTimestamps(1), + verify.WithTransparencyLog(1), + verify.WithObserverTimestamps(1), + ) + if err != nil { + return fmt.Errorf("cannot build sigstore verifier: %w", err) + } + + artifact, err := os.Open(artifactPath) + if err != nil { + return fmt.Errorf("cannot open artifact for verification: %w", err) + } + defer func() { _ = artifact.Close() }() + + policy := verify.NewPolicy( + verify.WithArtifact(artifact), + verify.WithCertificateIdentity(identity), + ) + + if _, err := sev.Verify(b, policy); err != nil { + return fmt.Errorf("sigstore verification failed: %w", err) + } + + return nil +} + +func buildSANRegex(repo, workflowPath, tagPrefix string) string { + return `^https://github\.com/` + + regexp.QuoteMeta(repo) + + `/` + + regexp.QuoteMeta(workflowPath) + + `@refs/tags/` + + regexp.QuoteMeta(tagPrefix) + + `.+$` +}