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 <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-05-26 09:01:08 -07:00
parent e643a3259c
commit 22e50b3f11
56 changed files with 7410 additions and 127 deletions

View File

@@ -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

View File

@@ -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/<name>@sha256:..., not library/<name>: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

View File

@@ -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.

1
cmd/probo-agent/VERSION Normal file
View File

@@ -0,0 +1 @@
0.1.0

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Productbuild distribution definition for the Probo device posture
agent.
Placeholders are substituted by build.sh:
@@VERSION@@ agent version, e.g. 0.1.0
@@PKG_ARCH@@ payload pkgbuild architecture (x86_64 or arm64)
@@HOST_ARCHS@@ host arch filter used by Installer.app
-->
<installer-gui-script minSpecVersion="2">
<title>Probo Device Posture Agent @@VERSION@@</title>
<!--
Single-pane install. Customization is disabled because the agent
is a single component and we do not want users to opt out of the
postinstall script.
-->
<options customize="never"
require-scripts="true"
rootVolumeOnly="true"
hostArchitectures="@@HOST_ARCHS@@"/>
<!--
Refuse installation on macOS older than 11 (Big Sur). The agent
relies on launchd bootstrap/bootout semantics and the modern
`system_profiler` output that earlier releases do not provide.
-->
<volume-check>
<allowed-os-versions>
<os-version min="11.0"/>
</allowed-os-versions>
</volume-check>
<welcome file="welcome.html" mime-type="text/html"/>
<license file="license.txt" mime-type="text/plain"/>
<conclusion file="conclusion.html" mime-type="text/html"/>
<pkg-ref id="com.getprobo.agent"
version="@@VERSION@@"
onConclusion="none">probo-agent-component.pkg</pkg-ref>
<choices-outline>
<line choice="default">
<line choice="com.getprobo.agent"/>
</line>
</choices-outline>
<choice id="default"/>
<choice id="com.getprobo.agent"
title="Probo Device Posture Agent"
description="Installs probo-agent to /usr/local/bin and the launchd unit to /Library/LaunchDaemons.">
<pkg-ref id="com.getprobo.agent"/>
</choice>
</installer-gui-script>

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Installation complete</title>
<style>
body {
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 13px;
color: #1d1d1f;
margin: 24px;
line-height: 1.45;
}
h1 { font-size: 18px; margin-bottom: 6px; }
h2 { font-size: 14px; margin-top: 18px; margin-bottom: 4px; }
code, pre {
font-family: Menlo, Consolas, monospace;
background: #f5f5f7;
border-radius: 4px;
}
code { padding: 2px 4px; }
pre {
padding: 8px 10px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<h1>Installation complete</h1>
<p>
The <code>probo-agent</code> binary is installed and the launchd
unit is loaded. If the installer found a pre-staged
configuration file at <code>/tmp/probo-agent.conf</code>, the
device is already enrolled and the agent is running.
</p>
<h2>Enroll this device manually</h2>
<p>
If you installed without a pre-staged configuration, finish the
setup from a Terminal:
</p>
<pre>sudo probo-agent install \
--server https://app.getprobo.com \
--enrollment-token &lt;TOKEN&gt;</pre>
<h2>Inspect the agent</h2>
<pre>sudo probo-agent status
sudo probo-agent collect</pre>
<h2>Uninstall</h2>
<pre>sudo probo-agent uninstall</pre>
<p>
Logs are written to <code>/var/log/probo-agent.log</code>. The
installer's own log lives at
<code>/var/log/probo-agent-install.log</code>.
</p>
</body>
</html>

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Probo Device Posture Agent</title>
<style>
body {
font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 13px;
color: #1d1d1f;
margin: 24px;
line-height: 1.45;
}
h1 { font-size: 18px; margin-bottom: 6px; }
h2 { font-size: 14px; margin-top: 18px; margin-bottom: 4px; }
code, pre {
font-family: Menlo, Consolas, monospace;
background: #f5f5f7;
border-radius: 4px;
padding: 2px 4px;
}
pre { padding: 8px 10px; overflow-x: auto; }
ul { margin: 4px 0 8px 18px; padding: 0; }
li { margin: 2px 0; }
</style>
</head>
<body>
<h1>Welcome to the Probo Device Posture Agent</h1>
<p>
This installer adds <code>probo-agent</code> to your Mac and starts it
as a system service. The agent reports device posture &mdash; disk
encryption, screen lock, firewall, OS version, and similar
signals &mdash; back to your Probo workspace over HTTPS.
</p>
<h2>What the installer does</h2>
<ul>
<li>Installs the <code>probo-agent</code> binary to
<code>/usr/local/bin/probo-agent</code>.</li>
<li>Registers the launchd unit
<code>com.getprobo.agent</code> in
<code>/Library/LaunchDaemons</code>.</li>
<li>Creates the persistent state directory
<code>/var/lib/probo-agent</code> (root-owned, mode 0700).</li>
<li>Enrolls the device automatically when an admin has pre-staged
<code>/tmp/probo-agent.conf</code> (typically via an MDM).</li>
</ul>
<h2>What you will need</h2>
<ul>
<li>Administrator privileges on this Mac.</li>
<li>The Probo server URL (e.g.
<code>https://app.getprobo.com</code>).</li>
<li>A device enrollment token issued by a workspace administrator.</li>
</ul>
<p>
Click <strong>Continue</strong> to review the license, then
<strong>Install</strong> to proceed.
</p>
</body>
</html>

View File

@@ -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 <path-to-probo-agent> 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}"

View File

@@ -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=<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 <URL> --enrollment-token <TOKEN>"
fi
echo "=== postinstall done ==="
exit 0

372
cmd/probo-agent/main.go Normal file
View File

@@ -0,0 +1,372 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

71
go.mod
View File

@@ -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

321
go.sum
View File

@@ -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=

583
pkg/deviceagent/agent.go Normal file
View File

@@ -0,0 +1,583 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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()
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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)
},
)
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,440 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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")
}

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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)
}

View File

@@ -0,0 +1,399 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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 ""
}

View File

@@ -0,0 +1,421 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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=<value>" 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 <ON|OFF>" 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)
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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)
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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]
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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]
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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]
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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()
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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"
)

234
pkg/deviceagent/client.go Normal file
View File

@@ -0,0 +1,234 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

145
pkg/deviceagent/config.go Normal file
View File

@@ -0,0 +1,145 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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"
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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")
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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)
},
)
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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])
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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 ""
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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)
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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)
},
)
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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"
)

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{xml .Label}}</string>
<key>ProgramArguments</key>
<array>
<string>{{xml .ExePath}}</string>
<string>run</string>
<string>--dir</string>
<string>{{xml .Dir}}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/probo-agent.log</string>
<key>StandardErrorPath</key>
<string>/var/log/probo-agent.log</string>
<key>UserName</key>
<string>root</string>
<key>GroupName</key>
<string>wheel</string>
</dict>
</plist>
`
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
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
// `<ArchiveDir>/<BinaryName>` 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
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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)
},
)
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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 <dst>.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) {}

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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 `<dst>.new` (same directory, so the final rename is
// just a metadata update and won't cross volumes).
// 2. Move the running binary to `<dst>.old` (NTFS lets us rename a
// locked exe).
// 3. Move `<dst>.new` into place at `<dst>`.
//
// On the next start the agent's main() calls CleanupAfterRestart to
// best-effort delete `<dst>.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
// `<exePath>.old` is fine and will be retried on the next boot.
func CleanupAfterRestart(exePath string) {
if exePath == "" {
return
}
_ = os.Remove(exePath + oldSuffix)
}

View File

@@ -0,0 +1,588 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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 `<agent state dir>/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 `<hex> <name>`; 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)
}

View File

@@ -0,0 +1,460 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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")
}

View File

@@ -0,0 +1,194 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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/<repo>/<workflow>@refs/tags/<tag-prefix><version>
//
// where <repo>, <workflow> and <tag-prefix> 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) +
`.+$`
}