projection: begin the exact-path public epoch

The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally.

Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

13
.gitea/allowed_signers Normal file
View file

@ -0,0 +1,13 @@
# Keys authorised to sign RedFlag release tags. The version gate in
# .gitea/workflows/release.yml verifies the tag against this file and nothing
# else, so this list is the whole of the release-signing authority.
#
# This key signs tags and does nothing else: it is absent from ~/.ssh/config,
# from every authorized_keys, and from any transport path. A key that opens a
# host should never also vouch for a release.
#
# Rotation: add the incoming key on its own line, cut one release that both
# keys can verify, then delete the outgoing line. ssh(1) allowed_signers also
# accepts valid-after= and valid-before= options when a key should lapse on a
# date rather than on a release.
casey.tunturi@gmail.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMLaHSap8O8lqv1ipvXC8F0QFBqAZF66Ombe/Ep8kX8M RedFlag release signing

527
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,527 @@
name: ci
on:
push:
branches: [main, public]
pull_request:
branches: [main, public]
workflow_dispatch:
inputs:
custody_admission:
description: Stage a synthetic candidate for non-publishing internal admission
type: boolean
default: false
publish_source:
description: Explicitly publish the current internal public projection
type: boolean
default: false
public_projection_sha:
description: Exact 40-character internal public SHA authorized for publication
type: string
default: ""
jobs:
release-contract:
runs-on: ubuntu-latest
container:
image: debian:13-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132
steps:
- name: Install distro runtime
run: apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates curl git nodejs python3
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Verify candidate policy and custody rejection
run: python3 scripts/test-release-contract.py -v
- name: Stage synthetic custody admission fixture
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && github.event.inputs.custody_admission == 'true'
env:
PACKAGE_WRITE_TOKEN: ${{ secrets.PACKAGE_WRITE_TOKEN }}
run: |
set -euo pipefail
python3 scripts/test-release-contract.py --export-fixture admission-fixture
scripts/stage-release-candidate.sh admission-fixture artifacts "admission-${GITHUB_RUN_ID}"
echo "ADMISSION_SHELF_VERSION=admission-${GITHUB_RUN_ID}"
go-vet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- name: go vet (server)
run: cd server && go vet ./...
- name: go vet (agent)
run: cd agent && go vet ./...
go-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- name: go test -race (server)
run: cd server && go test -race -count=1 ./...
- name: go test -race (agent)
run: cd agent && go test -race -count=1 ./...
rust-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
components: clippy
- name: cargo test
run: cd helper && cargo test
- name: Check package-managed binary update refusal
run: cd helper && REDFLAG_PACKAGE_BIN_DIR=/usr/bin cargo test --locked package_managed_binary_updates
- name: cargo clippy
run: cd helper && cargo clippy -- -D warnings
# Cross-compile check: does it build for every target platform?
# Tests run only on native linux-amd64 above; this catches portability
# regressions (cfg(target_os), FFI, path assumptions) without needing
# a runner per OS.
cross-compile:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: arm64
rust_target: aarch64-unknown-linux-gnu
linker: gcc-aarch64-linux-gnu
use_zigbuild: false
skip_helper: false
- goos: windows
goarch: amd64
rust_target: ""
linker: gcc-mingw-w64-x86-64
use_zigbuild: false
skip_helper: true
- goos: darwin
goarch: arm64
rust_target: aarch64-apple-darwin
linker: ""
use_zigbuild: true
skip_helper: false
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
targets: ${{ matrix.rust_target }}
- name: Install cross-linker
if: matrix.linker != ''
run: sudo apt-get update && sudo apt-get install -y ${{ matrix.linker }}
- name: Install cargo-zigbuild
if: matrix.use_zigbuild
run: pip3 install --break-system-packages cargo-zigbuild
- name: Cross-compile Go (server)
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: cd server && go build -o /dev/null ./cmd/server/
- name: Cross-compile Go (agent)
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: cd agent && go build -o /dev/null ./cmd/agent/
- name: Cross-compile Rust (helper)
if: "!matrix.skip_helper"
run: |
cd helper
if [ "${{ matrix.use_zigbuild }}" = "true" ]; then
cargo zigbuild --release --target ${{ matrix.rust_target }}
else
cargo build --release --target ${{ matrix.rust_target }}
fi
web-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
# npm run build = tsc && vite build — type errors and bundling failures
# both surface here, not at release time.
- name: Build web UI
run: cd web && npm ci && npm run build
desktop-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- name: Install Qt 6 build dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools libgl1-mesa-dev
# A release build subsumes the old `cargo check` and leaves behind
# something a human can install. Nothing else in CI emitted a Desktop
# binary, so a one-line fix could only be tried by cutting a release tag.
- name: Build native RedFlag Desktop
run: cd desktop && cargo build --release --locked
- name: Prove the binary links and names its version
run: desktop/target/release/redflag-desktop --version
- name: Package the matching Linux Agent, helper and Desktop
run: |
set -euo pipefail
VERSION=$(desktop/target/release/redflag-desktop --version | awk '{sub(/^v/, "", $2); print $2}')
mkdir -p dist
(cd agent && go build -trimpath -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$VERSION" -o ../dist/redflag-agent-linux-amd64 ./cmd/agent)
(cd helper && REDFLAG_PACKAGE_BIN_DIR=/usr/bin REDFLAG_RELEASE_VERSION="$VERSION" cargo build --release --locked)
cp helper/target/release/redflag-helper dist/redflag-helper-linux-amd64
cp desktop/target/release/redflag-desktop dist/redflag-desktop-linux-amd64
bash installer/linux/build-deb.sh --version "$VERSION" --bindir dist --outdir dist
bash installer/linux/inspect-deb.sh "dist/redflag_${VERSION}_amd64.deb" --version "$VERSION"
sha256sum dist/redflag_*_amd64.deb > dist/desktop-package.sha256
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: redflag-desktop-linux-amd64-package
path: |
dist/redflag_*_amd64.deb
dist/desktop-package.sha256
if-no-files-found: error
retention-days: 30
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: redflag-desktop-linux-amd64
path: desktop/target/release/redflag-desktop
retention-days: 30
installer-integrity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- name: Install template integrity
run: cd server && go test -run 'TestInstallTemplateRenders|TestFreshInstallConfigKeys|TestInstallTemplateScriptletSyntax' -v -count=1 ./internal/services/
# Dependency vulnerability scanning — RedFlag held to the supply-chain standard
# it enforces on the fleet. Tools installed directly (no third-party actions) so
# the socket-mounted runner's surface stays small. Go is reachability-gated via a
# documented allowlist (.govulncheck-allow, mirrored in SECURITY.md); npm gates the
# production tree and treats dev-only advisories as warnings; cargo gates outright.
dep-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
# Provenance first: record the substrate even if a later scan fails the job.
# "Are we using hacked programs to build it?" — this is how we SEE the answer.
# Floor enforcement (fail on an out-of-date engine/toolchain) is the next layer.
- name: Record build substrate
run: |
{
echo "## Build substrate"
echo '```'
echo "go: $(go version)"
echo "rustc: $(rustc --version)"
echo "cargo: $(cargo --version)"
echo "node: $(node --version)"
echo "npm: $(npm --version)"
echo "docker: $(docker version --format '{{.Server.Version}}' 2>&1 || echo 'no engine reachable')"
echo "runner: ${RUNNER_NAME:-unknown} / $(uname -srm)"
echo '```'
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
# Scanners run latest on purpose — an old scanner misses new advisories.
- name: Install scanners
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
cargo install cargo-audit --locked
# One audited script gates Go (reachability + allowlist), web (prod tree),
# and the Rust helper. release.yml runs the same script with --posture-out
# to emit the attested posture — CI and release can't drift on the verdict.
- name: Dependency gate
run: scripts/dep-scan.sh
commit-voice:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Test and enforce the public commit voice
run: |
python3 -m unittest discover -s .publication -p 'test_commit_voice.py'
if [ "${{ github.ref }}" = "refs/heads/public" ] || [ "${{ github.base_ref }}" = "public" ]; then
RANGE="${{ github.sha }}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}..${{ github.sha }}"
else
RANGE="${{ github.event.before }}..${{ github.sha }}"
# A manual re-run carries no before sha, and neither does a first
# push. Checking HEAD alone would let one clean tip commit launder
# the history behind it and report green, so both fall back to a
# window rather than a single commit.
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || \
[ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
RANGE="$(git rev-list --max-count=10 HEAD | tail -1)^..HEAD"
git rev-parse --verify "${RANGE%%..*}" >/dev/null 2>&1 || RANGE="HEAD"
fi
fi
python3 .publication/commit_voice.py --range "$RANGE" \
--allowlist .publication/commit-voice-allowlist.json \
--repository Fimeg/RedFlag
action-pins:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check for floating action refs
run: |
if grep -rE 'uses:.*@(v[0-9]+|stable|main|master)(\s|$)' .gitea/workflows/; then
echo "::error::Floating action refs found — run scripts/update-action-pins.sh"
exit 1
fi
echo "All action refs are SHA-pinned."
public-history:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/public' || github.base_ref == 'public'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Fetch pinned gitleaks
run: |
command -v curl || (apt-get update -qq && apt-get install -y -qq curl)
curl -fsSL -o /tmp/gitleaks.tar.gz \
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' |
sha256sum -c -
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
- name: Scan complete public history
run: |
/tmp/gitleaks git . --redact --no-banner --log-opts="$GITHUB_SHA"
scripts/check-public-history.sh HEAD
# Absence of known-secret content is not authorization to publish. This job
# asks the other question: does this tree belong outside at all. The manifest
# it reads is the authority for what may cross, and changing that manifest is
# a public-surface decision that shows up in this diff rather than in nobody's
# memory.
public-surface:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/public' || github.base_ref == 'public'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Gate the public surface
run: |
set -euo pipefail
python3 -m unittest discover -s .publication -p 'test_surface_gate.py'
python3 .publication/surface_gate.py \
--repo . --sha "$GITHUB_SHA" \
--manifest .publication/surface.json \
--out /tmp/public-surface.md
cat /tmp/public-surface.md
grep -q '^\*\*Verdict: \(PASS\|REVIEW\)\*\*' /tmp/public-surface.md
# An internal public push proves that a projection is safe to disclose; it
# does not disclose it. Publication requires a manual dispatch on the public
# branch with both the boolean authority and the exact tested SHA. The
# internal forge remains the only writer, and the result is read back
# anonymously before any optional downstream moves.
publish-forge:
runs-on: ubuntu-latest
needs: [go-vet, go-test, rust-test, cross-compile, web-build, desktop-check, installer-integrity, dep-scan, commit-voice, action-pins, public-history, public-surface]
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/public' && github.event.inputs.publish_source == 'true'
env:
PUBLIC_FORGE_TOKEN: ${{ secrets.PUBLIC_FORGE_TOKEN }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Publish and verify exact SHA
env:
AUTHORIZED_SHA: ${{ github.event.inputs.public_projection_sha }}
run: |
set -euo pipefail
: "${PUBLIC_FORGE_TOKEN:?PUBLIC_FORGE_TOKEN is required}"
expected="${GITHUB_SHA}"
[[ "$AUTHORIZED_SHA" =~ ^[0-9a-f]{40}$ ]]
if [ "$AUTHORIZED_SHA" != "$expected" ]; then
echo "::error::authorized projection $AUTHORIZED_SHA does not equal tested workflow SHA $expected"
exit 1
fi
forge_url='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
# Ordinary publication is fast-forward only. .publication/EPOCH may
# authorise exactly one replacement, and only of the SHA it names, so
# the authorisation is spent the moment it is used.
epoch_replaces() {
[ -f .publication/EPOCH ] || return 1
awk '$1 == "replaces" { print $2 }' .publication/EPOCH
}
check_publishable() {
url="$1"
remote_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"
if [ -z "$remote_sha" ]; then
return 0
fi
if git cat-file -e "${remote_sha}^{commit}" 2>/dev/null &&
git merge-base --is-ancestor "$remote_sha" "$expected"; then
return 0
fi
authorised="$(epoch_replaces || true)"
if [ -n "$authorised" ] && [ "$authorised" = "$remote_sha" ]; then
echo "[publish] epoch authorised to replace $remote_sha"
EPOCH_LEASE="$remote_sha"
return 0
fi
echo "[publish] refusing non-fast-forward public history: $url" >&2
echo "[publish] remote is $remote_sha; .publication/EPOCH authorises ${authorised:-nothing}" >&2
exit 1
}
EPOCH_LEASE=""
check_publishable "$forge_url"
forge_auth="$(printf 'publisher-redflag:%s' "$PUBLIC_FORGE_TOKEN" | base64 -w0)"
if [ -n "$EPOCH_LEASE" ]; then
git -c "http.https://forge.caseytunturi.com/.extraheader=Authorization: Basic $forge_auth" \
push --force-with-lease="refs/heads/public:$EPOCH_LEASE" "$forge_url" public:public
else
git -c "http.https://forge.caseytunturi.com/.extraheader=Authorization: Basic $forge_auth" \
push "$forge_url" public:public
fi
forge_sha="$(git ls-remote "$forge_url" refs/heads/public | awk '{print $1}')"
test "$forge_sha" = "$expected"
# Fetch the anonymous Forgejo ref back into the checkout. Downstream
# receives this ref, not the internal checkout ref that happened to
# produce it.
git fetch --force --no-tags "$forge_url" \
refs/heads/public:refs/remotes/public-forge/public
test "$(git rev-parse refs/remotes/public-forge/public)" = "$forge_sha"
echo "[publish] Forgejo anonymously serves exact tested SHA: $forge_sha"
# Downstreams reproduce the anonymously fetched Forgejo ref. They are
# deliberately best-effort: a missing credential or divergent history is a
# visible degraded mirror, never a failure of the canonical publication.
mirror-downstreams:
runs-on: ubuntu-latest
needs: [publish-forge]
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/public' && github.event.inputs.publish_source == 'true'
env:
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Mirror optional downstreams from Forgejo
run: |
set -euo pipefail
expected="${GITHUB_SHA}"
forge_url='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
forge_sha="$(git ls-remote "$forge_url" refs/heads/public | awk '{print $1}')"
test "$forge_sha" = "$expected"
git fetch --force --no-tags "$forge_url" \
refs/heads/public:refs/remotes/public-forge/public
test "$(git rev-parse refs/remotes/public-forge/public)" = "$forge_sha"
mirror_downstream() {
label="$1"
url="$2"
host="$3"
user="$4"
token="$5"
if [ -z "$token" ]; then
echo "::warning::[mirror] $label credential absent; Forgejo is published, $label is degraded"
return 0
fi
if ! remote_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"; then
echo "::warning::[mirror] cannot read $label public ref; Forgejo remains authoritative"
return 0
fi
lease=""
if [ -n "$remote_sha" ]; then
if ! git fetch --force --no-tags "$url" \
"refs/heads/public:refs/remotes/mirror-check/$label"; then
echo "::warning::[mirror] cannot fetch $label public ref; leaving it unchanged"
return 0
fi
if ! git merge-base --is-ancestor "$remote_sha" "$forge_sha"; then
# A mirror may follow the same epoch the forge just accepted,
# and only from the SHA that epoch names. Anything else is the
# divergence this branch has always refused.
authorised="$(awk '$1 == "replaces" { print $2 }' .publication/EPOCH 2>/dev/null || true)"
if [ -n "$authorised" ] && [ "$authorised" = "$remote_sha" ]; then
echo "[mirror] $label follows the authorised epoch from $remote_sha"
lease="$remote_sha"
else
echo "::warning::[mirror] refusing non-fast-forward $label history; recovery ref and explicit alignment required"
return 0
fi
fi
fi
auth="$(printf '%s:%s' "$user" "$token" | base64 -w0)"
if [ -n "$lease" ]; then
if ! git -c "http.https://$host/.extraheader=Authorization: Basic $auth" \
push --force-with-lease="refs/heads/public:$lease" \
"$url" refs/remotes/public-forge/public:public; then
echo "::warning::[mirror] $label epoch push failed; Forgejo remains authoritative"
return 0
fi
elif ! git -c "http.https://$host/.extraheader=Authorization: Basic $auth" \
push "$url" refs/remotes/public-forge/public:public; then
echo "::warning::[mirror] $label push failed; Forgejo remains authoritative"
return 0
fi
mirrored_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"
if [ "$mirrored_sha" != "$forge_sha" ]; then
echo "::warning::[mirror] $label SHA mismatch after push; Forgejo remains authoritative"
return 0
fi
echo "[mirror] $label agrees with Forgejo: $forge_sha"
}
mirror_downstream codeberg 'https://codeberg.org/Fimeg/RedFlag.git' codeberg.org Fimeg "$CODEBERG_TOKEN"
mirror_downstream github 'https://github.com/Fimeg/RedFlag.git' github.com Fimeg "$MIRROR_GITHUB_TOKEN"

View file

@ -0,0 +1,39 @@
name: desktop-windows
on:
workflow_dispatch:
inputs:
runner:
description: Native Windows runner label with Qt 6, MSVC, Rust, Go and Node
required: true
default: windows-desktop
version:
description: Desktop version matching the checked-out Cargo crate (three or four parts)
required: true
default: '0.2.9'
permissions:
contents: read
jobs:
desktop:
runs-on: ${{ github.event.inputs.runner }}
defaults:
run:
shell: powershell
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Build and stage the complete native Desktop payload
env:
DESKTOP_VERSION: ${{ github.event.inputs.version }}
run: |
$ErrorActionPreference = 'Stop'
$output = Join-Path $env:RUNNER_TEMP ('redflag-desktop-' + [guid]::NewGuid().ToString())
& ./installer/windows/build-desktop.ps1 -OutputDirectory $output -Version $env:DESKTOP_VERSION
if ($LASTEXITCODE -ne 0) { throw 'Native Desktop payload failed.' }
"DESKTOP_PAYLOAD=$output" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: redflag-desktop-windows-amd64
path: ${{ env.DESKTOP_PAYLOAD }}
if-no-files-found: error
retention-days: 30

View file

@ -0,0 +1,61 @@
name: msi-custody-proof
# Manual internal proof for an ordinary branch. It calls the same MSI build
# entry point as release.yml, but creates no tag, release, or public artifact.
on:
workflow_dispatch:
jobs:
prove:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: server/go.mod
cache: true
- name: Resolve Server version
id: version
run: |
set -euo pipefail
VERSION=$(grep -P '^\s*AgentVersion\s*=' server/internal/version/versions.go | grep -oP '"\K[^"]+')
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
echo "Server version: $VERSION"
- name: Build staged Windows Server
env:
GOOS: windows
GOARCH: amd64
CGO_ENABLED: "0"
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.value }}"
mkdir -p dist
cd server
go build -ldflags "-s -w \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.AgentVersion=$VERSION \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.ConfigVersion=$VERSION" \
-o ../dist/redflag-server-windows-amd64.exe ./cmd/server/
- name: Build MSI and prove staged-byte custody
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq jq msitools wixl
installer/windows/build-msi.sh \
dist/redflag-server-windows-amd64.exe \
"${{ steps.version.outputs.value }}" \
dist/RedFlagSetup-windows-amd64.msi \
dist/installer-proof-windows-amd64.json \
"$GITHUB_SHA" \
"$GITHUB_REF"
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: RedFlagSetup-windows-amd64-custody-proof
path: |
dist/RedFlagSetup-windows-amd64.msi
dist/installer-proof-windows-amd64.json
retention-days: 30

View file

@ -0,0 +1,181 @@
name: nightly
# Rolling alpha channel. Every night, if CI is green on public HEAD and there
# are new commits since the last nightly, build lean artifacts and replace the
# `nightly` prerelease on Gitea. Versioned releases stay on
# release.yml — this channel never tags v*, never ships a manifest, and is
# therefore invisible to fleet self-upgrade. Manual installs only.
on:
schedule:
- cron: "0 9 * * *"
workflow_dispatch: {}
permissions:
contents: write
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: public
fetch-depth: 0
- name: Preflight — CI green, new commits since last nightly
id: pre
run: |
set -euo pipefail
SHA=$(git rev-parse HEAD)
SHORT=$(git rev-parse --short HEAD)
API="${GITHUB_SERVER_URL}/api/v1"
STATE=$(curl -sf -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"$API/repos/${GITHUB_REPOSITORY}/commits/$SHA/status" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('state',''))" || echo "")
echo "HEAD=$SHA ci_state=$STATE"
if [ "$STATE" != "success" ]; then
echo "[INFO] [nightly] CI not green on public HEAD (state=$STATE) — no build tonight"
echo "go=false" >> "$GITHUB_OUTPUT"
exit 0
fi
PREV=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"$API/repos/${GITHUB_REPOSITORY}/releases/tags/nightly" \
| python3 -c "
import json,sys
try:
r = json.load(sys.stdin)
sha = r.get('target_commitish','')
print(sha if len(sha) == 40 else '')
except Exception:
print('')")
if [ "$PREV" = "$SHA" ]; then
echo "[INFO] [nightly] public HEAD unchanged since last nightly — nothing to build"
echo "go=false" >> "$GITHUB_OUTPUT"
exit 0
fi
BASE=$(grep -P '^\s*AgentVersion\s*=' server/internal/version/versions.go | grep -oP '"\K[^"]+')
VERSION="${BASE}-nightly.$(date -u +%Y%m%d).${SHORT}"
{
echo "go=true"
echo "sha=$SHA"
echo "short=$SHORT"
echo "prev=$PREV"
echo "version=$VERSION"
} >> "$GITHUB_OUTPUT"
echo "Building nightly $VERSION (prev nightly: ${PREV:-none})"
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
if: steps.pre.outputs.go == 'true'
with:
go-version-file: agent/go.mod
cache: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
if: steps.pre.outputs.go == 'true'
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
if: steps.pre.outputs.go == 'true'
- name: Build web UI and stage embed
if: steps.pre.outputs.go == 'true'
run: |
set -euo pipefail
cd web && npm ci && npm run build && cd ..
rm -rf server/internal/webui/dist
cp -r web/dist server/internal/webui/dist
test -s server/internal/webui/dist/index.html
# Agent + helper only. The server is NOT shipped on the nightly channel:
# its supported install paths are docker-compose-from-source today and the
# per-OS installers landing in v0.3.0 — both verify the signed release
# manifest, which nightly deliberately does not generate. Shipping a bare
# nightly server tarball would be an install artifact with a stub manifest,
# so it's left to the versioned (v*) channel where the manifest is real.
- name: Build agent + helper binaries (linux-amd64 + windows-amd64)
if: steps.pre.outputs.go == 'true'
run: |
set -euo pipefail
VERSION="${{ steps.pre.outputs.version }}"
mkdir -p dist
build_agent () { # $1=goos $2=suffix $3=ext
GOOS=$1 GOARCH=amd64 CGO_ENABLED=0 sh -c "
cd agent && go build -ldflags \"-s -w \
-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$VERSION \
-X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=$VERSION \
-X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \
-o ../dist/redflag-agent-$2$3 ./cmd/agent/"
}
build_agent linux linux-amd64 ""
build_agent windows windows-amd64 ".exe"
cd helper && cargo build --release && cd ..
cp helper/target/release/redflag-helper dist/redflag-helper-linux-amd64
- name: Package
if: steps.pre.outputs.go == 'true'
run: |
set -euo pipefail
VERSION="${{ steps.pre.outputs.version }}"
cd dist
tar czf redflag-$VERSION-linux-amd64.tar.gz redflag-agent-linux-amd64 redflag-helper-linux-amd64
zip -q redflag-$VERSION-windows-amd64.zip redflag-agent-windows-amd64.exe
sha256sum redflag-$VERSION-linux-amd64.tar.gz redflag-$VERSION-windows-amd64.zip > checksums-$VERSION.txt
ls -la
- name: Write release notes
if: steps.pre.outputs.go == 'true'
run: |
set -euo pipefail
SHA="${{ steps.pre.outputs.sha }}"
PREV="${{ steps.pre.outputs.prev }}"
VERSION="${{ steps.pre.outputs.version }}"
{
echo "Nightly alpha build — untagged channel, replaced every night. Agent + helper test binaries only; not fleet-upgradable (no manifest). Install the server from a versioned (v*) release or build from source."
echo ""
echo "version: $VERSION"
echo "commit: $SHA"
echo "built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
if [ -n "$PREV" ] && git cat-file -e "$PREV" 2>/dev/null; then
echo "### Since last nightly"
echo '```'
git log --oneline --no-decorate "$PREV..$SHA" | head -50
echo '```'
fi
} > notes.md
cat notes.md
- name: Publish nightly release (Gitea)
if: steps.pre.outputs.go == 'true'
run: |
set -euo pipefail
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TOKEN="${{ secrets.GITHUB_TOKEN }}"
SHA="${{ steps.pre.outputs.sha }}"
VERSION="${{ steps.pre.outputs.version }}"
RID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/nightly" \
| python3 -c "
import json,sys
try: print(json.load(sys.stdin).get('id',''))
except Exception: print('')")
[ -n "$RID" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$RID"
curl -s -o /dev/null -X DELETE -H "Authorization: token $TOKEN" "$API/tags/nightly" || true
BODY=$(python3 -c "import json; print(json.dumps(open('notes.md').read()))")
RID=$(curl -sf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"nightly\",\"target_commitish\":\"$SHA\",\"name\":\"nightly $VERSION\",\"prerelease\":true,\"draft\":false,\"body\":$BODY}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
for f in dist/redflag-$VERSION-linux-amd64.tar.gz dist/redflag-$VERSION-windows-amd64.zip dist/checksums-$VERSION.txt; do
curl -sf -o /dev/null -X POST "$API/releases/$RID/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" -F "attachment=@$f"
done
echo "Gitea nightly published (release id=$RID)"

View file

@ -0,0 +1,238 @@
name: promote-release
on:
workflow_dispatch:
inputs:
tag:
description: Signed internal Gitea alpha tag to promote, such as v0.2.9.4
required: true
type: string
receipt_sha256:
description: SHA-256 of the tested internal files.sha256 receipt
required: true
type: string
permissions:
contents: read
jobs:
promote:
runs-on: release-trusted
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Verify selected internal release tag
id: release
env:
PROMOTE_TAG: ${{ github.event.inputs.tag }}
run: |
set -euo pipefail
TAG=$PROMOTE_TAG
[[ "$TAG" =~ ^v[0-9]+(\.[0-9]+){2,3}([.-][0-9A-Za-z]+)*$ ]]
[[ "$TAG" != */* ]]
git check-ref-format "refs/tags/$TAG"
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
test -r "$TOKEN_FILE"
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
GIT_TOKEN_FILE="$TOKEN_FILE" \
GIT_TOKEN_USERNAME=release-redflag \
GIT_TERMINAL_PROMPT=0 \
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
git config gpg.format ssh
git config gpg.ssh.allowedSignersFile .gitea/allowed_signers
test "$(git cat-file -t "refs/tags/$TAG")" = tag
git tag -v "$TAG"
SOURCE_SHA=$(git rev-parse "${TAG}^{commit}")
VERSION=${TAG#v}
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
- name: Fetch accepted internal Gitea release bytes
env:
TAG: ${{ steps.release.outputs.tag }}
VERSION: ${{ steps.release.outputs.version }}
SOURCE_SHA: ${{ steps.release.outputs.source_sha }}
ACCEPTED_RECEIPT_SHA256: ${{ github.event.inputs.receipt_sha256 }}
run: |
set -euo pipefail
[[ "$ACCEPTED_RECEIPT_SHA256" =~ ^[0-9a-f]{64}$ ]]
command -v jq >/dev/null
API="${GITHUB_SERVER_URL}/api/v1"
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
test -r "$TOKEN_FILE"
GITEA_RELEASE_TOKEN=$(<"$TOKEN_FILE")
test -n "$GITEA_RELEASE_TOKEN"
gitea_curl() {
curl --config <(printf 'silent\nshow-error\nheader = "Authorization: token %s"\n' "$GITEA_RELEASE_TOKEN") "$@"
}
gitea_curl -f "$API/repos/${GITHUB_REPOSITORY}/releases/tags/$TAG" \
>/tmp/gitea-release.json
test "$(jq -r .tag_name /tmp/gitea-release.json)" = "$TAG"
test "$(jq -r .draft /tmp/gitea-release.json)" = false
RELEASE_ID=$(jq -er .id /tmp/gitea-release.json)
gitea_curl -f \
"$API/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?limit=100" \
>/tmp/gitea-assets.json
asset_url() {
jq -er --arg name "$1" \
'[.[] | select(.name == $name)] | if length == 1 then .[0].browser_download_url else error("asset count is not one") end' \
/tmp/gitea-assets.json
}
rm -rf artifacts/internal
mkdir -p artifacts/internal
gitea_curl -fL -o artifacts/internal/files.sha256 \
"$(asset_url files.sha256)"
RECEIPT_SHA256=$(sha256sum artifacts/internal/files.sha256 | awk '{print $1}')
test "$RECEIPT_SHA256" = "$ACCEPTED_RECEIPT_SHA256"
: >/tmp/expected-release-assets
echo files.sha256 >>/tmp/expected-release-assets
while read -r expected name extra; do
test -z "${extra:-}"
[[ "$expected" =~ ^[0-9a-f]{64}$ ]]
case "$name" in
''|/*|*'..'*|*/*)
echo "::error::unsafe internal release filename: $name"
exit 1
;;
esac
echo "$name" >>/tmp/expected-release-assets
gitea_curl -fL -o "artifacts/internal/$name" "$(asset_url "$name")"
printf '%s %s\n' "$expected" "$name" |
(cd artifacts/internal && sha256sum -c -)
done <artifacts/internal/files.sha256
LC_ALL=C sort /tmp/expected-release-assets >/tmp/expected-release-assets.sorted
test "$(uniq -d /tmp/expected-release-assets.sorted | wc -l)" -eq 0
jq -r '.[].name' /tmp/gitea-assets.json | LC_ALL=C sort \
>/tmp/actual-release-assets.sorted
diff -u /tmp/expected-release-assets.sorted /tmp/actual-release-assets.sorted
python3 scripts/release-contract.py verify artifacts/internal "$VERSION" "$SOURCE_SHA" "$TAG"
echo "Accepted internal alpha $TAG receipt $RECEIPT_SHA256"
- name: Promote the same bytes to Forgejo
env:
TAG: ${{ steps.release.outputs.tag }}
VERSION: ${{ steps.release.outputs.version }}
SOURCE_SHA: ${{ steps.release.outputs.source_sha }}
ACCEPTED_RECEIPT_SHA256: ${{ github.event.inputs.receipt_sha256 }}
run: |
set -euo pipefail
FORGE_URL='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
FORGE_API='https://forge.caseytunturi.com/api/v1/repos/Fimeg/RedFlag'
TOKEN_FILE=/srv/release-trusted/.secrets/forge-redflag-release-token
test -r "$TOKEN_FILE"
PUBLIC_FORGE_TOKEN=$(<"$TOKEN_FILE")
test -n "$PUBLIC_FORGE_TOKEN"
forge_curl() {
curl --config <(printf 'silent\nshow-error\nheader = "Authorization: token %s"\n' "$PUBLIC_FORGE_TOKEN") "$@"
}
git fetch --force --no-tags "$FORGE_URL" \
refs/heads/public:refs/remotes/public-forge/public
if ! git merge-base --is-ancestor "$SOURCE_SHA" refs/remotes/public-forge/public; then
echo "::error::Forgejo public branch does not contain release commit $SOURCE_SHA"
exit 1
fi
LOCAL_TAG_OBJECT=$(git rev-parse "refs/tags/$TAG")
remote_tag=$(git ls-remote --refs "$FORGE_URL" "refs/tags/$TAG" | awk '{print $1}')
if [ -n "$remote_tag" ]; then
git fetch --force --no-tags "$FORGE_URL" \
"refs/tags/$TAG:refs/public-forge/release-tag"
test "$(git cat-file -t refs/public-forge/release-tag)" = tag
test "$(git rev-parse refs/public-forge/release-tag)" = "$LOCAL_TAG_OBJECT"
test "$(git rev-parse 'refs/public-forge/release-tag^{commit}')" = "$SOURCE_SHA"
else
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
GIT_TOKEN_FILE="$TOKEN_FILE" \
GIT_TOKEN_USERNAME=publisher-redflag \
GIT_TERMINAL_PROMPT=0 \
git push "$FORGE_URL" "refs/tags/$TAG:refs/tags/$TAG"
fi
STABLE_FLOOR='0.3.0'
if [ "$(printf '%s\n%s\n' "$VERSION" "$STABLE_FLOOR" | sort -V | head -1)" = "$VERSION" ] \
&& [ "$VERSION" != "$STABLE_FLOOR" ]; then
PRERELEASE=true
else
PRERELEASE=false
fi
release_status=$(forge_curl -o /tmp/forgejo-release.json -w '%{http_code}' \
"$FORGE_API/releases/tags/$TAG")
case "$release_status" in
200)
;;
404)
payload=$(jq -nc \
--arg tag "$TAG" \
--arg name "$TAG" \
--arg body "Promoted from tested internal receipt SHA-256 $ACCEPTED_RECEIPT_SHA256" \
--argjson prerelease "$PRERELEASE" \
'{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:$prerelease}')
forge_curl -f -X POST "$FORGE_API/releases" \
-H 'Content-Type: application/json' \
--data "$payload" >/tmp/forgejo-release.json
;;
*)
echo "::error::Forgejo release lookup failed with HTTP $release_status"
exit 1
;;
esac
RELEASE_ID=$(jq -er .id /tmp/forgejo-release.json)
forge_curl -f \
"$FORGE_API/releases/$RELEASE_ID/assets?limit=100" >/tmp/forgejo-assets.json
mapfile -d '' release_files < <(
find artifacts/internal -maxdepth 1 -type f -print0 | LC_ALL=C sort -z
)
test "${#release_files[@]}" -gt 1
for file in "${release_files[@]}"; do
name=$(basename "$file")
asset_count=$(jq -r --arg name "$name" \
'[.[] | select(.name == $name)] | length' /tmp/forgejo-assets.json)
case "$asset_count" in
0)
forge_curl -f -X POST \
"$FORGE_API/releases/$RELEASE_ID/assets?name=$name" \
-F "attachment=@$file" >/tmp/forgejo-upload.json
download_url=$(jq -er .browser_download_url /tmp/forgejo-upload.json)
;;
1)
download_url=$(jq -r --arg name "$name" \
'.[] | select(.name == $name) | .browser_download_url' \
/tmp/forgejo-assets.json)
;;
*)
echo "::error::duplicate Forgejo release assets named $name"
exit 1
;;
esac
expected_hash=$(sha256sum "$file" | awk '{print $1}')
public_hash=$(curl -fsSL "$download_url" | sha256sum | awk '{print $1}')
test "$public_hash" = "$expected_hash"
echo "Verified public release asset: $name $expected_hash"
done
anonymous_tag=$(curl -fsS "$FORGE_API/releases/tags/$TAG" | jq -r .tag_name)
test "$anonymous_tag" = "$TAG"
public_tag_object=$(git ls-remote --refs "$FORGE_URL" "refs/tags/$TAG" | awk '{print $1}')
test "$public_tag_object" = "$LOCAL_TAG_OBJECT"
echo "Forgejo release $TAG is anonymous and byte-exact"

View file

@ -0,0 +1,735 @@
name: release
on:
push:
tags: ["v*"]
permissions:
contents: read
jobs:
# Gate runs before any build. A tag that doesn't agree with the tree is a
# broken release waiting to happen — fail here, not after artifacts exist.
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Version gate
run: |
set -euo pipefail
TAG=${GITHUB_REF#refs/tags/v}
echo "Tag version: $TAG"
FAIL=0
# versions.go — anchored so MinAgentVersion does not match.
SERVER_VER=$(grep -P '^\s*AgentVersion\s*=' server/internal/version/versions.go | grep -oP '"\K[^"]+')
CONFIG_VER=$(grep -P '^\s*ConfigVersion\s*=' server/internal/version/versions.go | grep -oP '"\K[^"]+')
echo "versions.go: AgentVersion=$SERVER_VER ConfigVersion=$CONFIG_VER"
if [ "$TAG" != "$SERVER_VER" ]; then
echo "::error::tag=$TAG but versions.go AgentVersion=$SERVER_VER"
FAIL=1
fi
if [ "$TAG" != "$CONFIG_VER" ]; then
echo "::error::tag=$TAG but versions.go ConfigVersion=$CONFIG_VER"
FAIL=1
fi
# docker-compose.yml BUILD_VERSION default.
COMPOSE_VER=$(grep -oP '(?<=BUILD_VERSION:-)[0-9]+(\.[0-9]+){3}' docker-compose.yml)
echo "docker-compose: BUILD_VERSION=$COMPOSE_VER"
if [ "$TAG" != "$COMPOSE_VER" ]; then
echo "::error::tag=$TAG but docker-compose BUILD_VERSION=$COMPOSE_VER"
FAIL=1
fi
# helper/Cargo.toml — 3-part semver, compare against first 3 octets.
CARGO_VER=$(grep -m1 '^version' helper/Cargo.toml | cut -d'"' -f2)
TAG_SEMVER=$(echo "$TAG" | cut -d. -f1-3)
echo "Cargo.toml: version=$CARGO_VER (tag semver=$TAG_SEMVER)"
if [ "$TAG_SEMVER" != "$CARGO_VER" ]; then
echo "::error::tag=$TAG (semver=$TAG_SEMVER) but Cargo.toml=$CARGO_VER"
FAIL=1
fi
# Desktop: optional component, but if present, its Rust/Qt crate version
# must match the first three tag octets.
if [ -f desktop/Cargo.toml ]; then
DESKTOP_CARGO_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2)
echo "desktop/Cargo.toml: version=$DESKTOP_CARGO_VER"
if [ "$TAG_SEMVER" != "$DESKTOP_CARGO_VER" ]; then
echo "::error::tag=$TAG (semver=$TAG_SEMVER) but desktop/Cargo.toml=$DESKTOP_CARGO_VER"
FAIL=1
fi
fi
# CHANGELOG must mention the version being released.
if ! grep -q "$TAG" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no entry for $TAG"
FAIL=1
fi
# Forward-only: the new tag must sort above every existing tag.
HIGHEST=$(git tag --list 'v*' --sort=-v:refname | head -1)
echo "Highest tag: $HIGHEST"
if [ "$HIGHEST" != "v$TAG" ]; then
echo "::error::v$TAG does not sort above existing tags (highest=$HIGHEST) — version must move forward"
FAIL=1
fi
# The tagged commit must be on public — no releases from stray branches.
if ! git rev-parse --verify --quiet origin/public >/dev/null; then
echo "::error::origin/public not found in checkout — cannot verify tag ancestry"
FAIL=1
elif ! git merge-base --is-ancestor "$GITHUB_SHA" origin/public; then
echo "::error::tagged commit $GITHUB_SHA is not on public"
FAIL=1
fi
# Tag must be signed with an SSH key from the allowed signers file.
# This ensures the release was created by an authorized maintainer.
SIGNERS_FILE=".gitea/allowed_signers"
if [ ! -f "$SIGNERS_FILE" ]; then
echo "::error::allowed signers file not found at $SIGNERS_FILE"
FAIL=1
else
# actions/checkout resolves the trigger ref to a commit SHA and then
# force-updates refs/tags/<tag> to point straight at it, printing
# "t [tag update]". That replaces the annotated tag object, and the
# signature it carries, with a lightweight ref — so there is nothing
# left here to verify. Fetch the real object back by name first.
# This cost v0.2.9.3 on 2026-09-04 and was invisible because the
# check below discarded its own stderr.
git fetch --force origin "refs/tags/v$TAG:refs/tags/v$TAG"
git config gpg.format ssh
git config gpg.ssh.allowedSignersFile "$SIGNERS_FILE"
if [ "$(git cat-file -t "refs/tags/v$TAG")" != tag ]; then
echo "::error::v$TAG is a lightweight tag — a release tag must be annotated and signed"
FAIL=1
elif [ "$(git rev-parse "v$TAG^{commit}")" != "$GITHUB_SHA" ]; then
echo "::error::v$TAG points at $(git rev-parse "v$TAG^{commit}"), not $GITHUB_SHA"
FAIL=1
elif ! git tag -v "v$TAG" >/dev/null 2>/tmp/tagverify.err; then
echo "::error::tag v$TAG is not signed or signature is invalid"
sed 's/^/ /' /tmp/tagverify.err
FAIL=1
else
echo "Tag signature verified"
fi
fi
exit $FAIL
- name: Component catalog gate
run: |
set -euo pipefail
TAG=${GITHUB_REF#refs/tags/v}
FAIL=0
# The component catalog in server/internal/services/release_manifest.go
# is the authoritative list. Every required component must have source in
# the tree — the build jobs will verify binaries and the publish job will
# verify artifact presence, but this gate catches "we said we build X but
# X's directory doesn't exist" before any compile time is spent.
echo "Checking component catalog coverage..."
# Agent: must have agent/cmd/ and go.mod
if [ ! -d agent/cmd/agent ] || [ ! -f agent/go.mod ]; then
echo "::error::component 'agent' required but agent/cmd/ or go.mod missing"
FAIL=1
fi
# Server: must have server/cmd/ and go.mod
if [ ! -d server/cmd/server ] || [ ! -f server/go.mod ]; then
echo "::error::component 'server' required but server/cmd/ or go.mod missing"
FAIL=1
fi
# Helper: must have helper/Cargo.toml
if [ ! -f helper/Cargo.toml ]; then
echo "::error::component 'helper' required but helper/Cargo.toml missing"
FAIL=1
fi
# Web: must have web/package.json
if [ ! -f web/package.json ]; then
echo "::error::component 'web' required but web/package.json missing"
FAIL=1
fi
# Desktop: optional, but if present, verify its source exists
if [ -d desktop ]; then
if [ -f desktop/Cargo.toml ]; then
DESKTOP_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2 || echo "")
echo "desktop/Cargo.toml: version=$DESKTOP_VER"
if [ -n "$DESKTOP_VER" ] && [ "$DESKTOP_VER" != "0.1.0" ]; then
echo "[INFO] [gate] desktop version is $DESKTOP_VER (not 0.1.0 stub — version lockstep effective)"
fi
fi
else
echo "[INFO] [gate] desktop component optional — no desktop/ directory, skipping"
fi
echo "Component catalog gate: OK"
exit $FAIL
- name: Complete public history gate
run: |
set -euo pipefail
command -v curl || (apt-get update -qq && apt-get install -y -qq curl)
curl -fsSL -o /tmp/gitleaks.tar.gz \
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' |
sha256sum -c -
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
/tmp/gitleaks git . --redact --no-banner --log-opts="$GITHUB_SHA"
scripts/check-public-history.sh "$GITHUB_SHA"
# Build the web UI once — it's the same embed for every platform.
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build web UI
run: cd web && npm ci && npm run build
- name: Stage for embedding
run: |
rm -rf server/internal/webui/dist
cp -r web/dist server/internal/webui/dist
test -s server/internal/webui/dist/index.html
test -d server/internal/webui/dist/assets
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: webui-dist
path: server/internal/webui/dist
retention-days: 1
# Supply-chain gate with teeth: a release cannot ship with an un-accepted
# reachable dependency vulnerability. Runs the SAME scripts/dep-scan.sh as CI,
# plus --posture-out to emit the attested posture embedded into the server
# binary and signed into the release manifest. If this fails, `release` never
# builds (it is in `needs`).
dep-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- name: Install scanners
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
cargo install cargo-audit --locked
- name: Dependency gate + posture
run: scripts/dep-scan.sh --posture-out server/internal/services/posture-build.json
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: supply-chain-posture
path: server/internal/services/posture-build.json
retention-days: 1
# Build binaries for every target platform. The webui-dist and supply-chain
# posture artifacts are downloaded into the embed paths before the server compile.
release:
runs-on: ubuntu-latest
needs: [gate, web, dep-scan]
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
rust_target: x86_64-unknown-linux-gnu
suffix: linux-amd64
linker: ""
use_zigbuild: false
skip_helper: false
- goos: linux
goarch: arm64
rust_target: aarch64-unknown-linux-gnu
suffix: linux-arm64
linker: gcc-aarch64-linux-gnu
use_zigbuild: false
skip_helper: false
- goos: windows
goarch: amd64
rust_target: ""
suffix: windows-amd64
linker: gcc-mingw-w64-x86-64
use_zigbuild: false
skip_helper: true
- goos: darwin
goarch: arm64
rust_target: aarch64-apple-darwin
suffix: darwin-arm64
linker: ""
use_zigbuild: true
skip_helper: false
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: agent/go.mod
cache: true
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
targets: ${{ matrix.rust_target }}
- name: Install cross-linker
if: matrix.linker != ''
run: sudo apt-get update && sudo apt-get install -y ${{ matrix.linker }}
- name: Install cargo-zigbuild
if: matrix.use_zigbuild
run: pip3 install --break-system-packages cargo-zigbuild
- name: Download web UI
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
with:
name: webui-dist
path: server/internal/webui/dist
# Embed the attested supply-chain posture (replaces the committed
# attested:false stub) so the running server signs an honest posture.
- name: Download supply-chain posture
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
with:
name: supply-chain-posture
path: server/internal/services
- name: Build server
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
VERSION=${GITHUB_REF#refs/tags/v}
EXT=""
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
cd server && go build -ldflags "-s -w \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.AgentVersion=$VERSION \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.ConfigVersion=$VERSION" \
-o ../dist/redflag-server-${{ matrix.suffix }}${EXT} ./cmd/server/
- name: Build agent
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
VERSION=${GITHUB_REF#refs/tags/v}
EXT=""
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
cd agent && go build -ldflags "-s -w \
-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$VERSION \
-X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=$VERSION \
-X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o ../dist/redflag-agent-${{ matrix.suffix }}${EXT} ./cmd/agent/
- name: Build helper
if: "!matrix.skip_helper"
run: |
VERSION=${GITHUB_REF#refs/tags/v}
export REDFLAG_RELEASE_VERSION="$VERSION"
cd helper
if [ "${{ matrix.use_zigbuild }}" = "true" ]; then
cargo zigbuild --release --target ${{ matrix.rust_target }}
else
cargo build --release --target ${{ matrix.rust_target }}
fi
EXT=""
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
cp target/${{ matrix.rust_target }}/release/redflag-helper${EXT} ../dist/redflag-helper-${{ matrix.suffix }}${EXT}
- name: Build native Qt/QML Desktop
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
# CXX-Qt compiles the Rust bridge and embeds the QML module. The web
# application is not an input to the native Desktop binary.
sudo apt-get update -qq
sudo apt-get install -y -qq qt6-base-dev qt6-declarative-dev qt6-declarative-dev-tools libgl1-mesa-dev
cd desktop
REDFLAG_RELEASE_VERSION="$VERSION" cargo build --release --locked
cp target/release/redflag-desktop ../dist/redflag-desktop-${{ matrix.suffix }}
echo "Desktop binary built: $(ls -lh ../dist/redflag-desktop-${{ matrix.suffix }})"
# The crate version stays 3-part because Cargo will not take a
# fourth field; build.rs carries the release identity in instead, so
# the installed binary and its /v1/desktop report both name the
# release. Anything less and the fleet cannot tell 0.2.9.3 from
# 0.2.9.0. Exact match, not grep -q: v0.2.9 must not satisfy v0.2.90.
DESKTOP_VER=$(../dist/redflag-desktop-${{ matrix.suffix }} --version 2>/dev/null || echo "no-version")
echo "Desktop version: $DESKTOP_VER (expected v$VERSION)"
DESKTOP_NUM=$(printf '%s\n' "$DESKTOP_VER" | grep -oE 'v[0-9]+(\.[0-9]+)*' | head -1)
if [ "$DESKTOP_NUM" != "v$VERSION" ]; then
echo "::error::desktop binary reports ${DESKTOP_NUM:-no version}, expected v$VERSION"
exit 1
fi
- name: Build Windows installer (RedFlagSetup.msi)
if: matrix.goos == 'windows' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
# NOT the official WiX Toolset .NET CLI — `wix build` genuinely
# does not work when the compiler runs on Linux (reproduced: even
# a single-char Directory/@Name fails WIX0389 "not a relative
# path" on every WiX version 4.0.5 through 6.0.1; the tool's own
# output says "only supports Windows... undefined behavior"
# beyond that point). msitools' `wixl` is a from-scratch
# Linux-native reimplementation of the same MSI-building grammar,
# built for exactly this case — verified locally 2026-07-01
# (msiinfo confirms Directory/Component/ServiceInstall/Upgrade
# tables all populated correctly against a real cross-compiled
# server binary).
sudo apt-get update -qq
# /usr/bin/wixl is shipped by the `wixl` package on Ubuntu noble,
# not by `msitools` — msitools carries msiinfo and msibuild only.
# Installing msitools alone succeeds and then dies twenty lines
# later on "wixl: command not found", which is how v0.2.9.3 failed
# on 2026-09-04. msitools stays for the msiinfo verification the
# Product.wxs comments describe.
sudo apt-get install -y -qq jq msitools wixl
command -v wixl
command -v msiinfo
command -v msiextract
installer/windows/build-msi.sh \
dist/redflag-server-windows-amd64.exe \
"$VERSION" \
dist/RedFlagSetup-${{ matrix.suffix }}.msi \
dist/installer-proof-${{ matrix.suffix }}.json \
"$GITHUB_SHA" \
"$GITHUB_REF"
# Did it make it in: linux-amd64 binaries self-report the tag version.
# Cross-compiled binaries can't run here (wrong arch/OS), but the native
# ones must match.
- name: Verify binary versions (native only)
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
SERVER_OUT=$(./dist/redflag-server-${{ matrix.suffix }} --version)
echo "$SERVER_OUT"
echo "$SERVER_OUT" | grep -q "v$VERSION" || { echo "::error::server binary reports wrong version"; exit 1; }
AGENT_OUT=$(./dist/redflag-agent-${{ matrix.suffix }} --version)
echo "$AGENT_OUT"
echo "$AGENT_OUT" | grep -q "v$VERSION" || { echo "::error::agent binary reports wrong version"; exit 1; }
HELPER_OUT=$(./dist/redflag-helper-${{ matrix.suffix }} --version)
echo "$HELPER_OUT"
echo "$HELPER_OUT" | grep -q "v$VERSION" || { echo "::error::helper binary reports wrong version"; exit 1; }
- name: Build package-managed helper for Debian
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
cd helper
REDFLAG_PACKAGE_BIN_DIR=/usr/bin REDFLAG_RELEASE_VERSION="$VERSION" \
cargo test --locked package_managed_binary_updates
REDFLAG_PACKAGE_BIN_DIR=/usr/bin REDFLAG_RELEASE_VERSION="$VERSION" \
cargo build --release --locked --target-dir target/debian
- name: Package tarball
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd dist
# AGPL binaries travel with their licence or they are conveyed
# without one.
cp ../LICENSE ../THIRD_PARTY_LICENSES.md .
ls -la
if [ "${{ matrix.goos }}" = "windows" ]; then
# Windows: zip (no helper — it's Unix-only). Desktop binary and
# the RedFlagSetup installer are both optional (only exist for
# windows-amd64 today, not e.g. windows-arm64).
ZIP_FILES="LICENSE THIRD_PARTY_LICENSES.md redflag-server-${{ matrix.suffix }}.exe redflag-agent-${{ matrix.suffix }}.exe"
if [ -f "redflag-desktop-${{ matrix.suffix }}.exe" ]; then
ZIP_FILES="$ZIP_FILES redflag-desktop-${{ matrix.suffix }}.exe"
fi
if [ -f "RedFlagSetup-${{ matrix.suffix }}.msi" ]; then
ZIP_FILES="$ZIP_FILES RedFlagSetup-${{ matrix.suffix }}.msi"
fi
zip redflag-$VERSION-${{ matrix.suffix }}.zip $ZIP_FILES
sha256sum redflag-$VERSION-${{ matrix.suffix }}.zip > checksums-$VERSION-${{ matrix.suffix }}.txt
else
tar czf redflag-$VERSION-${{ matrix.suffix }}.tar.gz LICENSE THIRD_PARTY_LICENSES.md redflag-*-${{ matrix.suffix }}
sha256sum redflag-$VERSION-${{ matrix.suffix }}.tar.gz > checksums-$VERSION-${{ matrix.suffix }}.txt
# The .deb is a distributable in its own right, not a tarball member:
# it must reach custody as its own file or it is published untracked.
# amd64-only, matching build-deb.sh's control architecture.
if [ "${{ matrix.suffix }}" = "linux-amd64" ]; then
bash ../installer/linux/build-deb.sh --version "$VERSION" --bindir . --outdir . \
--helper ../helper/target/debian/release/redflag-helper
bash ../installer/linux/inspect-deb.sh "redflag_${VERSION}_amd64.deb" --version "$VERSION"
sha256sum redflag_${VERSION}_amd64.deb >> checksums-$VERSION-${{ matrix.suffix }}.txt
fi
fi
- name: Generate manifest artifact snippet
run: |
set -euo pipefail
command -v jq >/dev/null || (apt-get update -qq && apt-get install -y -qq jq)
VERSION=${GITHUB_REF#refs/tags/v}
cd dist
# Emit one JSON object per binary in this platform's tarball.
# The publish job merges these into the full manifest artifacts array.
echo '[]' > "release-${{ matrix.suffix }}.artifacts.json"
for bin in redflag-server redflag-agent redflag-helper redflag-desktop; do
for f in "${bin}-${{ matrix.suffix }}" "${bin}-${{ matrix.suffix }}.exe"; do
if [ -f "$f" ]; then
SHA=$(sha256sum "$f" | awk '{print $1}')
SIZE=$(stat -c%s "$f")
# Map binary prefix to manifest platform name.
case "$bin" in
redflag-server) PLAT="server-${{ matrix.goos }}" ;;
redflag-agent) PLAT="${{ matrix.goos }}" ;;
redflag-helper) PLAT="helper-${{ matrix.goos }}" ;;
redflag-desktop) PLAT="desktop-${{ matrix.goos }}" ;;
esac
jq --arg plat "$PLAT" --arg arch "${{ matrix.goarch }}" \
--arg file "$(basename "$f")" --arg sha "$SHA" \
--argjson size "$SIZE" \
'. + [{"platform":$plat,"architecture":$arch,"filename":$file,"sha256":$sha,"size":$size}]' \
"release-${{ matrix.suffix }}.artifacts.json" > tmp.json \
&& mv tmp.json "release-${{ matrix.suffix }}.artifacts.json"
fi
done
done
# RedFlagSetup.msi doesn't fit the redflag-<bin>-<suffix> naming
# convention above (no redflag- prefix, .msi not .exe) — handled
# separately. Windows-amd64 only for now.
if [ -f "RedFlagSetup-${{ matrix.suffix }}.msi" ]; then
SHA=$(sha256sum "RedFlagSetup-${{ matrix.suffix }}.msi" | awk '{print $1}')
SIZE=$(stat -c%s "RedFlagSetup-${{ matrix.suffix }}.msi")
jq --arg plat "installer-${{ matrix.goos }}" --arg arch "${{ matrix.goarch }}" \
--arg file "RedFlagSetup-${{ matrix.suffix }}.msi" --arg sha "$SHA" \
--argjson size "$SIZE" \
'. + [{"platform":$plat,"architecture":$arch,"filename":$file,"sha256":$sha,"size":$size}]' \
"release-${{ matrix.suffix }}.artifacts.json" > tmp.json \
&& mv tmp.json "release-${{ matrix.suffix }}.artifacts.json"
fi
# The .deb uses dpkg's redflag_<version>_<arch> naming, so the
# redflag-<bin>-<suffix> loop above does not see it either.
for DEB in redflag_*_amd64.deb; do
[ -f "$DEB" ] || continue
SHA=$(sha256sum "$DEB" | awk '{print $1}')
SIZE=$(stat -c%s "$DEB")
jq --arg plat "package-${{ matrix.goos }}" --arg arch "${{ matrix.goarch }}" \
--arg file "$DEB" --arg sha "$SHA" \
--argjson size "$SIZE" \
'. + [{"platform":$plat,"architecture":$arch,"filename":$file,"sha256":$sha,"size":$size}]' \
"release-${{ matrix.suffix }}.artifacts.json" > tmp.json \
&& mv tmp.json "release-${{ matrix.suffix }}.artifacts.json"
done
echo "Artifact snippet:"
cat "release-${{ matrix.suffix }}.artifacts.json"
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: release-${{ matrix.suffix }}
# The checksum file and the manifest snippet do not start with
# redflag-, so a single redflag-* glob leaves both behind in the
# matrix job and publish finds nothing to merge or attach.
path: |
dist/redflag-*-${{ matrix.suffix }}*
dist/redflag_*_amd64.deb
dist/RedFlagSetup-${{ matrix.suffix }}.msi
dist/installer-proof-${{ matrix.suffix }}.json
dist/checksums-*-${{ matrix.suffix }}.txt
dist/release-${{ matrix.suffix }}.artifacts.json
retention-days: 1
# Generic CI assembles one deterministic candidate and deposits it on the
# package shelf. This token can write packages; it cannot publish a release.
stage_package:
runs-on: ubuntu-latest
container:
image: debian:13-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132
needs: [release]
outputs:
receipt_sha256: ${{ steps.assemble.outputs.receipt_sha256 }}
steps:
- name: Install distro runtime
run: apt-get update -qq && apt-get install -y --no-install-recommends ca-certificates curl git jq nodejs python3
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
with:
path: artifacts
- name: Assemble deterministic release candidate
id: assemble
run: |
set -euo pipefail
command -v jq >/dev/null || (apt-get update -qq && apt-get install -y -qq jq)
VERSION=${GITHUB_REF#refs/tags/v}
scripts/assemble-release-candidate.sh \
artifacts candidate "$VERSION" "$GITHUB_SHA" "v$VERSION"
RECEIPT=$(sha256sum candidate/files.sha256 | awk '{print $1}')
echo "receipt_sha256=$RECEIPT" >> "$GITHUB_OUTPUT"
- name: Deposit candidate on package shelf
env:
PACKAGE_WRITE_TOKEN: ${{ secrets.PACKAGE_WRITE_TOKEN }}
run: |
VERSION=${GITHUB_REF#refs/tags/v}
scripts/stage-release-candidate.sh candidate artifacts "$VERSION"
# Trusted execution fetches and verifies the exact package candidate. It
# rebuilds nothing and publishes only Casey's internal Gitea alpha release.
# Public promotion is a separate manual workflow after hands-on testing.
publish_internal:
runs-on: release-trusted
needs: [stage_package]
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
- name: Reverify release tag
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
TAG="v$VERSION"
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
test -r "$TOKEN_FILE"
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
GIT_TOKEN_FILE="$TOKEN_FILE" \
GIT_TOKEN_USERNAME=release-redflag \
GIT_TERMINAL_PROMPT=0 \
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
git config gpg.format ssh
git config gpg.ssh.allowedSignersFile .gitea/allowed_signers
test "$(git cat-file -t "refs/tags/$TAG")" = tag
test "$(git rev-parse "${TAG}^{commit}")" = "$GITHUB_SHA"
git tag -v "$TAG"
# The trusted lane checks ancestry independently of ordinary CI.
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
GIT_TOKEN_FILE="$TOKEN_FILE" \
GIT_TOKEN_USERNAME=release-redflag \
GIT_TERMINAL_PROMPT=0 \
git fetch --no-tags origin refs/heads/public:refs/remotes/origin/public
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/public
- name: Fetch and verify package candidate
env:
PACKAGE_RECEIPT_SHA256: ${{ needs.stage_package.outputs.receipt_sha256 }}
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
scripts/fetch-release-candidate.sh \
artifacts/package artifacts "$VERSION" \
"$PACKAGE_RECEIPT_SHA256" \
/srv/release-trusted/.secrets/package-read-token
python3 scripts/release-contract.py verify artifacts/package "$VERSION" "$GITHUB_SHA" "v$VERSION"
- name: Create Gitea release
run: |
set -euo pipefail
VERSION=${GITHUB_REF#refs/tags/v}
TAG="v$VERSION"
API="${GITHUB_SERVER_URL}/api/v1"
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
test -r "$TOKEN_FILE"
GITEA_RELEASE_TOKEN=$(<"$TOKEN_FILE")
test -n "$GITEA_RELEASE_TOKEN"
gitea_curl() {
curl --config <(printf 'silent\nshow-error\nheader = "Authorization: token %s"\n' "$GITEA_RELEASE_TOKEN") "$@"
}
# Alpha until v0.3.0. Anything sorting below the stable floor publishes
# as a prerelease; this auto-flips to a stable release at v0.3.0 with no
# manual toggle to forget.
STABLE_FLOOR="0.3.0"
if [ "$(printf '%s\n%s\n' "$VERSION" "$STABLE_FLOOR" | sort -V | head -1)" = "$VERSION" ] \
&& [ "$VERSION" != "$STABLE_FLOOR" ]; then
PRERELEASE=true
else
PRERELEASE=false
fi
echo "Release $VERSION prerelease=$PRERELEASE (stable floor v$STABLE_FLOOR)"
release_status=$(gitea_curl -o /tmp/gitea-release.json -w '%{http_code}' \
"$API/repos/${GITHUB_REPOSITORY}/releases/tags/$TAG")
case "$release_status" in
200)
;;
404)
payload=$(jq -nc \
--arg tag "$TAG" \
--arg name "$TAG" \
--argjson prerelease "$PRERELEASE" \
'{tag_name:$tag,name:$name,draft:false,prerelease:$prerelease}')
gitea_curl -f -X POST "$API/repos/${GITHUB_REPOSITORY}/releases" \
-H 'Content-Type: application/json' \
--data "$payload" >/tmp/gitea-release.json
;;
*)
echo "::error::Gitea release lookup failed with HTTP $release_status"
exit 1
;;
esac
RELEASE_ID=$(jq -er .id /tmp/gitea-release.json)
gitea_curl -f \
"$API/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?limit=100" \
>/tmp/gitea-assets.json
mapfile -d '' release_files < <(
find artifacts/package -maxdepth 1 -type f -print0
)
test "${#release_files[@]}" -gt 0
for file in "${release_files[@]}"; do
name=$(basename "$file")
download_url=$(jq -r --arg name "$name" \
'[.[] | select(.name == $name)] | if length == 1 then .[0].browser_download_url else "" end' \
/tmp/gitea-assets.json)
if [ -n "$download_url" ]; then
existing=$(mktemp)
gitea_curl -f -L -o "$existing" "$download_url"
cmp -s "$file" "$existing" || {
echo "::error::existing Gitea release asset differs: $name"
exit 1
}
rm -f "$existing"
echo "Reused byte-identical Gitea asset: $name"
else
echo "Uploading $name"
gitea_curl -f -X POST \
"$API/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?name=$name" \
-F "attachment=@$file" >/dev/null
fi
done
echo "Internal Gitea alpha release v$VERSION published byte-exact"

489
.gitignore vendored Normal file
View file

@ -0,0 +1,489 @@
# RedFlag .gitignore
# Comprehensive ignore file for Go, Node.js, and development files
# =============================================================================
# Go / Go Modules
# =============================================================================
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# All documentation goes in docs/ folder (private development)
docs/
TEST-CLONE.md
!LICENSE
!NOTICE
!.env.example
# Test binary, built with `go test -c`
*.test
# Output of go coverage tool, specifically when used with LiteIDE
*.out
# Go workspace file
go.work
# Dependency directories (remove comment if using vendoring)
vendor/
# Go build cache
.cache/
# Go mod download cache (can be large)
*.modcache
# =============================================================================
# Node.js / npm / yarn / pnpm
# =============================================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Bootstrap template (but not the actual .env files)
!config/.env.example
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# Config folder - keep only the example
config/.env
config/old.env
config/*.env.old
# Test files
test-agent
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
web/dist-desktop/
# embedded web UI build target — keep the dir, never its contents
!server/internal/webui/dist/
server/internal/webui/dist/*
!server/internal/webui/dist/.gitkeep
desktop/target/
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# =============================================================================
# IDE / Editor Files
# =============================================================================
# VSCode
.vscode/
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
# JetBrains / IntelliJ IDEA
.idea/
*.iml
*.ipr
*.iws
# Vim
*.swp
*.swo
*~
# Emacs
*~
\#*\#
/.emacs.desktop
/.emacs.desktop.lock
*.elc
auto-save-list
tramp
.\#*
# Sublime Text
*.sublime-project
*.sublime-workspace
# Kate
.session
# Gedit
*~
# =============================================================================
# OS Generated Files
# =============================================================================
# macOS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Windows
*.cab
*.msi
# The hollow run-3181 package is a regression input, not build output.
!installer/windows/testdata/*.msi
*.msix
*.msm
*.msp
*.lnk
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
# =============================================================================
# Application Specific
# =============================================================================
# RedFlag specific files
*.db
*.sqlite
*.sqlite3
# Compiled binaries (project-specific)
redflag-agent
!installer/linux/sudoers/redflag-agent
redflag-server
redflag-agent.exe
agent/redflag-agent
agent/redflag-agent.exe
agent/agent
agent/agent-test
agent/test-agent-*
agent/test-redflag-agent
server/redflag-server
server/server
# Agent configuration (may contain sensitive data)
agent/config.json
agent/.agent-id
agent/.token
# Server runtime files
server/logs/
server/data/
server/uploads/
# Local cache files
agent/cache/
agent/*.cache
/var/lib/redflag/
/var/cache/redflag/
/var/log/redflag/
# Test files and coverage
coverage.txt
coverage.html
*.cover
*.prof
test-results/
# Local development files
*.local
*.dev
.devenv/
dev/
# Development packages and scripts
server/scripts/
# Build artifacts
*.tar.gz
*.zip
*.rpm
*.deb
*.snap
# Documentation build
docs/_build/
docs/build/
# =============================================================================
# Docker / Container Related
# =============================================================================
# Docker volumes (avoid committing data)
volumes/
data/
# Docker build context
.dockerignore
# =============================================================================
# Security / Credentials
# =============================================================================
# Private keys and certificates
*.key
*.pem
*.crt
*.p12
*.pfx
id_rsa
id_rsa.pub
id_ed25519
id_ed25519.pub
# Passwords and secrets
secrets/
*.secret
*.password
*.token
.auth
# Cloud provider credentials
.aws/
.azure/
.gcp/
.kube/
# =============================================================================
# Miscellaneous
# =============================================================================
# Large files
*.iso
*.dmg
*.img
*.bin
*.dat
# Backup files
*.bak
*.backup
*.old
*.orig
*.save
# Temporary files
*.tmp
*.temp
*.swp
*.swo
# Lock files (keep some, ignore others)
*.lock
# Rust application crates: commit the lockfile — reproducible builds and the
# cargo-audit dependency gate (CI + docker self-attestation) both need it.
!helper/Cargo.lock
!desktop/Cargo.lock
# Keep package-lock.json and yarn.lock for dependency management
# yarn.lock
# package-lock.json
# Archive files
*.7z
*.rar
*.tar
*.tgz
*.gz
# Profiling and performance data
*.prof
*.pprof
*.cpu
*.mem
# Local database files
*.db-shm
*.db-wal
# =============================================================================
# AI / LLM Development Files
# =============================================================================
# Claude AI settings and cache
.claude/
*claude*
CLAUDE.md
# =============================================================================
# Essential files to INCLUDE for GitHub alpha release
# =============================================================================
# Include essential documentation files
!README.md
!CHANGELOG.md
!OPERATIONS.md
!THIRD_PARTY_LICENSES.md
!LICENSE
!.env.example
!docker-compose.yml
!Makefile
# Screenshots (needed for README)
!Screenshots/
!Screenshots/*.png
!Screenshots/*.jpg
!Screenshots/*.jpeg
# Core functionality (needed for working system)
!agent/internal/installer/
!agent/internal/scanner/dnf.go
!server/internal/api/handlers/
!server/internal/services/
!server/internal/database/migrations/
# Only minimal README, no other documentation
# =============================================================================
# AI / LLM Development Files
# =============================================================================
.claude/
# =============================================================================
# Development and deployment environments
# =============================================================================
website/
deployment/
# =============================================================================
# Discord Bot (private, contains credentials)
# =============================================================================
discord/
# Handoff files — session context, never committed
HANDOFF-*.md
# =============================================================================
# Development and investigation files (should not be in repo)
# =============================================================================
db_investigation.sh
fix_agent_permissions.sh
install.sh
docker-compose.dev.yml
.migration_temp/
# =============================================================================
# Root-level stray npm artifact (npm should be run from web/)
/package-lock.json
# Kate editor swap files
# =============================================================================
*.swp
*.kate-swp
.MIGRATION_STRATEGY.md.kate-swp
# =============================================================================
# Discord bot development (private, contains credentials)
# =============================================================================
discord/
discord/.env.example
# Local bin folder (build artifacts)
server/bin/
agent/bin/
# Local workspace files
*.code-workspace
# Stale WIP files (Dec 2025)
agent/internal/retry/
web/src/contexts/
server/internal/database/migrations/025_platform_scanner_subsystems.down.sql
vanguards-memories/

2
.gitleaksignore Normal file
View file

@ -0,0 +1,2 @@
# Reviewed findings present in reachable public history.
# Keep these fingerprint-specific: a new finding anywhere still fails CI.

15
.govulncheck-allow Normal file
View file

@ -0,0 +1,15 @@
# RedFlag dependency vulnerability exceptions (govulncheck)
#
# Each line is a KNOWN, ACCEPTED *reachable* finding with a documented reason.
# This file is the machine-readable register; the same exceptions are PUBLISHED
# in SECURITY.md ("Accepted dependency exceptions"). This is honest disclosure,
# not a silent bypass: any vuln govulncheck reports as reachable and NOT listed
# here fails CI. Keep this list as short as the world allows.
#
# Format: <GO-id> <reason>
#
# Review trigger: when a listed module gets a fixed version, bump the dep and
# DELETE the line. The gate warns on stale entries that no longer fire.
GO-2026-4887 Moby AuthZ plugin bypass via oversized request bodies. Daemon-side. RedFlag links github.com/docker/docker only as a CLIENT (Ping / SecretList / container scan) and never runs the engine's AuthZ path. No fixed version published (Fixed: N/A).
GO-2026-4883 Moby off-by-one in plugin privilege validation. Daemon-side. Same client-only rationale as GO-2026-4887. No fixed version published (Fixed: N/A).

13
.publication/EPOCH Normal file
View file

@ -0,0 +1,13 @@
# Break glass.
#
# Ordinary publication is fast-forward only. This file authorises the
# publication job to replace the public branch exactly once, and only when the
# public ref it finds is the one named below. After the epoch lands, the named
# SHA is no longer what any remote holds, so this authorisation is spent and
# force push goes dark again without anyone remembering to close it.
#
# Development history is never rewritten. It is preserved internally at
# archive/public-pre-epoch-2026-09-04.
replaces 22170bbed48a5256dce4d297d8a295bff54a1198
reason sanitized projection epoch; public history begins at the cut

View file

@ -0,0 +1,5 @@
{
"schema_version": 2,
"repository": "Fimeg/RedFlag",
"exceptions": []
}

View file

@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Block public commit prose that is not fit to leave the private forge."""
import argparse
import json
import re
import subprocess
import sys
from datetime import date
MAX_SUBJECT = 59
MAX_PROSE = 180
MAX_SENTENCES = 2
TRAILER = re.compile(r"^(?:Source-Sha|Policy-Sha|Tree-Digest):\s+\S+$")
PRIVATE = re.compile(
r"\b(?:10|127)(?:\.[0-9]{1,3}){2,3}\b"
r"|\b192\.168(?:\.[0-9]{1,3}){1,2}\b"
r"|\b172\.(?:1[6-9]|2[0-9]|3[01])(?:\.[0-9]{1,3}){1,2}\b"
r"|\bwiuf[-a-z0-9_]*\b|\barchdev\b|/home/[a-z0-9_-]+|/root/",
re.I,
)
ATTRIBUTION = re.compile(
r"co-authored-by:.*(?:claude|openai|chatgpt|copilot|letta|cursor)"
r"|generated by|generated with|ai-assisted|auto-generated by",
re.I,
)
PROMPT = re.compile(
r"\bcasey\b|\b(?:the\s+)?user\s+(?:said|asked|wanted|reported|told)\b"
r"|(?:\*|_)[\"“].+?[\"”](?:\*|_)|^\s*>\s+",
re.I | re.M | re.S,
)
RULES = {
"subject-empty", "subject-length", "subject-case", "prose-length",
"prose-sentences", "private-infrastructure", "model-attribution",
"prompt-prose",
}
def public_prose(body):
"""Drop transport trailers; they are proof, not Field Notes prose."""
lines = body.strip().splitlines()
while lines and (not lines[-1].strip() or TRAILER.match(lines[-1].strip())):
lines.pop()
return "\n".join(lines).strip()
def violations(subject, body):
prose = public_prose(body)
message = f"{subject}\n{prose}"
found = []
if not subject:
found.append(("subject-empty", "subject is empty"))
if len(subject) > MAX_SUBJECT:
found.append(("subject-length", f"subject is {len(subject)} characters; maximum is {MAX_SUBJECT}"))
first_alpha = next((char for char in subject if char.isalpha()), "")
if first_alpha and not first_alpha.islower():
found.append(("subject-case", "subject must start lowercase"))
if len(prose) > MAX_PROSE:
found.append(("prose-length", f"public prose is {len(prose)} characters; maximum is {MAX_PROSE}"))
sentences = len(re.findall(r"[.!?](?=\s|$)", prose))
if sentences > MAX_SENTENCES:
found.append(("prose-sentences", f"public prose has {sentences} sentences; maximum is {MAX_SENTENCES}"))
if PRIVATE.search(message):
found.append(("private-infrastructure", "message contains private infrastructure"))
if ATTRIBUTION.search(message):
found.append(("model-attribution", "message contains model attribution"))
if PROMPT.search(message):
found.append(("prompt-prose", "message contains prompt or private conversational prose"))
return found
def load_allowlist(path, repository):
with open(path) as handle:
raw = json.load(handle)
if raw.get("schema_version") != 2:
raise ValueError("allowlist schema_version must be 2")
if raw.get("repository") != repository:
raise ValueError("allowlist repository does not match this publication")
allowed = {}
for item in raw.get("exceptions", []):
item_repository = str(item.get("repository") or "")
sha = str(item.get("sha") or "").lower()
rule = str(item.get("rule") or "")
reason = str(item.get("reason") or "").strip()
reviewer = str(item.get("reviewer") or "").strip()
reviewed = str(item.get("reviewed") or "")
scope = str(item.get("scope") or "").strip()
if item_repository != repository:
raise ValueError("every exception must bind this exact repository")
if not re.fullmatch(r"[0-9a-f]{40}", sha):
raise ValueError("every exception needs one exact 40-character SHA")
if rule not in RULES:
raise ValueError(f"unknown exception rule: {rule}")
if not reason:
raise ValueError(f"{sha[:12]} {rule}: human reason is required")
if not reviewer:
raise ValueError(f"{sha[:12]} {rule}: reviewer identity is required")
if not scope:
raise ValueError(f"{sha[:12]} {rule}: lifetime or epoch scope is required")
try:
date.fromisoformat(reviewed)
except ValueError as error:
raise ValueError(f"{sha[:12]} {rule}: ISO review date is required") from error
allowed[(sha, rule)] = item
return allowed
def records(repo, revision_range):
result = subprocess.run(
["git", "-C", repo, "log", "--format=%H%x1f%s%x1f%b%x1e", revision_range],
capture_output=True, text=True,
)
if result.returncode:
raise RuntimeError(result.stderr.strip() or "git log failed")
for record in result.stdout.split("\x1e"):
fields = record.strip("\r\n").split("\x1f", 2)
if len(fields) == 3:
yield fields
def check(repo, revision_range, allowlist):
errors = []
notes = []
for sha, subject, body in records(repo, revision_range):
for rule, reason in violations(subject.strip(), body.strip()):
exception = allowlist.get((sha.lower(), rule))
if exception:
notes.append(f"{sha[:12]} {rule}: approved: {exception['reason']} (reviewed {exception['reviewed']})")
else:
errors.append(f"{sha[:12]} {rule}: {reason}")
return errors, notes
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--repo", default=".")
parser.add_argument("--range", required=True)
parser.add_argument("--allowlist", required=True)
parser.add_argument("--repository", required=True)
args = parser.parse_args()
try:
errors, notes = check(args.repo, args.range, load_allowlist(args.allowlist, args.repository))
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error:
print(f"[commit-voice] {error}", file=sys.stderr)
return 1
for note in notes:
print(f"[commit-voice] {note}")
for error in errors:
print(f"[commit-voice] {error}", file=sys.stderr)
if errors:
return 1
print("[commit-voice] clean")
return 0
if __name__ == "__main__":
sys.exit(main())

838
.publication/paths.txt Normal file
View file

@ -0,0 +1,838 @@
.gitea/allowed_signers
.gitea/workflows/ci.yml
.gitea/workflows/desktop-windows.yml
.gitea/workflows/msi-custody-proof.yml
.gitea/workflows/nightly.yml
.gitea/workflows/promote-release.yml
.gitea/workflows/release.yml
.gitignore
.gitleaksignore
.govulncheck-allow
.publication/EPOCH
.publication/commit-voice-allowlist.json
.publication/commit_voice.py
.publication/paths.txt
.publication/surface.json
.publication/surface_gate.py
.publication/test_commit_voice.py
.publication/test_surface_gate.py
AUTHOR.md
CHANGELOG.md
LICENSE
Makefile
OPERATIONS.md
PROVENANCE.md
RAF/OVERVIEW.md
RAF/README.md
RAF/components/01-server.md
RAF/components/02-agent.md
RAF/components/03-web.md
RAF/components/04-helper.md
RAF/components/05-desktop.md
RAF/core/01-ethos.md
RAF/core/02-architecture-decisions.md
RAF/flows/01-registration.md
RAF/flows/02-command-execution.md
RAF/flows/04-heartbeat.md
RAF/flows/05-capability-advertisement.md
RAF/flows/06-update-lifecycle.md
RAF/flows/07-process-scan.md
RAF/flows/08-wazuh-event-emitter.md
RAF/reference/01-file-mappings.md
RAF/reference/02-glossary.md
RAF/scanners/01-windows-updates.md
RAF/scanners/02-docker-scanner.md
RAF/scanners/03-apt-scanner.md
RAF/scanners/04-dnf-scanner.md
RAF/scanners/05-process-scanner.md
RAF/scanners/06-pacman-scanner.md
RAF/security/01-trust-boundaries.md
RAF/security/02-authentication-stack.md
RAF/security/03-refresh-tokens.md
RAF/security/04-machine-binding.md
RAF/security/05-supply-chain-gate.md
RAF/security/06-standalone-authority.md
RAF/testing/01-test-pyramid.md
RAF/verification/01-signing-pipeline.md
RAF/verification/02-agent-verification.md
RAF/verification/03-key-rotation.md
RAF/verification/04-replay-protection.md
README.md
SECURITY.md
Screenshots/7Zip-Updates-RedFlag-Dependency.png
Screenshots/RedFlag Agent List.png
Screenshots/RedFlag Default Dashboard.png
Screenshots/RedFlag Docker Dashboard.png
Screenshots/RedFlag Heartbeat System.png
Screenshots/RedFlag History Dashboard.png
Screenshots/RedFlag Linux Agent Details.png
Screenshots/RedFlag Live Operations - Failed Dashboard.png
Screenshots/RedFlag Updates Dashboard.png
Screenshots/RedFlag Windows Agent Details.png
Screenshots/Upstream-Version-Tracking.png
THIRD_PARTY_LICENSES.md
agent/NOTICE
agent/cmd/agent/cli.go
agent/cmd/agent/local_status.go
agent/cmd/agent/main.go
agent/cmd/ethos_emoji_test.go
agent/go.mod
agent/go.sum
agent/install.sh
agent/internal/acknowledgment/tracker.go
agent/internal/agent/backoff_test.go
agent/internal/agent/loop.go
agent/internal/cache/cache.go
agent/internal/cache/hash_cache.go
agent/internal/cache/local.go
agent/internal/cache/local_test.go
agent/internal/capability/keyid.go
agent/internal/capability/mutation_manifest.go
agent/internal/capability/mutation_manifest_test.go
agent/internal/capability/token.go
agent/internal/capability/token_test.go
agent/internal/circuitbreaker/circuitbreaker.go
agent/internal/circuitbreaker/circuitbreaker_test.go
agent/internal/client/client.go
agent/internal/client/inventory.go
agent/internal/client/machine_id_logging_test.go
agent/internal/common/agentfile.go
agent/internal/config/config.go
agent/internal/config/config_roundtrip_test.go
agent/internal/config/docker.go
agent/internal/config/kernel_enforcement.go
agent/internal/config/mode_test.go
agent/internal/config/subsystems.go
agent/internal/constants/paths.go
agent/internal/crypto/pubkey.go
agent/internal/crypto/pubkey_test.go
agent/internal/crypto/replay_test.go
agent/internal/crypto/verification.go
agent/internal/crypto/verification_test.go
agent/internal/desktop/manager.go
agent/internal/desktop/session_linux.go
agent/internal/desktop/session_other.go
agent/internal/desktop/session_windows.go
agent/internal/display/ethos_exempt_test.go
agent/internal/display/terminal.go
agent/internal/event/buffer.go
agent/internal/event/helpers.go
agent/internal/event/history_test.go
agent/internal/event/tee_logger.go
agent/internal/event/tee_logger_test.go
agent/internal/guardian/interval_guardian.go
agent/internal/handlers/agent_restart_other.go
agent/internal/handlers/agent_restart_windows.go
agent/internal/handlers/agent_update.go
agent/internal/handlers/commands.go
agent/internal/handlers/dispatch.go
agent/internal/handlers/dry_run.go
agent/internal/handlers/heartbeat.go
agent/internal/handlers/install.go
agent/internal/handlers/local_approve.go
agent/internal/handlers/local_approve_test.go
agent/internal/handlers/local_state.go
agent/internal/handlers/local_trigger.go
agent/internal/handlers/processes.go
agent/internal/handlers/reboot.go
agent/internal/handlers/scan.go
agent/internal/handlers/screenshot.go
agent/internal/handlers/upgrade_attestation.go
agent/internal/handlers/upgrade_attestation_test.go
agent/internal/handlers/upgrade_healthcheck.go
agent/internal/installer/apt.go
agent/internal/installer/artifact_hash.go
agent/internal/installer/artifact_hash_test.go
agent/internal/installer/discovery.go
agent/internal/installer/discovery_test.go
agent/internal/installer/dnf.go
agent/internal/installer/docker.go
agent/internal/installer/installer.go
agent/internal/installer/pacman_resolver.go
agent/internal/installer/pacman_resolver_linux.go
agent/internal/installer/pacman_resolver_linux_test.go
agent/internal/installer/pacman_resolver_other.go
agent/internal/installer/types.go
agent/internal/installer/windows.go
agent/internal/installer/winget.go
agent/internal/instancelock/lock.go
agent/internal/instancelock/lock_unix.go
agent/internal/instancelock/lock_windows.go
agent/internal/integrations/integrations.go
agent/internal/integrations/sunshine.go
agent/internal/kernel/ebpf_consumer.go
agent/internal/kernel/kernel.go
agent/internal/kernel/windows_wdac.go
agent/internal/localapi/approve_test.go
agent/internal/localapi/client.go
agent/internal/localapi/client_unix.go
agent/internal/localapi/client_unix_test.go
agent/internal/localapi/client_windows.go
agent/internal/localapi/listener_unix.go
agent/internal/localapi/listener_windows.go
agent/internal/localapi/server.go
agent/internal/localapi/server_test.go
agent/internal/localapi/trigger_test.go
agent/internal/logging/example_integration_test.go
agent/internal/logging/process_logger.go
agent/internal/logging/process_logger_test.go
agent/internal/logging/security_logger.go
agent/internal/logging/security_logger_test.go
agent/internal/migration/detection.go
agent/internal/migration/docker.go
agent/internal/migration/docker_executor.go
agent/internal/migration/ethos_emoji_test.go
agent/internal/migration/executor.go
agent/internal/migration/executor_config_v5_test.go
agent/internal/migration/pathutils/manager.go
agent/internal/migration/state.go
agent/internal/migration/validation/validator.go
agent/internal/models/storage_metrics.go
agent/internal/models/system_event.go
agent/internal/orchestrator/command_handler.go
agent/internal/orchestrator/docker_scanner.go
agent/internal/orchestrator/orchestrator.go
agent/internal/orchestrator/storage_scanner.go
agent/internal/orchestrator/system_scanner.go
agent/internal/polling_jitter_test.go
agent/internal/receipt/tracker.go
agent/internal/reconnect_stagger_test.go
agent/internal/recovery/panic.go
agent/internal/registration/service.go
agent/internal/scanner/apt.go
agent/internal/scanner/detect.go
agent/internal/scanner/dnf.go
agent/internal/scanner/dnf_test.go
agent/internal/scanner/pacman.go
agent/internal/scanner/pacman_test.go
agent/internal/scanner/windows.go
agent/internal/scanner/windows_ghost_test.go
agent/internal/scanner/windows_override.go
agent/internal/scanner/windows_service_parity_test.go
agent/internal/scanner/windows_wua.go
agent/internal/scanner/winget.go
agent/internal/scanner/winget_logging_test.go
agent/internal/scanner/winget_parser_test.go
agent/internal/scanner/winget_path_test.go
agent/internal/service/service_stub.go
agent/internal/service/windows.go
agent/internal/startup/event.go
agent/internal/supplychain/binary_update.go
agent/internal/supplychain/consumer.go
agent/internal/supplychain/consumer_test.go
agent/internal/supplychain/mint.go
agent/internal/supplychain/mutation.go
agent/internal/supplychain/osv.go
agent/internal/supplychain/osv_test.go
agent/internal/system/connections.go
agent/internal/system/connections_linux.go
agent/internal/system/connections_other.go
agent/internal/system/device.go
agent/internal/system/device_test.go
agent/internal/system/info.go
agent/internal/system/machine_id.go
agent/internal/system/machine_id_arm_test.go
agent/internal/system/machine_id_fallback_test.go
agent/internal/system/machine_id_format_test.go
agent/internal/system/machine_id_winpath_test.go
agent/internal/system/monitor.go
agent/internal/system/monitor_linux.go
agent/internal/system/monitor_other.go
agent/internal/system/monitor_test.go
agent/internal/system/process.go
agent/internal/system/process_capabilities.go
agent/internal/system/process_capabilities_test.go
agent/internal/system/process_darwin.go
agent/internal/system/process_detail.go
agent/internal/system/process_detail_linux.go
agent/internal/system/process_detail_other.go
agent/internal/system/process_linux.go
agent/internal/system/process_owner.go
agent/internal/system/process_owner_test.go
agent/internal/system/process_test.go
agent/internal/system/process_windows.go
agent/internal/system/services.go
agent/internal/system/services_linux.go
agent/internal/system/services_other.go
agent/internal/system/software.go
agent/internal/system/software_linux.go
agent/internal/system/software_other.go
agent/internal/system/windows.go
agent/internal/system/windows_cpu_parse.go
agent/internal/system/windows_cpu_parse_test.go
agent/internal/system/windows_stub.go
agent/internal/validator/interval_validator.go
agent/internal/version/version.go
agent/pkg/windowsupdate/enum.go
agent/pkg/windowsupdate/iautomaticupdates.go
agent/pkg/windowsupdate/iautomaticupdatessettings.go
agent/pkg/windowsupdate/icategory.go
agent/pkg/windowsupdate/idownloadjob.go
agent/pkg/windowsupdate/idownloadprogress.go
agent/pkg/windowsupdate/idownloadresult.go
agent/pkg/windowsupdate/iimageinformation.go
agent/pkg/windowsupdate/iinstallationbehavior.go
agent/pkg/windowsupdate/iinstallationjob.go
agent/pkg/windowsupdate/iinstallationprogress.go
agent/pkg/windowsupdate/iinstallationresult.go
agent/pkg/windowsupdate/isearchjob.go
agent/pkg/windowsupdate/isearchresult.go
agent/pkg/windowsupdate/istringcollection.go
agent/pkg/windowsupdate/isysteminformation.go
agent/pkg/windowsupdate/iupdate.go
agent/pkg/windowsupdate/iupdatecollection.go
agent/pkg/windowsupdate/iupdatedownloadcontent.go
agent/pkg/windowsupdate/iupdatedownloader.go
agent/pkg/windowsupdate/iupdatedownloadresult.go
agent/pkg/windowsupdate/iupdateexception.go
agent/pkg/windowsupdate/iupdatehistoryentry.go
agent/pkg/windowsupdate/iupdateidentity.go
agent/pkg/windowsupdate/iupdateinstallationresult.go
agent/pkg/windowsupdate/iupdateinstaller.go
agent/pkg/windowsupdate/iupdatesearcher.go
agent/pkg/windowsupdate/iupdateservice.go
agent/pkg/windowsupdate/iupdateservicemanager.go
agent/pkg/windowsupdate/iupdateserviceregistration.go
agent/pkg/windowsupdate/iupdatesession.go
agent/pkg/windowsupdate/iwebproxy.go
agent/pkg/windowsupdate/iwindowsdriverupdate.go
agent/pkg/windowsupdate/iwindowsupdateagentinfo.go
agent/pkg/windowsupdate/oleconv.go
agent/test-config/config.yaml
agent/uninstall.sh
config/.env.example
desktop/.qmlls.ini
desktop/Cargo.lock
desktop/Cargo.toml
desktop/README.md
desktop/build.rs
desktop/icons/icon.png
desktop/qml/AppButton.qml
desktop/qml/HistoryPanel.qml
desktop/qml/LineGraph.qml
desktop/qml/Main.qml
desktop/qml/MetricCard.qml
desktop/qml/NavItem.qml
desktop/qml/PageHeader.qml
desktop/qml/ScanPanel.qml
desktop/qml/Theme.qml
desktop/src/bridge/local_api.rs
desktop/src/bridge/machine.rs
desktop/src/bridge/mod.rs
desktop/src/main.rs
docker-compose.yml
helper/.cargo/config.toml
helper/.gitignore
helper/Cargo.lock
helper/Cargo.toml
helper/build.rs
helper/src/main.rs
helper/src/mutation_protocol.rs
installer/linux/build-deb.sh
installer/linux/debian/control.in
installer/linux/debian/postinst
installer/linux/debian/postrm
installer/linux/debian/prerm
installer/linux/desktop/redflag-desktop.desktop
installer/linux/inspect-deb.sh
installer/linux/polkit/50-redflag-agent.rules
installer/linux/sudoers/redflag-agent
installer/linux/systemd/redflag-agent.service
installer/windows/Product.wxs
installer/windows/build-desktop.ps1
installer/windows/build-msi.sh
installer/windows/config/redflag.env.example
installer/windows/testdata/run3181-hollow.msi
installer/windows/verify-msi.sh
protocol/README.md
protocol/testdata/mutation-golden.json
scripts/assemble-release-candidate.sh
scripts/bump-version.sh
scripts/check-public-history.sh
scripts/dep-scan.sh
scripts/fetch-desktop-windows.sh
scripts/fetch-release-candidate.sh
scripts/git-askpass-token.sh
scripts/govulncheck-gate.py
scripts/provision-standalone-authority.sh
scripts/public-history-allowlist.txt
scripts/release-contract.py
scripts/release.sh
scripts/stage-release-candidate.sh
scripts/test-release-contract.py
scripts/update-action-pins.sh
scripts/vendor/release-contract-core.py
server/.env.example
server/Dockerfile
server/cmd/server/main.go
server/cmd/server/webui_test.go
server/cmd/server/wire.go
server/docker-entrypoint.sh
server/go.mod
server/go.sum
server/internal/api/handlers/agent_events.go
server/internal/api/handlers/agent_security_events.go
server/internal/api/handlers/agent_setup.go
server/internal/api/handlers/agent_tracked_software.go
server/internal/api/handlers/agent_unregister_test.go
server/internal/api/handlers/agent_updates.go
server/internal/api/handlers/agents.go
server/internal/api/handlers/auth.go
server/internal/api/handlers/auth_middleware_leak_test.go
server/internal/api/handlers/auth_verify_test.go
server/internal/api/handlers/build_orchestrator.go
server/internal/api/handlers/client_errors.go
server/internal/api/handlers/command_delivery_race_test.go
server/internal/api/handlers/docker.go
server/internal/api/handlers/docker_reports.go
server/internal/api/handlers/downloads.go
server/internal/api/handlers/downloads_auth_test.go
server/internal/api/handlers/downloads_checksum_test.go
server/internal/api/handlers/downloads_install_test.go
server/internal/api/handlers/downloads_security_test.go
server/internal/api/handlers/ethos_emoji_test.go
server/internal/api/handlers/ethos_logging_test.go
server/internal/api/handlers/ethos_setup_exempt_test.go
server/internal/api/handlers/events.go
server/internal/api/handlers/fleet_join.go
server/internal/api/handlers/global_events.go
server/internal/api/handlers/inventory.go
server/internal/api/handlers/maintenance_window_handler.go
server/internal/api/handlers/metrics.go
server/internal/api/handlers/osv_version_test.go
server/internal/api/handlers/processes.go
server/internal/api/handlers/rapid_mode_ratelimit_test.go
server/internal/api/handlers/rate_limits.go
server/internal/api/handlers/reconcile_integration_test.go
server/internal/api/handlers/reconcile_test.go
server/internal/api/handlers/registration_tokens.go
server/internal/api/handlers/registration_transaction_test.go
server/internal/api/handlers/retry_signing_test.go
server/internal/api/handlers/scanner_config.go
server/internal/api/handlers/security.go
server/internal/api/handlers/security_settings.go
server/internal/api/handlers/server_url.go
server/internal/api/handlers/settings.go
server/internal/api/handlers/setup.go
server/internal/api/handlers/setup_keys_test.go
server/internal/api/handlers/stats.go
server/internal/api/handlers/stats_n1_test.go
server/internal/api/handlers/storage_metrics.go
server/internal/api/handlers/subsystems.go
server/internal/api/handlers/system.go
server/internal/api/handlers/token_renewal_transaction_test.go
server/internal/api/handlers/updates.go
server/internal/api/handlers/upstream.go
server/internal/api/middleware/audit.go
server/internal/api/middleware/auth.go
server/internal/api/middleware/auth_secret_leak_test.go
server/internal/api/middleware/cors.go
server/internal/api/middleware/db_pool_shed.go
server/internal/api/middleware/db_pool_shed_test.go
server/internal/api/middleware/ethos_emoji_test.go
server/internal/api/middleware/machine_binding.go
server/internal/api/middleware/machine_id_recovery_test.go
server/internal/api/middleware/metrics_auth.go
server/internal/api/middleware/metrics_auth_test.go
server/internal/api/middleware/rate_limiter.go
server/internal/api/middleware/rate_limiter_grace_test.go
server/internal/api/middleware/require_admin.go
server/internal/api/middleware/require_admin_behavior_test.go
server/internal/api/middleware/require_admin_test.go
server/internal/api/middleware/scheduler_auth_test.go
server/internal/api/middleware/token_confusion_test.go
server/internal/capability/keyid.go
server/internal/capability/mutation_manifest.go
server/internal/capability/mutation_manifest_test.go
server/internal/capability/token.go
server/internal/capability/token_test.go
server/internal/circuitbreaker/circuitbreaker.go
server/internal/circuitbreaker/circuitbreaker_test.go
server/internal/command/factory.go
server/internal/command/validator.go
server/internal/common/agentfile.go
server/internal/config/config.go
server/internal/config/config_test.go
server/internal/crypto/aesgcm.go
server/internal/database/db.go
server/internal/database/db_test.go
server/internal/database/migration_runner_test.go
server/internal/database/migrations/001_initial_schema.down.sql
server/internal/database/migrations/001_initial_schema.up.sql
server/internal/database/migrations/003_create_update_tables.up.sql
server/internal/database/migrations/004_fix_update_logs_foreign_key.up.sql
server/internal/database/migrations/005_add_pending_dependencies_status.up.sql
server/internal/database/migrations/006_add_missing_command_statuses.up.sql
server/internal/database/migrations/007_expand_status_column_length.up.sql
server/internal/database/migrations/008_create_refresh_tokens_table.up.sql
server/internal/database/migrations/009_add_agent_version_tracking.up.sql
server/internal/database/migrations/009b_add_retry_tracking.up.sql
server/internal/database/migrations/010_add_archived_failed_status.up.sql
server/internal/database/migrations/011_create_registration_tokens_table.up.sql
server/internal/database/migrations/012_add_token_seats.up.sql
server/internal/database/migrations/012b_create_admin_user.up.sql
server/internal/database/migrations/013_add_reboot_tracking.up.sql
server/internal/database/migrations/014_add_command_source.up.sql
server/internal/database/migrations/015_agent_subsystems.down.sql
server/internal/database/migrations/015_agent_subsystems.up.sql
server/internal/database/migrations/016_agent_update_packages.down.sql
server/internal/database/migrations/016_agent_update_packages.up.sql
server/internal/database/migrations/017_add_machine_id.down.sql
server/internal/database/migrations/017_add_machine_id.up.sql
server/internal/database/migrations/018_create_metrics_and_docker_tables.down.sql
server/internal/database/migrations/018_create_metrics_and_docker_tables.up.sql
server/internal/database/migrations/019_create_system_events_table.up.sql
server/internal/database/migrations/020_add_command_signatures.down.sql
server/internal/database/migrations/020_add_command_signatures.up.sql
server/internal/database/migrations/021_create_storage_metrics.up.sql
server/internal/database/migrations/022_add_subsystem_to_logs.down.sql
server/internal/database/migrations/022_add_subsystem_to_logs.up.sql
server/internal/database/migrations/023_client_error_logging.down.sql
server/internal/database/migrations/023_client_error_logging.up.sql
server/internal/database/migrations/023a_command_deduplication.down.sql
server/internal/database/migrations/023a_command_deduplication.up.sql
server/internal/database/migrations/024_disable_updates_subsystem.down.sql
server/internal/database/migrations/024_disable_updates_subsystem.up.sql
server/internal/database/migrations/025_add_key_id_signed_at.down.sql
server/internal/database/migrations/025_add_key_id_signed_at.up.sql
server/internal/database/migrations/025b_platform_scanner_subsystems.down.sql
server/internal/database/migrations/025b_platform_scanner_subsystems.up.sql
server/internal/database/migrations/026_add_expires_at.down.sql
server/internal/database/migrations/026_add_expires_at.up.sql
server/internal/database/migrations/027_create_scanner_config_table.down.sql
server/internal/database/migrations/027_create_scanner_config_table.up.sql
server/internal/database/migrations/028_add_stuck_commands_index.down.sql
server/internal/database/migrations/028_add_stuck_commands_index.up.sql
server/internal/database/migrations/029_add_command_retry_count.down.sql
server/internal/database/migrations/029_add_command_retry_count.up.sql
server/internal/database/migrations/030_add_operational_settings.down.sql
server/internal/database/migrations/030_add_operational_settings.up.sql
server/internal/database/migrations/031_create_maintenance_windows_table.down.sql
server/internal/database/migrations/031_create_maintenance_windows_table.up.sql
server/internal/database/migrations/032_add_commands_updated_at.up.sql
server/internal/database/migrations/033_add_received_command_state.down.sql
server/internal/database/migrations/033_add_received_command_state.up.sql
server/internal/database/migrations/034_add_security_settings_created_at.down.sql
server/internal/database/migrations/034_add_security_settings_created_at.up.sql
server/internal/database/migrations/035_create_upstream_sync.down.sql
server/internal/database/migrations/035_create_upstream_sync.up.sql
server/internal/database/migrations/036_create_token_seats_and_convert_timestamps.down.sql
server/internal/database/migrations/036_create_token_seats_and_convert_timestamps.up.sql
server/internal/database/migrations/037_dedupe_agent_update_packages.down.sql
server/internal/database/migrations/037_dedupe_agent_update_packages.up.sql
server/internal/database/migrations/038_add_policy_settings.down.sql
server/internal/database/migrations/038_add_policy_settings.up.sql
server/internal/database/migrations/039_create_agent_tracked_software.down.sql
server/internal/database/migrations/039_create_agent_tracked_software.up.sql
server/internal/database/migrations/040_add_expected_sha256.down.sql
server/internal/database/migrations/040_add_expected_sha256.up.sql
server/internal/database/migrations/041_update_version_history_status_constraint.down.sql
server/internal/database/migrations/041_update_version_history_status_constraint.up.sql
server/internal/database/migrations/042_create_capability_tokens.down.sql
server/internal/database/migrations/042_create_capability_tokens.up.sql
server/internal/database/migrations/043_create_package_versions.down.sql
server/internal/database/migrations/043_create_package_versions.up.sql
server/internal/database/migrations/044_add_polling_operational_settings.down.sql
server/internal/database/migrations/044_add_polling_operational_settings.up.sql
server/internal/database/migrations/045_add_refresh_token_rotation.down.sql
server/internal/database/migrations/045_add_refresh_token_rotation.up.sql
server/internal/database/migrations/046_hash_registration_tokens.down.sql
server/internal/database/migrations/046_hash_registration_tokens.up.sql
server/internal/database/migrations/047_rename_updated_to_installed.down.sql
server/internal/database/migrations/047_rename_updated_to_installed.up.sql
server/internal/database/migrations/048_add_update_log_result_values.down.sql
server/internal/database/migrations/048_add_update_log_result_values.up.sql
server/internal/database/migrations/049_add_registration_token_encrypted.down.sql
server/internal/database/migrations/049_add_registration_token_encrypted.up.sql
server/internal/database/migrations/050_version_soak_gate.down.sql
server/internal/database/migrations/050_version_soak_gate.up.sql
server/internal/database/migrations/051_tracked_software_discovery.down.sql
server/internal/database/migrations/051_tracked_software_discovery.up.sql
server/internal/database/migrations/052_docker_enrichment.down.sql
server/internal/database/migrations/052_docker_enrichment.up.sql
server/internal/database/migrations/053_retire_soak_override_scaffolding.down.sql
server/internal/database/migrations/053_retire_soak_override_scaffolding.up.sql
server/internal/database/migrations/054_add_observability_metrics_settings.down.sql
server/internal/database/migrations/054_add_observability_metrics_settings.up.sql
server/internal/database/migrations/055_create_process_tables.down.sql
server/internal/database/migrations/055_create_process_tables.up.sql
server/internal/database/migrations/056_create_agent_inventory.down.sql
server/internal/database/migrations/056_create_agent_inventory.up.sql
server/internal/database/migrations/057_add_totp_to_registration_tokens.down.sql
server/internal/database/migrations/057_add_totp_to_registration_tokens.up.sql
server/internal/database/migrations/058_totp_seed_encrypted.down.sql
server/internal/database/migrations/058_totp_seed_encrypted.up.sql
server/internal/database/migrations/059_tracked_software_prereleases.down.sql
server/internal/database/migrations/059_tracked_software_prereleases.up.sql
server/internal/database/migrations/060_drop_token_seats.down.sql
server/internal/database/migrations/060_drop_token_seats.up.sql
server/internal/database/migrations/061_agent_device_type.down.sql
server/internal/database/migrations/061_agent_device_type.up.sql
server/internal/database/migrations/062_widen_device_type_enum.down.sql
server/internal/database/migrations/062_widen_device_type_enum.up.sql
server/internal/database/migrations/idempotency_test.go
server/internal/database/migrations/index_audit_test.go
server/internal/database/migrations/migration018_test.go
server/internal/database/migrations/migration024_test.go
server/internal/database/queries/admin.go
server/internal/database/queries/agent_tracked_software.go
server/internal/database/queries/agent_updates.go
server/internal/database/queries/agents.go
server/internal/database/queries/capability_tokens.go
server/internal/database/queries/commands.go
server/internal/database/queries/commands_ttl_test.go
server/internal/database/queries/docker.go
server/internal/database/queries/ethos_logging_test.go
server/internal/database/queries/filter.go
server/internal/database/queries/inventory.go
server/internal/database/queries/maintenance_window.go
server/internal/database/queries/metrics.go
server/internal/database/queries/packages.go
server/internal/database/queries/processes.go
server/internal/database/queries/reconciliation.go
server/internal/database/queries/refresh_tokens.go
server/internal/database/queries/registration_tokens.go
server/internal/database/queries/registration_tokens_no_cascade_test.go
server/internal/database/queries/repology_aliases.go
server/internal/database/queries/retention.go
server/internal/database/queries/scanner_config.go
server/internal/database/queries/security_settings.go
server/internal/database/queries/signing_keys.go
server/internal/database/queries/storage_metrics.go
server/internal/database/queries/subsystems.go
server/internal/database/queries/updates.go
server/internal/database/queries/upstream.go
server/internal/database/refresh_token_cleanup_test.go
server/internal/database/stuck_command_retry_test.go
server/internal/httpx/httpx.go
server/internal/integrations/wazuh/emitter.go
server/internal/integrations/wazuh/emitter_test.go
server/internal/logging/example_integration.go
server/internal/logging/sanitize.go
server/internal/logging/security_logger.go
server/internal/models/agent.go
server/internal/models/agent_update.go
server/internal/models/command.go
server/internal/models/docker.go
server/internal/models/inventory.go
server/internal/models/maintenance_window_model.go
server/internal/models/metrics.go
server/internal/models/process.go
server/internal/models/reconcile_test.go
server/internal/models/security_event.go
server/internal/models/security_settings.go
server/internal/models/signing_key.go
server/internal/models/storage_metrics.go
server/internal/models/subsystem.go
server/internal/models/supply_chain_gate.go
server/internal/models/system_event.go
server/internal/models/tracked_software.go
server/internal/models/update.go
server/internal/models/update_state.go
server/internal/models/user.go
server/internal/observability/metrics.go
server/internal/observability/metrics_test.go
server/internal/orchestrator/interfaces.go
server/internal/orchestrator/orchestrator.go
server/internal/orchestrator/orchestrator_test.go
server/internal/orchestrator/policy.go
server/internal/orchestrator/timeouts.go
server/internal/orchestrator/workflow.go
server/internal/routeaudit/auditor.go
server/internal/routeaudit/auditor_test.go
server/internal/scheduler/queue.go
server/internal/scheduler/queue_test.go
server/internal/scheduler/scheduler.go
server/internal/scheduler/scheduler_test.go
server/internal/security/totp.go
server/internal/security/totp_test.go
server/internal/services/agent_builder.go
server/internal/services/build_orchestrator.go
server/internal/services/build_types.go
server/internal/services/capability_minter.go
server/internal/services/config_builder.go
server/internal/services/docker_secrets.go
server/internal/services/ethos_logging_test.go
server/internal/services/event_renderer.go
server/internal/services/install_template_integrity_test.go
server/internal/services/install_template_service.go
server/internal/services/notifier/notifier.go
server/internal/services/notifier/notifier_test.go
server/internal/services/notifier/ntfy.go
server/internal/services/notifier/smtp.go
server/internal/services/package_age.go
server/internal/services/package_age_test.go
server/internal/services/posture-build.json
server/internal/services/posture.go
server/internal/services/reconciler.go
server/internal/services/release_manifest.go
server/internal/services/retention.go
server/internal/services/secrets_manager.go
server/internal/services/security_settings_service.go
server/internal/services/security_settings_service_test.go
server/internal/services/signing.go
server/internal/services/signing_replay_test.go
server/internal/services/soak_gate.go
server/internal/services/supply_chain.go
server/internal/services/supply_chain_gate_config_test.go
server/internal/services/supply_chain_vuln_test.go
server/internal/services/system_event_logger.go
server/internal/services/templates/install/scripts/linux.sh.tmpl
server/internal/services/templates/install/scripts/windows.ps1.tmpl
server/internal/services/timeout.go
server/internal/services/timeout_config_test.go
server/internal/services/timezone.go
server/internal/services/update_nonce.go
server/internal/services/upstream/bitbucket_tags.go
server/internal/services/upstream/endoflife.go
server/internal/services/upstream/forgejo_releases.go
server/internal/services/upstream/forgejo_releases_test.go
server/internal/services/upstream/git_tags.go
server/internal/services/upstream/github_releases.go
server/internal/services/upstream/gitlab_releases.go
server/internal/services/upstream/repology.go
server/internal/services/upstream/repology_cache.go
server/internal/services/upstream/source.go
server/internal/services/upstream/syncer.go
server/internal/services/upstream/version.go
server/internal/services/upstream/version_test.go
server/internal/taskrunner/taskrunner.go
server/internal/taskrunner/taskrunner_test.go
server/internal/utils/version.go
server/internal/version/version.go
server/internal/version/versions.go
server/internal/version/versions_test.go
server/internal/webui/dist/.gitkeep
server/internal/webui/webui.go
web/.env.example
web/Dockerfile
web/index.html
web/nginx.conf
web/package-lock.json
web/package.json
web/postcss.config.js
web/public/favicon.svg
web/src/App.tsx
web/src/components/AdvisoryBanner.tsx
web/src/components/AgentHealth.tsx
web/src/components/AgentIntegrations.tsx
web/src/components/AgentSoftwareBindings.tsx
web/src/components/AgentStorage.tsx
web/src/components/AgentUpdate.tsx
web/src/components/AgentUpdatesEnhanced.tsx
web/src/components/AgentUpdatesModal.tsx
web/src/components/AttentionPanel.tsx
web/src/components/ChatTimeline.tsx
web/src/components/DependencyClosureTree.tsx
web/src/components/DeviceTypeIcon.tsx
web/src/components/ErrorBoundary.tsx
web/src/components/HistoryTimeline.tsx
web/src/components/Layout.tsx
web/src/components/ProcessDetailModal.tsx
web/src/components/ProcessesTab.tsx
web/src/components/RelayList.tsx
web/src/components/ServerStatusOverlay.tsx
web/src/components/ServerUpdateBanner.tsx
web/src/components/SetupCompletionChecker.tsx
web/src/components/StackDriftPanel.tsx
web/src/components/VulnerabilityList.tsx
web/src/components/WelcomeChecker.tsx
web/src/components/primitives/CommandCard.tsx
web/src/components/primitives/CommandStatusBadge.tsx
web/src/components/primitives/ConfirmDialog.test.tsx
web/src/components/primitives/ConfirmDialog.tsx
web/src/components/primitives/FilterBar.test.tsx
web/src/components/primitives/FilterBar.tsx
web/src/components/primitives/FilterCountButton.tsx
web/src/components/primitives/FilterDropdown.tsx
web/src/components/primitives/FilterPill.tsx
web/src/components/primitives/MetricItem.tsx
web/src/components/primitives/Modal.tsx
web/src/components/primitives/PageState.tsx
web/src/components/primitives/Pagination.tsx
web/src/components/primitives/ProcessTable.tsx
web/src/components/primitives/ScreenshotCard.tsx
web/src/components/primitives/SearchInput.tsx
web/src/components/primitives/SortableTable.tsx
web/src/components/primitives/StatCard.tsx
web/src/components/primitives/StateBadge.tsx
web/src/components/primitives/index.ts
web/src/components/primitives/statusColors.ts
web/src/components/security/SecurityCategorySection.tsx
web/src/components/security/SecurityEvents.tsx
web/src/components/security/SecuritySetting.tsx
web/src/components/security/SecurityStatusCard.tsx
web/src/components/security/SigningKeyRoster.tsx
web/src/hooks/useAdvisoryHealth.ts
web/src/hooks/useAgentBindings.ts
web/src/hooks/useAgentEvents.ts
web/src/hooks/useAgentPolling.ts
web/src/hooks/useAgentUpdate.ts
web/src/hooks/useAgents.ts
web/src/hooks/useColumnSort.tsx
web/src/hooks/useCommands.ts
web/src/hooks/useDebounce.ts
web/src/hooks/useDocker.ts
web/src/hooks/useFilterUrl.test.tsx
web/src/hooks/useFilterUrl.ts
web/src/hooks/useGlobalEvents.ts
web/src/hooks/useHeartbeat.ts
web/src/hooks/useMaintenanceWindows.ts
web/src/hooks/useMultimodalFilter.ts
web/src/hooks/useProcessExplorer.ts
web/src/hooks/useProcesses.ts
web/src/hooks/useQueryParser.ts
web/src/hooks/useRateLimits.ts
web/src/hooks/useRegistrationTokens.ts
web/src/hooks/useScanState.ts
web/src/hooks/useSecurity.ts
web/src/hooks/useSecuritySettings.ts
web/src/hooks/useServerStatus.ts
web/src/hooks/useSettings.ts
web/src/hooks/useStats.ts
web/src/hooks/useUpdates.ts
web/src/hooks/useUpstream.ts
web/src/index.css
web/src/lib/api.ts
web/src/lib/client-error-logger.ts
web/src/lib/client-logger.ts
web/src/lib/command-naming.ts
web/src/lib/polling.ts
web/src/lib/queryParser.ts
web/src/lib/store.ts
web/src/lib/utils.ts
web/src/lib/vulnerabilities.ts
web/src/main.tsx
web/src/pages/Agents.test.tsx
web/src/pages/Agents.tsx
web/src/pages/Dashboard.tsx
web/src/pages/Docker.tsx
web/src/pages/History.test.tsx
web/src/pages/History.tsx
web/src/pages/LiveOperations.test.tsx
web/src/pages/LiveOperations.tsx
web/src/pages/Login.tsx
web/src/pages/PackageDetail.tsx
web/src/pages/RateLimiting.tsx
web/src/pages/SecuritySettings.tsx
web/src/pages/Settings.tsx
web/src/pages/Setup.tsx
web/src/pages/Updates.test.tsx
web/src/pages/Updates.tsx
web/src/pages/settings/AgentPolling.tsx
web/src/pages/settings/AgentsEnrollment.tsx
web/src/pages/settings/General.tsx
web/src/pages/settings/MaintenanceWindows.tsx
web/src/pages/settings/ProcessExplorer.tsx
web/src/pages/settings/UpstreamTracking.tsx
web/src/test/setup.ts
web/src/types/index.ts
web/src/types/integrations.ts
web/src/types/process.ts
web/src/types/security.ts
web/src/vite-env.d.ts
web/tailwind.config.js
web/tsconfig.json
web/tsconfig.node.json
web/vite.config.ts
web/vitest.config.ts

99
.publication/surface.json Normal file
View file

@ -0,0 +1,99 @@
{
"schema_version": 2,
"repository": "Fimeg/RedFlag",
"_note": "The exact authority for what may cross into the public projection. Every candidate path must be named in path_manifest; changing either file is a public-surface decision.",
"path_manifest": ".publication/paths.txt",
"exclude": [
".publication/evidence/github-break-glass-2026-09-04.json",
"RAF/components/06-session-broker.md",
"RAF/flows/03-agent-upgrade.md",
"Screenshots/AgentMgmt.png",
"Screenshots/Lore Testing.png",
"Screenshots/Overview.png",
"Screenshots/RedFlag Agent Dashboard.png",
"Screenshots/RedFlag Linux Agent Health Details.png",
"Screenshots/RedFlag Linux Agent History Extended.png",
"Screenshots/RedFlag Linux Agent Update Details.png",
"Screenshots/RedFlag Registration Tokens.jpg",
"Screenshots/RedFlag Settings Page.jpg",
"Screenshots/RedFlag Windows Agent History .png",
"Screenshots/RedFlag Windows Agent History Extended.png",
"Screenshots/RedFlagIntro.jpg",
"Screenshots/Screenshot 2026-05-31 at 10-24-24 RedFlag Dashboard.png",
"Screenshots/Screenshot 2026-05-31 at 12-13-09 RedFlag Dashboard.png",
"Screenshots/Screenshot 2026-05-31 at 20-49-57 RedFlag Dashboard.png",
"scripts/generate-keypair.go"
],
"review_required": [
"Screenshots/7Zip-Updates-RedFlag-Dependency.png",
"Screenshots/RedFlag Default Dashboard.png",
"Screenshots/RedFlag Docker Dashboard.png",
"Screenshots/RedFlag Heartbeat System.png",
"Screenshots/RedFlag History Dashboard.png",
"Screenshots/RedFlag Linux Agent Details.png",
"Screenshots/RedFlag Live Operations - Failed Dashboard.png",
"Screenshots/RedFlag Agent List.png",
"Screenshots/RedFlag Updates Dashboard.png",
"Screenshots/Upstream-Version-Tracking.png",
"Screenshots/RedFlag Windows Agent Details.png",
"RAF/README.md",
"RAF/components/04-helper.md",
"RAF/components/05-desktop.md",
"RAF/core/02-architecture-decisions.md",
"RAF/flows/06-update-lifecycle.md",
"RAF/security/01-trust-boundaries.md"
],
"forbidden": [
"rescue",
".handoff-from-phone"
],
"forbidden_classes": [
"rescue/*",
"handoff/*",
".handoff*",
"transcripts/*",
"sessions/*",
"*.log",
"*.jsonl",
"*.bak",
"*.old",
"*.orig",
"*.rej",
"*-copy-*",
"*__pycache__*",
"*.pyc"
],
"max_blob_bytes": 2097152,
"exceptions": [
{
"path": "web/src/pages/Agents.test.tsx",
"rule": "rfc1918",
"reason": "documentation-range fixture addresses in a unit test, not topology",
"reviewed": "2026-09-04"
},
{
"path": "AUTHOR.md",
"rule": "internal-email",
"reason": "deliberate public contact address for the project author",
"reviewed": "2026-09-04"
},
{
"path": "SECURITY.md",
"rule": "internal-email",
"reason": "deliberate public security-report address",
"reviewed": "2026-09-04"
},
{
"path": ".publication/surface_gate.py",
"rule": "internal-host",
"reason": "the public gate must name the forbidden pattern it enforces",
"reviewed": "2026-09-08"
},
{
"path": "scripts/check-public-history.sh",
"rule": "internal-host",
"reason": "the public history gate must name the forbidden pattern it enforces",
"reviewed": "2026-09-08"
}
]
}

View file

@ -0,0 +1,535 @@
#!/usr/bin/env python3
"""Editorial authority over a public tree.
The existing `.publication` mechanism answers a transport question: was this
SHA produced by the right gates, on the right repository, under a policy that
authorizes projection to this destination. It is a good answer to that
question and this file does not replace it.
This file answers the question nobody was asking:
Does this tree belong outside at all?
Six gates, in the order a reviewer would actually apply them:
1. path-authority every recursive path is named by the exact manifest
2. new-surface what the public diff P1 -> P2 newly admits
3. file-class forbidden classes anywhere in the tree
4. content the whole candidate tree, not the changed lines
5. history subjects, bodies, and author identities
6. presentation what a stranger sees in the root listing
Gate 1 is the admission authority. Gate 4 supplies the disclosure inspection
that an incremental scanner structurally cannot provide. A patch scanner sees
a file the day it lands. It never sees it again. A tree that was clean when
every one of its commits was scanned can still be a tree that should not be
public, because publication is a property of the tree, not of the diffs that
built it.
The severity model is deliberately harsher than the incremental scanner's.
There, a home path or a LAN address is a WARN: one line in one patch, and a
human is reading the patch anyway. Here the same finding is a DENY, because
nobody reads a whole tree, and because the finding means the path is standing
in the public product right now, not that it passed through once.
Absence of known-secret content is not authorization to publish.
Exceptions are narrow and auditable: path, rule, reason, review date. A rule
turned off globally is not an exception, it is a retreat, so this file has no
syntax for one.
"""
import argparse
import fnmatch
import json
import os
import re
import subprocess
import sys
from datetime import date
SCHEMA_VERSION = 2
DENY = "deny"
REVIEW = "review"
NOTE = "note"
SEVERITY_ORDER = {DENY: 0, REVIEW: 1, NOTE: 2}
# Anything a forge will not render as source. Reviewed by eye, not by rule.
BINARY_HINT = re.compile(
r"\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|xz|zst|bz2|tar|so|a|o|dll|dylib"
r"|exe|bin|wav|mp3|mp4|ogg|woff2?|ttf|otf|jar|whl|deb|rpm|img|iso)$",
re.IGNORECASE,
)
# Host suffixes a stranger can actually resolve. A submodule pointed anywhere
# else is both a leak and a repository that does not clone.
PUBLIC_FORGE_HOSTS = (
"forge.caseytunturi.com",
"codeberg.org",
"github.com",
"gitlab.com",
"git.sr.ht",
)
class Rule:
__slots__ = ("rule_id", "description", "pattern", "severity")
def __init__(self, rule_id, description, pattern, severity):
self.rule_id = rule_id
self.description = description
self.pattern = re.compile(pattern, re.IGNORECASE)
self.severity = severity
# Capabilities deny because publishing one hands control to whoever reads it
# and no later cleanup takes it back. Topology denies here — see the module
# docstring — because in a tree it is a standing disclosure, not a transit.
CONTENT_RULES = (
Rule("private-key", "private key material",
r"-----BEGIN (?:RSA|DSA|EC|OPENSSH|PGP) PRIVATE KEY", DENY),
# The value must be quoted. An unquoted run of letters after `authorization:`
# is a type name in every language that has types, and matching it made the
# gate cry wolf over `authorization: MutationAuthorization` on first run.
Rule("bearer-token", "embedded token or bearer credential",
r"(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}"
r"|(?:authorization|private[-_]?token|api[-_]?key|client[-_]?secret)"
r"\s*[:=]\s*['\"][A-Za-z0-9._~+/=-]{16,}['\"]", DENY),
Rule("rfc1918", "private LAN address",
r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
r"|192\.168\.\d{1,3}\.\d{1,3}"
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b", DENY),
# Case-sensitive on purpose. Every rule here is otherwise IGNORECASE, and
# a case-blind `/Users/` matched the phrase "sessions/users/seats" in a
# comment about loginctl. A macOS home is capitalised; a POSIX path segment
# spelled `users` is not a home directory.
Rule("home-path", "developer home directory",
r"/home/[a-z][a-z0-9_-]*|(?-i:/Users/[A-Za-z])|(?-i:C:\\\\Users\\\\)", DENY),
Rule("internal-host", "internal hostname or forge",
r"\bwiuf|\barchdev\b|\bwiufph\b", DENY),
# The internal domain only. An author's own public contact address is not a
# leak — it is on the security page on purpose, and matching it made the
# gate refuse commits for being signed by the person who wrote them.
Rule("internal-email", "internal service identity or internal domain",
r"@wiuf\.net", DENY),
Rule("ai-attribution", "model attribution",
r"co-authored-by:\s*(?:claude|gpt|copilot|codex)"
r"|generated with \[?claude", REVIEW),
)
MESSAGE_RULES = CONTENT_RULES
def run(repo, args, ok=(0,)):
p = subprocess.run(["git", "-C", repo] + args,
capture_output=True, text=True, errors="replace")
if p.returncode not in ok:
raise RuntimeError(f"git {' '.join(args)}: {p.stderr.strip()}")
return p.stdout
class Finding:
__slots__ = ("gate", "severity", "rule", "path", "detail", "excerpt")
def __init__(self, gate, severity, rule, path, detail, excerpt=""):
self.gate = gate
self.severity = severity
self.rule = rule
self.path = path
self.detail = detail
self.excerpt = excerpt
def redact(text, match):
"""Keep the shape, drop the value. A report is itself a publishable object."""
s, e = match.span()
line = text[max(0, s - 40):e + 40].replace("\n", " ").strip()
hit = match.group(0)
keep = 2 if len(hit) > 6 else 1
masked = hit[:keep] + "*" * max(1, len(hit) - keep * 2) + (hit[-keep:] if len(hit) > 6 else "")
return line.replace(hit, masked)[:150]
class Manifest:
"""The exact authority for what may cross."""
def __init__(self, raw, path, repo, sha):
self.path = path
self.schema_version = raw.get("schema_version")
if self.schema_version != SCHEMA_VERSION:
raise ValueError(f"{path}: schema_version must be {SCHEMA_VERSION}")
if "public_roots" in raw or "public_root_files" in raw:
raise ValueError(
f"{path}: broad root authority is not valid in schema {SCHEMA_VERSION}")
self.repository = raw.get("repository", "?")
self.path_manifest = raw.get("path_manifest", "")
validate_policy_path(self.path_manifest, "path_manifest")
manifest_text = run(repo, ["show", f"{sha}:{self.path_manifest}"])
paths = manifest_text.splitlines()
if not paths:
raise ValueError(f"{self.path_manifest}: exact path manifest is empty")
for candidate in paths:
validate_policy_path(candidate, "admitted path")
if paths != sorted(paths):
raise ValueError(f"{self.path_manifest}: paths must be bytewise sorted")
if len(paths) != len(set(paths)):
raise ValueError(f"{self.path_manifest}: duplicate path")
self.allowed_paths = frozenset(paths)
self.exclude = list(raw.get("exclude", []))
self.review_required = list(raw.get("review_required", []))
self.forbidden = list(raw.get("forbidden", []))
self.forbidden_classes = list(raw.get("forbidden_classes", []))
self.max_blob_bytes = int(raw.get("max_blob_bytes", 2 * 1024 * 1024))
self.exceptions = list(raw.get("exceptions", []))
def excluded(self, path):
return next((pat for pat in self.exclude if fnmatch.fnmatch(path, pat)), None)
def needs_review(self, path):
return next((pat for pat in self.review_required if fnmatch.fnmatch(path, pat)), None)
def excepted(self, path, rule_id):
"""An exception names one path and one rule, and says why, and when."""
for exc in self.exceptions:
if exc.get("rule") != rule_id:
continue
if not fnmatch.fnmatch(path, exc.get("path", "")):
continue
if not exc.get("reason") or not exc.get("reviewed"):
continue
return exc
return None
def validate_policy_path(path, label):
if not isinstance(path, str) or not path:
raise ValueError(f"{label}: non-empty string required")
if (path.startswith("/") or "\\" in path or "\0" in path or "\n" in path
or "\r" in path or "\t" in path):
raise ValueError(f"{label}: unsafe path {path!r}")
if path != os.path.normpath(path) or path.startswith("../") or path == "..":
raise ValueError(f"{label}: path must be normalized and repository-relative: {path!r}")
def tree_entries(repo, sha):
"""(mode, type, oid, path) for every entry, recursively."""
out = run(repo, ["ls-tree", "-r", "-l", "-z", sha])
entries = []
for record in out.split("\0"):
if not record:
continue
meta, path = record.split("\t", 1)
parts = meta.split()
mode, otype, oid = parts[0], parts[1], parts[2]
size = parts[3] if len(parts) > 3 else "-"
entries.append((mode, otype, oid, size, path))
return entries
def top_level(repo, sha):
return [l for l in run(repo, ["ls-tree", "--name-only", sha]).splitlines() if l.strip()]
# ---------------------------------------------------------------- gate 1
def gate_path_authority(repo, sha, manifest, findings):
if run(repo, ["rev-parse", "--is-shallow-repository"]).strip() != "false":
findings.append(Finding(
"path-authority", DENY, "shallow-history", "-",
"complete history is required to establish path authority"))
return
entries = tree_entries(repo, sha)
present = {entry[4] for entry in entries}
origins = {path: sha for path in present}
commits = run(repo, ["rev-list", sha]).splitlines()
for commit in commits[1:]:
for _mode, _otype, _oid, _size, path in tree_entries(repo, commit):
origins.setdefault(path, commit)
for path, origin in sorted(origins.items()):
location = "candidate" if origin == sha else f"reachable commit {origin[:12]}"
root = path.split("/", 1)[0]
if root in manifest.forbidden:
findings.append(Finding(
"path-authority", DENY, "forbidden-root", path,
"top-level path is forbidden by the public-surface manifest"))
excluded_by = manifest.excluded(path)
if excluded_by:
findings.append(Finding(
"path-authority", DENY, "excluded-path", path,
f"{location} contains a path excluded by {excluded_by!r}"))
if path not in manifest.allowed_paths:
findings.append(Finding(
"path-authority", DENY, "unlisted-path", path,
f"no exact manifest entry admits this path in {location}"))
review_pattern = manifest.needs_review(path)
if review_pattern and path in present:
findings.append(Finding(
"path-authority", REVIEW, "review-path", path,
f"manifest preserves human review under {review_pattern!r}"))
for path in sorted(manifest.allowed_paths - present):
findings.append(Finding(
"path-authority", DENY, "missing-path", path,
"exact manifest entry is absent from the candidate tree"))
findings.append(Finding(
"path-authority", NOTE, "exact-census", "-",
f"{len(present)} candidate paths checked against "
f"{len(manifest.allowed_paths)} exact manifest entries; "
f"{len(origins)} distinct paths checked across {len(commits)} reachable commits"))
# ---------------------------------------------------------------- gate 2
def gate_new_surface(repo, sha, previous, manifest, findings):
if not previous:
findings.append(Finding(
"new-surface", NOTE, "no-baseline", "-",
"no previous public SHA given; the diff gate did not run"))
return
out = run(repo, ["diff", "--name-status", "--diff-filter=ACR", previous, sha])
added = []
for line in out.splitlines():
parts = line.split("\t")
if len(parts) >= 2:
added.append(parts[-1])
for path in added:
severity = NOTE if path in manifest.allowed_paths else DENY
findings.append(Finding(
"new-surface", severity, "new-path", path,
"new candidate path has exact manifest authority" if severity == NOTE
else "new candidate path has no exact manifest authority"))
if os.path.basename(path).startswith("."):
findings.append(Finding(
"new-surface", REVIEW, "new-dotfile", path,
"new dotfile or dotdirectory entering the public tree"))
# Symlinks and submodules are surface changes disguised as files.
for mode, otype, _oid, _size, path in tree_entries(repo, sha):
if mode == "120000":
target = run(repo, ["show", f"{sha}:{path}"]).strip()
escapes = target.startswith("/") or ".." in target.split("/")
findings.append(Finding(
"new-surface", DENY if escapes else NOTE, "symlink", path,
"symlink target leaves the public tree" if escapes
else "symlink stays inside the public tree"))
if otype == "commit":
findings.append(Finding(
"new-surface", REVIEW, "submodule", path,
"submodule gitlink; its URL must resolve publicly"))
def gate_submodule_urls(repo, sha, findings):
try:
text = run(repo, ["show", f"{sha}:.gitmodules"])
except RuntimeError:
return
name = None
for line in text.splitlines():
line = line.strip()
if line.startswith("[submodule"):
name = line
if line.startswith("url"):
url = line.split("=", 1)[1].strip()
host = re.sub(r"^[a-z+]+://", "", url).split("/")[0].split("@")[-1].split(":")[0]
if not any(host == h or host.endswith("." + h) for h in PUBLIC_FORGE_HOSTS):
findings.append(Finding(
"new-surface", DENY, "submodule-private-url", ".gitmodules",
f"submodule url host is not publicly resolvable ({host}); "
"the public repository cannot clone and the host leaks",
excerpt=name or ""))
# ---------------------------------------------------------------- gate 3
def gate_file_class(repo, sha, manifest, findings):
for _mode, _otype, _oid, size, path in tree_entries(repo, sha):
for pat in manifest.forbidden_classes:
if fnmatch.fnmatch(path, pat) or fnmatch.fnmatch(os.path.basename(path), pat):
findings.append(Finding(
"file-class", DENY, "forbidden-class", path,
f"matches forbidden class {pat!r}"))
break
if size not in ("-", None) and size.isdigit() and int(size) > manifest.max_blob_bytes:
findings.append(Finding(
"file-class", REVIEW, "oversized-blob", path,
f"{int(size):,} bytes exceeds the reviewed maximum "
f"({manifest.max_blob_bytes:,})"))
# ---------------------------------------------------------------- gate 4
def gate_content(repo, sha, manifest, findings, limit_per_rule=40):
counts = {}
for _mode, otype, _oid, _size, path in tree_entries(repo, sha):
if otype != "blob" or BINARY_HINT.search(path):
continue
try:
text = run(repo, ["show", f"{sha}:{path}"])
except RuntimeError:
continue
if "\0" in text[:8000]:
continue
for rule in CONTENT_RULES:
m = rule.pattern.search(text)
if not m:
continue
exc = manifest.excepted(path, rule.rule_id)
if exc:
findings.append(Finding(
"content", NOTE, rule.rule_id, path,
f"excepted: {exc['reason']} (reviewed {exc['reviewed']})"))
continue
counts[rule.rule_id] = counts.get(rule.rule_id, 0) + 1
if counts[rule.rule_id] > limit_per_rule:
continue
findings.append(Finding(
"content", rule.severity, rule.rule_id, path,
rule.description, excerpt=redact(text, m)))
# ---------------------------------------------------------------- gate 5
def gate_history(repo, sha, manifest, findings, limit_per_rule=25):
sep = "\x1e"
out = run(repo, ["log", f"--format=%H{sep}%an <%ae>{sep}%s{sep}%b\x1d", sha])
counts = {}
total = 0
for record in out.split("\x1d"):
record = record.strip("\n")
if not record.strip():
continue
parts = record.split(sep)
if len(parts) < 4:
continue
h, ident, subject, body = parts[0], parts[1], parts[2], parts[3]
total += 1
blob = f"{ident}\n{subject}\n{body}"
for rule in MESSAGE_RULES:
m = rule.pattern.search(blob)
if not m:
continue
counts[rule.rule_id] = counts.get(rule.rule_id, 0) + 1
if counts[rule.rule_id] > limit_per_rule:
continue
findings.append(Finding(
"history", rule.severity, rule.rule_id, h[:12],
f"{rule.description} in commit metadata: {subject[:60]}",
excerpt=redact(blob, m)))
findings.append(Finding("history", NOTE, "reachable", "-",
f"{total} commits reachable from {sha[:12]}"))
# ---------------------------------------------------------------- gate 6
def gate_presentation(repo, sha, manifest, findings):
roots = top_level(repo, sha)
findings.append(Finding(
"presentation", NOTE, "root-listing", "-",
f"{len(roots)} top-level entries; every recursive path is exact-manifest checked"))
# ---------------------------------------------------------------- report
GATES = ("path-authority", "new-surface", "file-class", "content", "history", "presentation")
def inventory(repo, sha):
"""Deterministic object inventory for one immutable candidate."""
return "".join(
f"{mode} {oid} {size} {path}\n"
for mode, _otype, oid, size, path in tree_entries(repo, sha)
)
def report(findings, repo, sha, previous, manifest):
lines = []
w = lines.append
w(f"# Public-surface audit — {manifest.repository}")
w("")
w(f"- candidate SHA: `{sha[:12]}`")
w(f"- previous public SHA: `{previous[:12] if previous else '(none given)'}`")
w(f"- manifest: `{os.path.basename(manifest.path)}`")
w(f"- generated: {date.today().isoformat()}")
w("")
deny = [f for f in findings if f.severity == DENY]
review = [f for f in findings if f.severity == REVIEW]
verdict = "DENY" if deny else ("REVIEW" if review else "PASS")
w(f"**Verdict: {verdict}** — {len(deny)} deny, {len(review)} review, "
f"{len([f for f in findings if f.severity == NOTE])} note.")
w("")
for gate in GATES:
rows = [f for f in findings if f.gate == gate]
if not rows:
continue
w(f"## {gate}")
w("")
rows.sort(key=lambda f: (SEVERITY_ORDER[f.severity], f.rule, f.path))
w("| sev | rule | path | detail |")
w("| --- | --- | --- | --- |")
for f in rows:
detail = f.detail
if f.excerpt:
detail += f" — `{f.excerpt}`"
detail = detail.replace("|", "\\|")
w(f"| {f.severity} | {f.rule} | `{f.path}` | {detail} |")
w("")
return "\n".join(lines)
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--repo", required=True)
ap.add_argument("--sha", required=True)
ap.add_argument("--manifest", required=True)
ap.add_argument("--previous", default="")
ap.add_argument("--out", default="")
ap.add_argument("--inventory-out", default="")
ap.add_argument("--skip-history", action="store_true")
args = ap.parse_args()
sha = run(args.repo, ["rev-parse", args.sha]).strip()
previous = run(args.repo, ["rev-parse", args.previous]).strip() if args.previous else ""
repo_root = run(args.repo, ["rev-parse", "--show-toplevel"]).strip()
manifest_path = os.path.realpath(args.manifest)
manifest_rel = os.path.relpath(manifest_path, repo_root)
validate_policy_path(manifest_rel, "manifest")
with open(manifest_path) as fh:
manifest_text = fh.read()
committed_manifest = run(args.repo, ["show", f"{sha}:{manifest_rel}"])
if manifest_text != committed_manifest:
raise ValueError(
f"{manifest_rel}: working copy does not match candidate {sha[:12]}")
manifest = Manifest(json.loads(manifest_text), manifest_rel, args.repo, sha)
findings = []
gate_path_authority(args.repo, sha, manifest, findings)
gate_new_surface(args.repo, sha, previous, manifest, findings)
gate_submodule_urls(args.repo, sha, findings)
gate_file_class(args.repo, sha, manifest, findings)
gate_content(args.repo, sha, manifest, findings)
if not args.skip_history:
gate_history(args.repo, sha, manifest, findings)
gate_presentation(args.repo, sha, manifest, findings)
text = report(findings, args.repo, sha, previous, manifest)
if args.out:
with open(args.out, "w") as fh:
fh.write(text + "\n")
print(f"wrote {args.out}")
else:
print(text)
if args.inventory_out:
with open(args.inventory_out, "w") as fh:
fh.write(inventory(args.repo, sha))
print(f"wrote {args.inventory_out}")
return 1 if any(f.severity == DENY for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,84 @@
import importlib.util
import json
import os
import subprocess
import tempfile
import unittest
HERE = os.path.dirname(__file__)
SPEC = importlib.util.spec_from_file_location("commit_voice", os.path.join(HERE, "commit_voice.py"))
commit_voice = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(commit_voice)
class CommitVoiceTests(unittest.TestCase):
def test_two_short_public_sentences_pass(self):
self.assertEqual([], commit_voice.violations(
"publication: keep the public edge narrow",
"The exact tested object crosses. Anonymous verification follows.",
))
def test_long_or_three_sentence_prose_blocks(self):
found = dict(commit_voice.violations(
"publication: reject a loose message",
"One sentence. A second sentence. " + "A third sentence is too much. " + "x" * 180,
))
self.assertIn("prose-length", found)
self.assertIn("prose-sentences", found)
def test_projection_trailers_do_not_count_as_prose(self):
body = (
"One public sentence.\n\n"
"Source-Sha: " + "a" * 40 + "\n"
"Policy-Sha: " + "b" * 64 + "\n"
"Tree-Digest: " + "c" * 64
)
self.assertEqual([], commit_voice.violations("publication: carry proof", body))
def test_exception_requires_exact_sha_rule_reason_and_date(self):
sha = "a" * 40
with tempfile.TemporaryDirectory() as work:
path = os.path.join(work, "allowlist.json")
with open(path, "w") as handle:
json.dump({"schema_version": 2, "repository": "Fimeg/RedFlag", "exceptions": [{
"repository": "Fimeg/RedFlag", "sha": sha,
"rule": "prose-length", "reason": "",
"reviewer": "Fimeg", "reviewed": "2026-09-04",
"scope": "test-epoch",
}]}, handle)
with self.assertRaisesRegex(ValueError, "human reason is required"):
commit_voice.load_allowlist(path, "Fimeg/RedFlag")
def test_exception_refuses_another_repository(self):
with tempfile.TemporaryDirectory() as work:
path = os.path.join(work, "allowlist.json")
with open(path, "w") as handle:
json.dump({"schema_version": 2, "repository": "Fimeg/other",
"exceptions": []}, handle)
with self.assertRaisesRegex(ValueError, "repository"):
commit_voice.load_allowlist(path, "Fimeg/RedFlag")
def test_human_exception_approves_only_its_exact_finding(self):
with tempfile.TemporaryDirectory() as repo:
subprocess.run(["git", "init", "-q", repo], check=True)
subprocess.run(["git", "-C", repo, "config", "user.name", "Fimeg"], check=True)
subprocess.run(["git", "-C", repo, "config", "user.email", "test@example.test"], check=True)
proof = os.path.join(repo, "proof")
with open(proof, "w") as handle:
handle.write("proof\n")
subprocess.run(["git", "-C", repo, "add", "proof"], check=True)
subprocess.run(["git", "-C", repo, "commit", "-q", "-m", "voice: test override",
"-m", "x" * 181], check=True)
sha = subprocess.check_output(["git", "-C", repo, "rev-parse", "HEAD"], text=True).strip()
errors, _ = commit_voice.check(repo, "HEAD", {})
self.assertEqual(1, len(errors))
errors, notes = commit_voice.check(repo, "HEAD", {(sha, "prose-length"): {
"reason": "the human reviewed this exact historical message",
"reviewed": "2026-09-04",
}})
self.assertEqual([], errors)
self.assertEqual(1, len(notes))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,169 @@
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
HERE = Path(__file__).resolve().parent
GATE = HERE / "surface_gate.py"
class SurfaceGateTests(unittest.TestCase):
def setUp(self):
self.work = tempfile.TemporaryDirectory()
self.repo = Path(self.work.name)
subprocess.run(["git", "init", "-q", self.repo], check=True)
subprocess.run(["git", "-C", self.repo, "config", "user.name", "Test"], check=True)
subprocess.run(
["git", "-C", self.repo, "config", "user.email", "test@example.test"],
check=True,
)
def tearDown(self):
self.work.cleanup()
def candidate(self, files, admitted=None, exclude=None):
for name, content in files.items():
path = self.repo / name
path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, bytes):
path.write_bytes(content)
else:
path.write_text(content)
policy_dir = self.repo / ".publication"
policy_dir.mkdir(exist_ok=True)
policy_paths = {".publication/paths.txt", ".publication/surface.json"}
admitted = set(files) if admitted is None else set(admitted)
admitted.update(policy_paths)
(policy_dir / "paths.txt").write_text("".join(f"{p}\n" for p in sorted(admitted)))
policy = {
"schema_version": 2,
"repository": "Fimeg/RedFlag",
"path_manifest": ".publication/paths.txt",
"exclude": exclude or [],
"review_required": [],
"forbidden": [],
"forbidden_classes": [],
"max_blob_bytes": 2097152,
"exceptions": [],
}
(policy_dir / "surface.json").write_text(json.dumps(policy, indent=2) + "\n")
subprocess.run(["git", "-C", self.repo, "add", "."], check=True)
subprocess.run(
["git", "-C", self.repo, "commit", "-q", "-m", "test: make candidate"],
check=True,
)
return subprocess.check_output(
["git", "-C", self.repo, "rev-parse", "HEAD"], text=True
).strip()
def run_gate(self, sha, *extra):
return subprocess.run(
[
"python3",
GATE,
"--repo",
self.repo,
"--sha",
sha,
"--manifest",
self.repo / ".publication/surface.json",
"--skip-history",
*extra,
],
capture_output=True,
text=True,
)
def test_explicitly_admitted_file_passes(self):
sha = self.candidate({"product/main.go": "package main\n"})
result = self.run_gate(sha)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertIn("Verdict: PASS", result.stdout)
def test_unlisted_file_under_product_directory_fails(self):
sha = self.candidate(
{"product/main.go": "package main\n", "product/private.txt": "not admitted\n"},
admitted={"product/main.go"},
)
result = self.run_gate(sha)
self.assertEqual(1, result.returncode)
self.assertIn("unlisted-path", result.stdout)
self.assertIn("product/private.txt", result.stdout)
def test_excluded_file_cannot_appear(self):
sha = self.candidate(
{"product/main.go": "package main\n", "private/note.txt": "no\n"},
exclude=["private/*"],
)
result = self.run_gate(sha)
self.assertEqual(1, result.returncode)
self.assertIn("excluded-path", result.stdout)
self.assertIn("private/note.txt", result.stdout)
def test_missing_manifest_entry_fails_closed(self):
sha = self.candidate(
{"product/main.go": "package main\n"},
admitted={"product/main.go", "product/missing.go"},
)
result = self.run_gate(sha)
self.assertEqual(1, result.returncode)
self.assertIn("missing-path", result.stdout)
self.assertIn("product/missing.go", result.stdout)
def test_approved_candidate_inventory_is_deterministic(self):
sha = self.candidate({"a.txt": "a\n", "nested/b.txt": "b\n"})
first = self.repo / "first.manifest"
second = self.repo / "second.manifest"
one = self.run_gate(sha, "--inventory-out", first)
two = self.run_gate(sha, "--inventory-out", second)
self.assertEqual(0, one.returncode, one.stdout + one.stderr)
self.assertEqual(0, two.returncode, two.stdout + two.stderr)
self.assertEqual(first.read_bytes(), second.read_bytes())
paths = [line.split(" ", 3)[3] for line in first.read_text().splitlines()]
self.assertEqual(sorted(paths), paths)
self.assertEqual(
[".publication/paths.txt", ".publication/surface.json", "a.txt", "nested/b.txt"],
paths,
)
def test_unlisted_binary_path_fails_before_content_scan(self):
sha = self.candidate(
{"product/main.go": "package main\n", "product/payload.bin": b"\0\xff\0\xff"},
admitted={"product/main.go"},
)
result = self.run_gate(sha)
self.assertEqual(1, result.returncode)
self.assertIn("unlisted-path", result.stdout)
self.assertIn("product/payload.bin", result.stdout)
def test_deleted_unlisted_file_in_history_still_fails(self):
self.candidate({"product/main.go": "package main\n", "private/note.txt": "no\n"})
(self.repo / "private/note.txt").unlink()
sha = self.candidate({"product/main.go": "package main\n"})
result = self.run_gate(sha)
self.assertEqual(1, result.returncode)
self.assertIn("unlisted-path", result.stdout)
self.assertIn("reachable commit", result.stdout)
def test_deleted_excluded_binary_in_history_still_fails(self):
self.candidate({"product/main.go": "package main\n", "product/old.png": b"\0\xff"})
(self.repo / "product/old.png").unlink()
sha = self.candidate({"product/main.go": "package main\n"}, exclude=["product/old.png"])
result = self.run_gate(sha)
self.assertEqual(1, result.returncode)
self.assertIn("excluded-path", result.stdout)
self.assertIn("reachable commit", result.stdout)
def test_edits_to_admitted_files_preserve_valid_history(self):
self.candidate({"product/main.go": "package main\n"})
sha = self.candidate({"product/main.go": "package main\n// changed\n"})
result = self.run_gate(sha)
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
self.assertIn("2 reachable commits", result.stdout)
if __name__ == "__main__":
unittest.main()

24
AUTHOR.md Normal file
View file

@ -0,0 +1,24 @@
# The Author
I'm **Casey Tunturi** — Fimeg in the community. Systems architect, 25+ years on the frontier: network engineering, security, and resurrecting dead datacenters from the ashes of ransomware strikes. Hamilton, Ontario.
## Why RedFlag is free
RedFlag is my resume piece. It will never be monetized: no pro tier, no cloud edition, no per-agent pricing. The update manager is part of your attack surface. Everyone deserves one that treats it that way, not just organizations with an RMM budget. The code is AGPL-3.0, the architecture is documented in the open, and that's the whole business model: there isn't one.
If you're evaluating whether I can build the thing you need built, this repository is the interview. Read the code, read the architecture docs, run it against your fleet.
## The RAF
The **RedFlag Architecture Framework** is the system's design of record, published alongside the code: how the trust model works, why the capability-token gate is shaped the way it is, the architectural decisions and the pitfalls we think are still out there. It is not a manual for attacking RedFlag — it's the reasoning behind it, so you can judge the security model yourself instead of trusting a README.
## Hire me
I build systems that organizations actually own: cryptographically secured, self-hosted, and auditable — free from the extortion of vendor lock-in. I am available for anyone whose genuine purpose is the betterment of humanity:
- **Full-Time Campaigns** — joining a team to build sovereign AI, high-trust infrastructure, or federated networks.
- **Consulting Expeditions ($175/hr)** — hands-on architecture, network engineering, and system hardening.
- **Incident Response ($250/hr)** — ransomware restoration, AD/DNS rebuilds, and pulling your servers back from the void.
- **Pro bono** — for causes I believe in. The condition: you ask honestly, and I get to choose.
**Contact:** casey@samaritansolutions.net · [LinkedIn](https://www.linkedin.com/in/casey-tunturi) · [GitHub Sponsors](https://github.com/sponsors/Fimeg) · [Discord](https://discord.gg/TReG3mZC4Y)

560
CHANGELOG.md Normal file
View file

@ -0,0 +1,560 @@
# Changelog
All notable changes to RedFlag are documented here.
Format: version, date, then grouped by category (Added, Changed, Removed, Fixed, Security).
> **Status: alpha.** Every release before **v0.3.0** ships as a prerelease. Schema, APIs,
> and config can still change without backward-compatibility shims (there are no live
> field clients yet). **v0.3.0 is the planned first stable release.**
---
## v0.2.9.6 (September 2026)
### Fixed
- Fixed Windows MSI packaging so the installer now embeds the server payload it
advertises instead of referencing an external cabinet.
- Added a custody check that extracts the installer and verifies its embedded
server and configuration bytes exactly match the staged release artifacts.
## v0.2.9.5 (September 2026)
### Fixed
- Release secret scanning is pinned to the admitted projection commit instead
of traversing unrelated private development history. The projection epoch's
three reviewed documentation and test fixtures now retain exact-fingerprint
exceptions after their commit identity changed.
## v0.2.9.4 (September 2026)
### Added
- Native Debian package for the agent, helper, and Desktop, including systemd,
sudoers, polkit, desktop-entry, install, upgrade, and removal policy.
- Local Desktop history and scan views backed by the agent's bounded event
history API.
### Changed
- Release builds now stage one complete receipt-bound candidate on the internal
package shelf. The trusted lane fetches and verifies those exact bytes before
creating an internal alpha; public binary promotion is a separate manual act.
- The internal `public` branch now records an admitted public projection without
exposing it. Source publication requires a manual run pinned to the exact
tested projection SHA.
### Fixed
- Package-managed binaries refuse RedFlag's self-update path, and helper staging
now rejects symlink substitution before privileged replacement.
- Pacman scans request plain output so terminal colour codes cannot enter package
identities.
## v0.2.9.3 (July 2026)
### Added
- Native Windows installer (`RedFlagSetup.msi`) — installs the server binary as a
Windows service with a forward-only upgrade guard. Built with `wixl` (msitools),
not the official WiX Toolset .NET CLI, which does not work when the compiler runs
on Linux. CI (`release.yml`) builds it via `apt-get install msitools && wixl`.
- Native (non-docker) server config loading — the server previously only read OS
environment variables, which only ever worked because docker-compose's `env_file:`
injected them. A native install now reads a flat config file
(`REDFLAG_CONFIG_FILE` or a per-OS default path), additive and inert for existing
docker deployments.
### Changed
- Registration-token expiry ceiling raised from 7 days to 90 days (matching the
existing refresh-token precedent); the "Agents & Enrollment" settings page is
unified into one enroll flow instead of toggled panels, and the server signing-key
section moved out of the per-token detail pane (it wasn't per-token).
- Release manifest correctly catalogues the server binary as `kind: binary` instead
of `kind: docker` — every release was already building and packaging it, it just
wasn't gate-checked or documented as a real install artifact.
### Fixed
- The release build's server compile step (and three `Makefile` targets) passed
`cmd/server/main.go` as a single-file argument, silently excluding `wire.go`
(added 2026-06-11) from the build. Broken since `v0.2.9.1`. Now builds the package
directory (`./cmd/server/`), matching how CI's own build-verification step already
did it correctly.
### Security / Design
- Windows privileged-mutation helper (SEC-030) elevation model decided: a
Scheduled Task run once as SYSTEM, ACL-delegated to the agent's service account,
provisioned at agent-install time — mirrors the Linux `systemd-run` transient-unit
pattern. Design of record in `RAF/components/04-helper.md`. Implementation not
started.
## v0.2.9.1 (June 2026)
### Added
- Arch Linux (pacman) update scanner wired into the agent loop. The scanner
(`checkupdates` from pacman-contrib) was implemented but never registered —
now resolves subsystem config, circuit breaker, and orchestrator registration
matching the apt/dnf pattern. Server-side support (scheduler, subsystem
defaults, repology aliases, capability gate) was already present.
### Known limitations
- pacman updates skip OSV.dev supply-chain checks — Arch Linux is not yet a
supported OSV ecosystem. Tracked as GATE-006 in `supply_chain.go`. Install
safety relies on the capability gate + hash verification until OSV adds Arch.
## v0.2.9.0 (June 2026)
### Added
- Desktop tray ships for Windows — cross-compiled via cargo-xwin, installed by
the Windows installer with per-user autostart (registry Run key).
- Standalone tray actions: `trigger_scan` and `approve_update` Tauri invoke
commands wired to the local API (`/v1/actions/trigger-scan`,
`/v1/actions/approve-update`).
- Unified settings page (`Agents & Enrollment`) replaces separate Token and
Agent Management pages.
### Changed
- Desktop tray no longer child-spawned by the agent service on Linux — relies
solely on XDG autostart, killing the double-launch.
- Self-update staging paths are platform-aware (`constants/paths.go`) instead
of hardcoded `/var/lib/redflag/...`.
- Consumer helper invocation platform-gated: `sudo systemd-run` on Linux,
direct child process elsewhere.
- Desktop server route expanded to `/desktop/:platform/:arch` for multi-OS
binary serving.
- Windows tray reaches the server image by fetching the signed exe from the
newest release (hash-verified against the manifest), not by cross-compiling
Tauri-for-Windows in every from-source build — that drags in the ~9GB MSVC
sysroot. CI builds it once; from-source servers download the verified
artifact. Absent/offline degrades to no tray (optional component).
- `signalDesktopRestart` works on Windows (`taskkill /F /IM`) instead of
no-opping.
### Fixed
- Desktop tray window_open now correctly tracks visibility: set `false` on
close-to-tray, set `true` on left-click tray icon.
### Removed
- Dead `NewEnforcer` (nil-logger wrapper) in kernel package — refactor
droppage from the LoopContext migration.
- Dead `NewMigrationExecutorWithEvents` — the log-only constructor is the
intended one for early-boot migration.
- Orphaned service methods in `service/windows.go` (renewTokenIfNeeded,
reportSystemInfo, reportLogWithAck, getConfigPath).
## v0.2.8.4 (June 2026)
### Security
- Supply chain: RedFlag now gates its own dependencies and ships the verdict signed —
self-attestation posture covering dep-scan, build provenance, and an install guard.
- Crypto: forward-only key-path ceiling; the privileged helper compares artifact hashes
in constant time.
- `/admin` route group latched behind `RequireAdmin` (SEC-026) — inert under the current
single-admin model, live the moment RBAC lands.
### Added
- Desktop tray updates route through the privileged helper like every other install path.
- RedFlag self-tracking: startup seeds a default upstream row for
`codeberg.org/Fimeg/RedFlag`, tracks prereleases during alpha, and surfaces a
dashboard update banner with the operator-run rebuild command.
### Changed
- Web: interactive/brand surface recolored to steel blue, split cleanly from `danger` so
red reads as error again (the red scale is unchanged under `danger`).
- Web: client errors log at the boundary (axios interceptor, ErrorBoundary, global
handlers) instead of through a toast-coupled wrapper.
### Removed
- Premature drift-to-update apply hooks: tracked-software drift remains an
identification surface until the operator install path is scoped.
### Fixed
- Web: 15 dashboard correctness/UX defects from the UI/UX audit — Docker filter cards that
matched nothing, duplicated retry/cancel hooks, WebSocket reconnect leaks, notification
dedup, and non-navigable notifications among them.
- OSV resilience and capability-token serialization hardening.
---
## v0.2.8.2 (June 2026)
### Security
- Agent-facing URLs (install scripts, registration responses, fleet-join) no longer
trust the request Host header. The operator-configured `REDFLAG_PUBLIC_URL` wins;
the Host header is only a logged fallback for unconfigured installs.
- Setup wizard reads the bootstrap database password from its environment instead
of a hardcoded literal (shipped default kept as fallback).
### Fixed
- Malformed agent-supplied metadata could panic handler goroutines (rapid-polling
fields, buffered event metadata, timeout params) — all assertions now guarded.
- Scanner-timeout admin endpoints panicked on every call: `user_id` was asserted
as a UUID but the auth middleware stores a string.
- Route auditor now sees the auth middleware instances (AUDIT-002); retention
sweep wired onto the background runner (RETAIN-001 wiring).
- Offline-agent and refresh-token cleanup tickers moved onto the managed task
runner (shutdown, panic isolation, /health/tasks visibility); upstream syncer
and reconciler are now stopped on shutdown; `Stop()` is idempotent on all three.
- Manual sync-now reports real failures instead of unconditional success; an OSV
vulnerability parse failure is logged and still fails closed.
### Removed
- ~760 lines of never-wired lifecycle/build service scaffolding
(`AgentLifecycleService`, `ConfigService`, `BuildService`, `ArtifactService`,
`AgentBuildHandler`).
## v0.2.8.1 (June 2026)
### Added
- Retention sweep (RETAIN-001): scheduled pruning of aged rows from append-only
history tables. Three operator-tunable horizons (metrics/events/audit), 0 = keep
forever.
- Web: shared FilterBar with URL-synced filter state on the Agents and History
pages; vitest + jsdom test infrastructure.
### Security
- Fleet-join TOTP seed stored as AES-256-GCM ciphertext at rest instead of a
SHA-256 hash (migration 058) — hash-only storage could never verify a time code
without treating the seed as a second cleartext shared secret.
### Fixed
- Process scan commands were created without a Source and hit a database check
constraint every time (thanks QiTechCo).
- 12-finding code-review fan-out across server, agent, and web: URL filter sync
race, NULL handling for absent machine IDs at registration, config upgrade
merging for nested keys, route-audit recorder replacing unsafe reflection.
## v0.2.8.0 (June 2026)
### Added
- Process explorer: on-demand `/proc` scanning with osquery-parity detail (command line,
cwd, environment size, open sockets with inode correlation, capabilities, namespaces),
plus a dedicated settings page with lazy route loading.
- Inventory scanner interface with a Docker inventory path; inventory and security-event
report handlers on the server.
- Self-update capability tokens for the agent, helper, and desktop binaries — binary
self-upgrade now flows through the same Ed25519-signed, hash-pinned token gate as
package installs.
- Setup accepts an operator-supplied signing keypair (with validation) instead of only
generating one — supports bring-your-own-key deployments.
- TeeLogger wired through the agent loop, migration executors, and validator: structured
dual-output logging (local + server event stream) on previously local-only paths.
- CI cross-compilation matrix: linux-arm64, windows-amd64, darwin-arm64. Helper skipped
on Windows (Unix-only APIs), darwin via cargo-zigbuild, aarch64 linker pinned through
`helper/.cargo/config.toml`.
### Fixed
- `rpmEVRAhead` returned true for equal versions when only an explicit epoch-0 prefix
differed (`0:2.0-1` vs `2.0-1`) — packages already at target were perpetually flagged
upgradeable on every DNF check.
- Desktop self-update burned its replay token before install, so a transient failure
(disk full, backup error) permanently blocked further desktop updates. Replay check
now runs before install; the token is recorded as consumed only after success.
- Orchestrator constructor could carry a nil logger on the Windows service scan path;
it now defaults to a log-only TeeLogger.
- `/proc/stat` field index bug and ProcessCaps data-collection limits; process scan
dedup; NaN guards and safe parsing in the process explorer UI.
- Security event insert path corrected; `GetAgentByID` signature mismatch.
---
## v0.2.7.1 (June 2026)
### Added
- CI/CD pipeline via Gitea Actions: `ci.yml` (go vet, go test -race, cargo test + clippy,
web build, AI-attribution check, action-pins check) and `release.yml` (version gate,
binary self-report verification, Docker image verification, Gitea-native release API).
- Release gate enforces tag == versions.go == docker-compose == Cargo.toml, CHANGELOG entry
exists, forward-only tag ordering, and tag ancestry on `public`.
- Guided release script (`scripts/release.sh`): interactive, verifies every assumption
before tagging or pushing.
- Supply-chain consumer and OSV coverage: 10 new tests for `agent/internal/supplychain/`.
### Fixed
- Screenshot capability now granted via `AmbientCapabilities=CAP_SYS_PTRACE` in the
service template — survives binary self-upgrade. File-cap restoration alone was
insufficient for units that predated the template line.
- Helper self-upgrade reconciles the unit drop-in (`10-capabilities.conf`) before
restarting the agent, so fleet units that only ever self-upgrade still get the
capability grant. Never adds `CapabilityBoundingSet` — that strips setuid caps from
sudo inside the unit and kills package discovery.
- Tray socket access chain (`/var/lib/redflag``agent``localapi`) now enforced
on upgrades, not just fresh installs.
- Web UI is built and staged into `server/internal/webui/dist` before the server compile
in the release pipeline — previous tarballs shipped a server with an empty embed.
- CI `typescript-check` job replaced with `web-build` (full `npm run build`), so bundle
failures surface in CI, not at release time.
### Removed
- Dead `version-consistency` CI job (could never trigger on branch-only push events).
- `scripts/polkit/` and `scripts/redflag-screenshot.sh` (abandoned pkexec approach).
- `scripts/build-secure-agent.sh` and its Makefile target (bare `go build`, no version
injection, misleading name).
---
## v0.2.6.6 (June 2026)
### Fixed
- Windows process logging now writes `agent.log` before the console stream, so
service-mode runs still produce file diagnostics when `stderr` is unavailable
or invalid under Windows Service Manager.
- Agent startup logs a `process_logger_initialized` marker after the durable log
sink is configured.
- Windows CPU telemetry fallback now parses PowerShell CIM JSON structurally,
restoring core/thread counts on hosts where `wmic` is unavailable.
---
## v0.2.6.5 (June 2026)
### Fixed
- Windows agent startup now wires the standard logger to
`C:\ProgramData\RedFlag\logs\agent.log`, so service-mode diagnostics are
available without relying on Event Viewer rendering.
---
## v0.2.6.4 (June 2026)
### Fixed
- Windows one-liner self-authenticates again (`irm | iex` path repaired).
- Install script served as CRLF for Windows compatibility.
- Install script bakes reachable host into URL, not server bind address.
- Agent install URL preserves port for non-localhost hosts.
- Windows installer tolerates missing Ed25519 verifier on cold start.
---
## v0.2.6.2 (June 2026)
### Changed
- OSV scanning moved from approval to detection — `enqueueOSVChecks` runs on the
scan-report path, writes results to metadata. Approval reads persisted verdict
instead of re-scanning inline.
- Soak gate (`GATE-005`) promoted to a real policy: `supply_chain.soak_window_days`
and `soak_enforcement` resolve env → config → DB → default. Was env-only.
- Age gate (`package_age.go`) wired to DB config via `GetSupplyChainGateConfig`.
Added opt-in `block_unknown_age` (default false = sovereignty).
### Removed
- Dead soak override scaffolding: dropped `soak_window_hours_override` column,
`version_soak_overrides` table, and `SoakWindowHoursOverride` model field.
Migration 053.
---
## v0.2.6.1 (June 2026)
### Added
- RECONCILE-001: scan-set closure (close-by-absence) reconciler. Packages that
vanish from a successful scan are closed as `not_applicable`, fixing out-of-band
false positives.
---
## v0.2.6.0 (June 2026)
### Added
- FEAT-002: metadata pipeline — agent → server → metadata transport for upstream
intelligence, CVE details, and package provenance.
- GATE-005: version soak-gating configuration.
- BRIDGE-001: auto-discovery bridge (Repology, container registry, exact match).
- Docker enrichment pipeline: container image detail, update history pagination.
- Filter/search primitives — composable UI components and hooks.
### Fixed
- SPA nav hygiene + history crosslinks.
- Code review batch 1 + dead store setting cleanup.
---
## v0.2.5.2 (June 2026)
### Added
- SETTINGS-001: reversible token encryption + one-liner restore.
### Fixed
- Heartbeat auto-queue treats duplicate-pending as benign (ETHOS #4 — idempotency).
---
## v0.2.5.1 (June 2026)
### Added
- Lifecycle history section on update detail page: shows `update_version_history`
entries with status badges, version transitions, failure reasons, timestamps.
- Failed state recovery: `failed` is no longer terminal — transitions to `pending`
(reopen), `installed` (resolve), or `ignored`. `IsTerminal()` means scan-stable,
not transition-locked.
- Reopen/resolve endpoints: `POST /updates/:id/reopen` and `POST /updates/:id/resolve`.
Replaced broken `RetryUpdate` which was structurally blind to capability-token installs.
- Failure metadata on live row: `failure_reason` and `failed_by` stamped on transition
into failed state.
### Changed
- History page renamed to Fleet Activity, uses `GetFleetActivity` endpoint.
### Fixed
- History 500 error on load.
- Dead unified history substrate removed.
---
## v0.2.3.5 (June 2026)
### Added
- Unified agent + helper upgrade: closure carries both binaries, helper installs
agent on verify.
- `update_logs.result` extended with `started`/`partial`/`running` states — fixes
agent-report badge semantics.
### Security
- Path traversal fixes across file-serving endpoints.
- File permissions tightened (result files 0644 so agent can read back from root-owned helper).
- Sudoers and polkit scope narrowed.
- Staging cleanup.
### Fixed
- Self-update and gated installs work on fresh hosts (first-time registration path).
---
## v0.2.3.1 (June 2026)
### Security
- Supply chain gate hardened: a known vulnerability anywhere in the resolved dependency
closure is a full stop at approval time. `ApproveUpdate` returns `409` and mints nothing.
Override requires an explicit operator reason in the request body — waives the vulnerability
judgment only; signing and hash verification stay non-negotiable. Every override journaled
as a `supply_chain_override` security event.
- Auto-confirm shares the `ClosureCleared` predicate with manual approval — the two paths
cannot drift on what counts as a clean closure.
### Fixed
- Ack tracking: acks clear on result-recorded, not command lifecycle status. Eliminates
34-deep recycling loops on long-running command chains.
- Timeouts, cancels, dropped acks/receipts, and failed actions all land in unified history
instead of dying on stdout.
---
## v0.2.3.0 (May 2026)
### Changed
- OSV checks switched to batch endpoint (`/v1/querybatch`, 100 per POST) with global
concurrency cap (4 concurrent batches). 300 packages = 3 HTTP calls instead of 300.
- Auto-confirm frisks the whole dependency closure for CVEs, not just the top-level package.
### Fixed
- DNF dry-run success detection: `--assumeno` exits non-zero even on a clean resolve.
Gate on `Transaction Summary` presence; reject `Nothing to do` / `No match` / `Error:`
explicitly instead of trusting exit code.
- Web logout clears zustand persist key alongside tokens, fixing stale JWT survival across
server reinstalls (JWT_SECRET rotation).
---
## v0.2.2.0 (May 2026)
### Added
- Package state machine enforced across all transitions: typed `PackageStatus`,
`ValidateTransition` guards, guarded UPDATE with `WHERE status = $current`.
Migration 047 aligns all existing rows. (LIFECYCLE-001)
- Lifecycle orchestrator foundation: timer-driven auto-advance, stuck-state recovery for
`checking_dependencies` and `installing`, auto-approval policy support. (LIFECYCLE-003)
- Vulnerability dashboard: per-agent and fleet-wide OSV finding visibility.
---
## v0.2.1.1 (May 2026)
### Added
- Agent self-upgrade via helper: the privileged helper now performs agent binary swaps
(backup, install, chmod, systemctl restart) under an `agent-self` capability token.
The agent stages the verified binary; the helper re-verifies the hash before installing.
No agent sudo for cp/chmod/systemctl.
- OSV.dev supply-chain checks at discovery time: async, deduped per server lifetime.
Results written to `current_package_state.metadata` for immediate UI visibility.
- OSV.dev startup backfill: unchecked packages get queried on server boot (best-effort).
- apt and dnf added to OSV.dev ecosystem mapping (Debian, AlmaLinux).
- Docker handler uses dedicated `DockerQueries` with proper image/container separation.
- Staging page: LiveOperations renamed to Staging, shows packages awaiting dependency
review alongside in-flight operations. Loading and error states added.
- Server-side status filter for the Updates package list (HAVING clause on aggregation).
- Docker severity displayed from actual data instead of hardcoded "medium".
### Changed
- apt discovery runs unprivileged: sandbox opts redirect lists/cache/state/log to an
agent-writable temp dir, matching dnf's model. No apt sudo grants in sudoers.
- Docker commands no longer prefixed with `sudo` (agent uses docker group membership).
- Sudoers template: removed all Docker grants (docker group), all self-update grants
(helper does it), and apt discovery grants (unprivileged). Agent's only sudo is the
single `systemd-run --pipe` helper invocation line.
- Polling loop recalculates interval after processing commands, not before (rapid-polling
takes effect in the same cycle it's enabled).
- `UpdateCurrentStateInTx` exported for single-row writes outside batch transactions.
- Update result values normalized: `updated` -> `success`, `rollback` -> `success`.
- Drift-to-update bridge writes `UpdateEvent` via `UpsertCurrentState` instead of the
removed `UpsertUpdate`/`UpdatePackage` path.
- `AgentDockerImage` model fields aligned with agent's `DockerReportItem` JSON tags.
### Removed
- `UpdatePackage` model and its `UpsertUpdate`/`ListUpdates` query methods (dead code
from the pre-state-table era).
### Security
- Agent holds zero sudo for self-upgrade, Docker, and apt discovery. The helper is the
only privileged path and it verifies capability-token signatures and artifact hashes
before every operation.
---
## v0.2.1.0 (May 2026)
### Added
- Helper privilege split: `redflag-helper` invoked via `sudo systemd-run --pipe` as its
own transient service, escaping the agent's `ProtectSystem=strict` sandbox.
- Token-is-the-command: all package-manager operations routed through discovery
(`DiscoveryRunner`) or mutation (`consumer.go -> systemd-run -> redflag-helper`).
- `EcosystemConfig` registry: adding a new ecosystem means one config entry + discovery
interface. Mutation is automatic via the token path.
- Hash verification fail-closed: empty expected hash returns error.
### Changed
- `Installer` interface shrunk from 7 methods to 4: `IsAvailable`, `GetPackageType`,
`DryRun`, `VerifyHash`.
- `apt-get` replaced with `apt` throughout.
- Sudoers narrowed: agent can only run discovery commands + helper invocation.
### Removed
- `SecureCommandExecutor` (replaced by `DiscoveryRunner` + helper).
- Direct mutation methods from `DNFInstaller` and `APTInstaller`.
---
## v0.2.0.7 (May 2026)
### Security
- Refresh-token rotation with accept-previous-once crash-recovery grace.
- Machine-bound renewal: stolen `config.json` replayed from another host -> 403.
- Typed sentinel errors for auth failures in polling loop.
- Revoked refresh tokens and machine-ID mismatches surface as critical events.
- Signing-disabled path removed from orchestrator; unsigned fallback removed.
---
## v0.2.0.6 (May 2026)
### Added
- Agent cold-start trust root: signed release manifest verified before first execution.
- Supply-chain hash registry (Layer 1): expected SHA-256 stored server-side, verified
by agents before install.
- Drift-to-UpdatePackage bridge: drifted bindings automatically create pending updates.
- Upstream tracking UI with release-source adapters (GitHub, Gitea, GitLab, Bitbucket).
- Attention panel: surfaces offline agents, failed updates, EOL drift, upstream movement.
---
## v0.2.0.0 (May 2026)
### Added
- Maintenance windows for scheduling/gating installs.
- Docker image scanning and update management.
- Multi-agent fleet overview dashboard.

675
LICENSE Normal file
View file

@ -0,0 +1,675 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license
document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
adopt. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 1996-12-20, or similar
laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed in a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no way prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source covered under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Proprohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run the covered work,
to propagate or modify the covered work, subject to this License.
You are not responsible for enforcing compliance by third parties with
this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling the contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of the contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties to whom you convey the covered work, a discriminatory patent
license (a) in connection with copies of the covered work conveyed by
you (or copies made from those copies), or (b) primarily for and in
connection with specific products or collections of products containing
the covered work, unless you entered into that arrangement, or that
patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For
For example, if you have given a patent license in connection with
that covered work to one party, and, in a separate transaction, to
another party who conveys the same covered work to a first party, on
the assumption that the patent license from the first party would
extend to the second party, but that separate transaction is, or is
held to be, invalid, then, as a consequence, a court order or
agreement may require you to compensate the first party for patent
infringement as to which you are liable under the first patent
license, and the second party's conveying of the covered work may or
may not be the infringing use.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a network (if your version supports
such interaction) an opportunity to receive the Corresponding Source of
your version through a network server at no charge, through some standard
or customary means of facilitating copying of software. This
corresponding source must include the Corresponding Source for any work
covered by version 3 of the GNU General Public License that is incorporated
pursed under this License. You must inform recipients that this
Corresponding Source is available, and how to find it. This requirement
is in addition to the requirement in section 4 to keep intact all notices.
If your version of the Program is network server software and
requires interaction with users through a network, you must offer
them an opportunity to receive the Corresponding Source of your
version. You may not impose any further restrictions on the exercise
of the rights granted or affirmed under this License. The requirement
to provide Corresponding Source for your modified version is a
condition of your exercising the right to convey a covered work in
this section. It applies to the non-exhaustive list of "users" that
includes all those who interact with the Program remotely through a network.
14. Revised Versions of this License.
Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new
versions will be similar in spirit to the version, but may differ in
detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero
General Public License "or any later version" applies to it, you have
the option of following the terms and conditions either of that
numbered version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
the GNU Affero General Public License, you may choose any version ever
published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that
proxy's public statement of acceptance of a version permanently
authorizes you to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provisions
cannot be given legal effect according to their terms,
courts applying the law of jurisdictions that allow the exclusion or
limitation of liability for consequential or incidental damages, a
limitation that cannot be given legal effect in the jurisdiction in
which the is sought shall be applied to the maximum extent that court
deems enforceable. The disclaimer of warranty and limitation of
liability provisions shall be interpreted to have the least limited
scope possible so as to give effect to them, and in no event shall the
parties' definitions in sections 15 and 16 be expanded.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
RedFlag Update Management Platform
Copyright (C) 2025-2026 RedFlag Project / Casey Tunturi
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a network,
you should also make sure that it provides a way for users to get its
source. For example, if your program is a web app, its interface
could display a "Source" link that leads users to an archive of the
code. There are many ways you could offer copies of the source code;
the requirement is that it is prominently offered to all users who
interact with the software remotely.
For more information on this, and how to apply and follow the GNU
AGPL, see <https://www.gnu.org/licenses/>.

98
Makefile Normal file
View file

@ -0,0 +1,98 @@
.PHONY: help db-up db-down server agent clean kernel-enforcer test lint version up rebuild rebuild-clean down logs prune-cruft
# docker compose v2 (override with COMPOSE=docker-compose for the v1 plugin)
COMPOSE ?= docker compose
VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "dev")
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
AGENT_LDFLAGS := -X github.com/Fimeg/RedFlag/agent/internal/version.Version=$(VERSION) \
-X github.com/Fimeg/RedFlag/agent/internal/version.ConfigVersion=$(VERSION) \
-X github.com/Fimeg/RedFlag/agent/internal/version.BuildTime=$(BUILD_TIME)
SERVER_LDFLAGS := -X github.com/Fimeg/RedFlag/server/internal/version/versions.AgentVersion=$(VERSION) \
-X github.com/Fimeg/RedFlag/server/internal/version/versions.ConfigVersion=$(VERSION)
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
version: ## Print current version
@echo "VERSION=$(VERSION)"
up: ## Start the stack (NO rebuild — reuses the existing image)
$(COMPOSE) up -d
rebuild: ## Rebuild changed layers and restart (the everyday command)
$(COMPOSE) up -d --build
rebuild-clean: ## Full no-cache rebuild from scratch, then restart
$(COMPOSE) build --no-cache && $(COMPOSE) up -d
down: ## Stop the stack
$(COMPOSE) down
logs: ## Tail the server logs
$(COMPOSE) logs -f server
prune-cruft: ## Remove retired RedFlag images (redflag-web, desktop-stage-test)
-docker image rm redflag-web:latest redflag-desktop-stage-test:latest 2>/dev/null; true
fetch-desktop-windows: ## Fetch+verify the signed Windows Desktop from the latest release into ./dist
@mkdir -p dist
sh scripts/fetch-desktop-windows.sh amd64 ./dist
@ls -lh dist/redflag-desktop.exe 2>/dev/null || echo "no Windows Desktop in the latest release yet"
db-up: ## Start PostgreSQL database
docker-compose up -d postgres
@echo "Waiting for database to be ready..."
@sleep 3
db-down: ## Stop PostgreSQL database
docker-compose down
server: ## Build and run the server
cd server && go mod tidy && go run ./cmd/server/
agent: ## Build and run the agent
cd agent && go mod tidy && go run cmd/agent/main.go
build-server: ## Build server binary
cd server && go mod tidy && go build -ldflags "$(SERVER_LDFLAGS)" -o bin/server ./cmd/server/
build-agent: ## Build agent binary with version injection
cd agent && go mod tidy && go build -ldflags "$(AGENT_LDFLAGS)" -o bin/agent ./cmd/agent/
clean: ## Clean build artifacts
rm -rf server/bin agent/bin
build-all: ## Build all components with version from git tag
@echo "Building all components at version $(VERSION)..."
cd server && go mod tidy && go build -ldflags "$(SERVER_LDFLAGS)" -o redflag-server ./cmd/server/
cd agent && go mod tidy && go build -ldflags "$(AGENT_LDFLAGS)" -o redflag-agent ./cmd/agent/
@echo "Build complete!"
test: ## Run all tests
cd server && go test -race -count=1 ./...
cd agent && go test -race -count=1 ./...
cd helper && cargo test
lint: ## Run linters and vet
cd server && go vet ./...
cd agent && go vet ./...
cd helper && cargo clippy -- -D warnings
kernel-enforcer: ## Build eBPF kernel enforcer
@echo "Building eBPF kernel enforcer..."
@cd agent && clang -O2 -g -target bpf -mllvm -bpf-stack-size=4096 -c pkg-gate/pkg-gate.c -o pkg-gate/pkg-gate.o \
-I/usr/src/kernels/$(shell uname -r)/vmlinux.h \
-I/usr/src/kernels/$(shell uname -r)/tools/lib/bpf \
-I/usr/src/kernels/$(shell uname -r)/tools/bpf/resolve_btfids/libbpf/include \
-I/usr/src/kernels/$(shell uname -r)/tools/bpf/resolve_btfids/libbpf \
-I/usr/src/kernels/$(shell uname -r)
@echo "eBPF enforcer built successfully"
kernel-enforcer-clean: ## Clean eBPF kernel enforcer artifacts
@echo "Cleaning eBPF kernel enforcer..."
@cd agent && rm -f pkg-gate/pkg-gate.o
@echo "Clean complete"

174
OPERATIONS.md Normal file
View file

@ -0,0 +1,174 @@
# RedFlag Operations Runbook
Operator procedures for RedFlag deployments. Scope: hardware rebind, agent credential
renewal/rotation and the instance lock, disaster recovery, signing key rotation.
---
## 1. Hardware Change / Machine Rebind
When an agent is migrated to new hardware (motherboard swap, VM rebuild, disk image clone), the recorded `machine_id` will diverge from what the agent now reports. The agent will be rejected by `MachineBindingMiddleware` and every command will be denied.
### Symptoms
- Agent shows online but every command returns `403 unauthorized` in the server log: `[WARN] [server] [middleware] machine_id_mismatch`.
- Token renewal also fails: `POST /api/v1/agents/renew` returns `403` and the agent logs a terminal `ErrMachineMismatch` (see §2). A rebind that does not also restore a usable refresh token will leave the agent unable to renew.
- `security_events` table has rows of type `MACHINE_ID_MISMATCH` for the affected agent.
### Procedure
1. Verify the agent's host. Do **not** rebind unless you can confirm the agent is on the expected hardware.
2. As an admin, call the rebind endpoint:
```bash
curl -X POST https://<server>/api/admin/agents/<agent-uuid>/rebind-machine-id \
-H "Authorization: Bearer <admin-jwt>" \
-H "Content-Type: application/json" \
-d '{"reason": "hardware replacement 2026-05-24"}'
```
3. The endpoint clears the stored machine_id and on next check-in the new value is bound. Audit row written to `security_events`.
4. Restart the agent if it does not recover within one heartbeat window.
The endpoint is rate-limited (`admin_operations`) and requires `WebAuthMiddleware`. There is no agent-side procedure — rebind is admin-driven.
> Do not clone or copy a *running* agent's `config.json` onto a second host as a shortcut. It carries a machine-bound refresh token; the clone will fail the machine check on its first renewal and, if the original keeps running, can trip refresh-token reuse detection and revoke the whole family (§2). Re-enroll the new host instead.
---
## 2. Agent Credentials: Renewal, Rotation, and the Instance Lock
An agent holds two credentials in `config.json`: a short-lived access token (JWT) and a long-lived refresh token. The access token is presented on every request; the refresh token is used only at `POST /api/v1/agents/renew` to mint a new access token. Renewal is machine-bound and the refresh token rotates on every use.
### What rotation means operationally
- Each successful renewal returns a **new** refresh token and invalidates the old one. The agent persists the successor to `config.json`.
- A one-time grace accepts the immediately previous token once more, so an agent that renews but crashes before persisting can still recover on restart.
- Presenting a refresh token after its successor has already been used is treated as **reuse** (the fingerprint of a stolen, replayed token). RedFlag revokes the **entire token family** and writes a critical `security_event`. This is forward-only — there is no un-revoke. Recovery is re-enrollment of the agent.
- Renewal is also machine-bound: the refresh token is checked against `X-Machine-ID` before rotation. A stolen `config.json` replayed from another host gets `403` + `MACHINE_ID_MISMATCH`, never a token.
### Symptoms of a revoked family
- Agent logs a terminal `ErrRefreshTokenInvalid` (or `ErrUnauthorized`) and the polling loop stops — these are not retried.
- `security_events` shows a refresh-token reuse / family-revocation row.
- Recovery: re-enroll the agent (fresh registration). Do **not** attempt to hand-edit tokens back into `config.json`.
### The instance lock
Two agent processes must never share one `config.json` — they would race each other's renewals and trip reuse detection. The agent takes an exclusive instance lock at startup (`agent/internal/instancelock/`): a Unix `flock` on Linux/macOS, a named kernel mutex `Global\RedFlagAgent_v1` on Windows. A second process starting against the same config fails fast.
- Symptom of a lock conflict: the agent exits immediately at startup logging that another instance holds the lock.
- This is also why imaging/cloning a running agent is unsafe (§1): the clone either fails the lock (same host) or fails the machine check and trips reuse (different host).
---
## 3. Disaster Recovery
### What to back up
| Asset | Where | Cadence |
|-------|-------|---------|
| PostgreSQL database (`redflag`) | `pg_dump` of the database container | nightly |
| `.env` / Docker secrets | filesystem outside docker volumes | on change |
| Ed25519 signing private key | offline secure storage (password manager / hardware key) | once, at setup, **and on every rotation** |
| Server TLS material (if terminating at server) | filesystem | on change |
The signing private key is the most critical asset. Without it the server cannot sign new agent commands and all existing agents will continue accepting only the old key until their next public-key fetch.
### Restore order
1. Provision a host with the same Docker stack version.
2. Restore the `.env` (or recreate secrets) — including `REDFLAG_SIGNING_PRIVATE_KEY`.
3. Start the postgres container with restored data volume (or `pg_restore` into a fresh volume).
4. Start the server. Confirm migrations apply cleanly (server logs `[INFO] migrations applied N`).
5. Start agents in batches. Watch `security_events` for `MACHINE_ID_MISMATCH` (clone hosts will need rebind, §1).
6. Verify command flow end-to-end: queue a `scan_apt` (or platform equivalent) to one agent and confirm `agent_commands.status = 'success'`.
### Refresh tokens after a database restore
A restored database carries the refresh-token state as of the backup. An agent that renewed *after* the backup was taken now holds a token the restored server considers already-rotated. On first renewal this can read as reuse and revoke the family (§2). Expect a wave of agents needing re-enrollment proportional to renewal activity between the backup and the restore — keep the backup cadence tight relative to the renewal interval, and treat post-restore re-enrollment as a normal step, not an incident.
### What cannot be restored without the signing private key
- Outgoing commands the server signs. Restoring the database is not enough — agents reject unsigned commands in strict mode.
- If the key is lost: generate a new keypair, write it to the database (`signing_keys` table — see §4), and accept that there will be a transitional window where commands signed with the new key require agents to fetch the new public key before they will execute.
---
## 4. Signing Key Rotation
Implemented via the `signing_keys` table (migration 020). The table holds versioned keys; agents fetch all currently-active public keys on registration and on demand, so two keys can be valid simultaneously for a controlled transition.
### When to rotate
- Suspected compromise of the private key.
- Operator-mandated cadence (recommend annually).
- Personnel change where the prior key holder no longer needs access.
### Procedure
1. **Generate** a new Ed25519 keypair on a clean host (offline if possible):
```bash
cd server
go run ./cmd/keygen # if not present, generate via openssl ed25519
```
Output: hex-encoded 64-byte private key and 32-byte public key.
2. **Register the new key** as active alongside the existing one. The signing service has `InitializePrimaryKey()` for first registration; for rotation, insert directly into the `signing_keys` table with the next version number and `status = 'active'`:
```sql
INSERT INTO signing_keys (key_id, version, public_key_hex, status, created_at)
VALUES (gen_random_uuid(), (SELECT COALESCE(MAX(version), 0) + 1 FROM signing_keys), '<hex>', 'active', NOW());
```
Both old and new keys are active. Agents fetch both via the public-key endpoint.
3. **Swap server signing key**: update `REDFLAG_SIGNING_PRIVATE_KEY` in `.env` (or the Docker secret) to the new private key. Restart server.
4. **Verify**: queue a command to one agent. Confirm the agent verifies the new signature. Repeat across a representative sample of agents.
5. **Revoke the old key** after a transition window (recommend 7 days, longer if you have agents that may be offline):
```sql
UPDATE signing_keys SET status = 'revoked', revoked_at = NOW() WHERE version = <old_version>;
```
Agents will reject commands signed by revoked keys on their next public-key refresh.
6. **Securely destroy** the old private key copies (password managers, escrow, sealed envelopes).
### Rollback
If a rotation causes broad agent failure, revert step 3 (put the old private key back in `.env`, restart server). The old key remains `active` in `signing_keys` until you mark it `revoked`. The transition window exists precisely to allow this rollback.
---
## 5. Supply Chain Checks — Advisory vs. Gate
There are three postures, and they are not the same thing. Don't conflate them.
**Advisory display (fail-open).** The discovery-time OSV badge (`CheckOSVVulnerabilities`) and the package age gate in `warn` mode are informational. If the upstream service is unreachable, the check logs a `[WARNING]` and returns nil — the dashboard just loses a hint. This is deliberate: an OSV.dev outage should not blind you to a clean dashboard, and a warn-mode age finding is a note, not a wall.
**Approval gate (full stop, audited override).** Approving an update is an enforcement point, not advisory. A known vulnerability — top-level *or* anywhere in the resolved dependency closure — is a hard stop: `ApproveUpdate` returns `409` and mints nothing. For capability-gated ecosystems (dnf/apt), a closure OSV could not check (service unreachable) is also a stop — minting over it would trust unverified artifacts. The only way through is an explicit operator override in the request body:
```
POST /api/v1/updates/:id/approve { "override_supply_chain": true, "override_reason": "<why>" }
```
The reason is required. The override waives the **vulnerability judgment only** — the signed token still binds the real artifact hashes and the executor still verifies signature + hash. There is no skip-verify path; this is not a runtime knob, it is a per-decision human call. Every override writes a `supply_chain_override` `system_event` (component `security`) recording the package, cause, and operator reason. Bulk approve carries **no** blanket override: flagged updates come back in `blocked[]` and must be approved individually, each with its own reason.
The age gate in `block` enforcement is a separate full stop (still env-driven: `REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT`), handled before the vuln gate.
**Auto-confirm (fail-closed).** The automatic confirmation sweep never trusts the void: an unchecked or vulnerable closure is never auto-minted (`models.ClosureCleared`, shared with the approval gate so the two cannot drift). A human is the only path past a flagged closure.
**Coverage:**
- OSV.dev: npm, PyPI, apt (Debian), dnf (AlmaLinux) — coverage varies by ecosystem
- Package age gate: npm (registry.npmjs.org), PyPI (pypi.org)
**Monitoring:** Search server logs for `[SECURITY] [server] [supply_chain]``approval_blocked` (a gate stop), `bulk_approval_blocked`, and `gate_overridden` (an operator trusted the void). Overrides also surface in the events API as `supply_chain_override`. Upstream advisory failures stay at `[WARNING]`; grep `supply_chain` / `package_age` for chronic upstream unavailability.
---
## 6. Rate Limiter — In-Memory, Restart Semantics
The API rate limiter is **in-memory only** (per-key sliding windows in the server
process). A server restart clears all counters. This is a known, accepted
characteristic — there is no Redis/DB backing store.
**Restart penalty (SEC-004).** For the first 60 seconds after boot, every limit
runs at **half its configured budget** (minimum 1 request). This makes a forced
restart strictly worse for an attacker trying to reset their counters, while
legitimate agents — each rate-limited under its own key — reconnect comfortably
within half budget.
**Operational notes:**
- A burst of `429`s in the minute after a deploy/restart is the grace penalty
working, not a misconfiguration. It clears itself at T+60s.
- Limits are operator-tunable at runtime via `/api/v1/admin/rate-limits`; the
grace penalty halves whatever is configured at request time.
- If you see repeated unexplained server restarts combined with high request
volume from one source, treat it as a possible counter-reset attempt and
block at the firewall — the limiter alone cannot fully stop an attacker who
can crash the server.

25
PROVENANCE.md Normal file
View file

@ -0,0 +1,25 @@
# Publication provenance
This branch is a constructed public projection. Its commit trailers identify
the internal source commit and the policy used to admit the projected tree.
Admission means the tree is eligible for disclosure; it neither authorizes nor
implies external publication.
`.publication/paths.txt` is the exact path authority. Every file in a candidate
must be listed, and every listed file must exist. Parent directories grant no
recursive authority. `.publication/surface_gate.py` can also write the exact
mode, blob ID, size, and path inventory for one immutable candidate with
`--inventory-out`.
Path authority covers every reachable commit, including files deleted before
the tip. Removing a path from disclosure therefore requires history that does
not contain it; a deletion commit alone is insufficient. Shallow history fails
closed. Internal development history is preserved separately.
Reproduce the tree digest from a checked-out public commit with:
```sh
LC_ALL=C git ls-tree -r --full-tree HEAD^{tree} | LC_ALL=C sort | sha256sum
```
The result must equal the commit's `Tree-Digest` trailer.

207
RAF/OVERVIEW.md Normal file
View file

@ -0,0 +1,207 @@
# Start Here: RedFlag Architecture Overview
**Version:** v0.2.9.3 (July 2026)
This is the entry point into the RedFlag Architecture Framework. Read this first to
understand the shape of the system, then follow the links into the detailed docs.
---
## What RedFlag Is
A machine knowledge and operations system with a self-hosted fleet surface. Agents
report operating-system, process, socket, service, container, software, update, and
security state. RedFlag connects those facts to human approval, signed authority,
privileged execution, and durable lifecycle history instead of treating inspection and
mutation as separate products.
---
## Operating Surfaces and Authority Tiers
### Tier 1: Machine Observation and Fleet Lifecycle
Agents register with a one-time token and a hardware fingerprint (TOFU). The server
issues Ed25519-signed commands; agents verify signatures, check nonces, reject replays.
Pull-based polling (5 min default, rapid mode available). Subsystem scanning across
apt, dnf, pacman, winget, WUA, and Docker sits beside on-demand process/socket
inspection and local machine telemetry.
Packages move through a server-owned state machine (`pending` through `installed` or
`failed`) with typed transitions and guarded UPDATEs — no free-form string jumps. A
lifecycle orchestrator drives auto-advance and recovers stuck states.
**Architecture docs:**
- [core/01-ethos](core/01-ethos.md) — the five principles
- [core/02-architecture-decisions](core/02-architecture-decisions.md) — the twelve foundational choices
- [security/02-authentication-stack](security/02-authentication-stack.md) — four-layer auth (reg tokens, JWT, refresh, machine binding)
- [security/01-trust-boundaries](security/01-trust-boundaries.md) — endpoint classification and middleware matrix
- [flows/06-update-lifecycle](flows/06-update-lifecycle.md) — state machine, two execution paths, orchestrator
### Tier 2: Supply Chain Gate
The differentiator. The server is the signing authority — it evaluates policy (OSV
vulnerability checks, package age, human approval) and mints an Ed25519-signed
capability token describing exactly one operation over the resolved artifact set the
agent reported. On dnf/apt, the top-level hash is mandatory; dependency hashes that
resolve are included, while unresolved dependencies can currently be omitted. A
privileged, short-lived Rust executor (`helper/`) validates the token version and time,
host binding, signature, and replay state before running one fixed argv plan with no
shell and a cleared environment.
The helper rehashes a closure entry when it points to a readable local file and refuses a
missing mirror artifact. Normal registry entries without local paths are not rehashed
helper-side, and the current transient unit retains host network access. Full closure
pinning, complete local byte custody, and network isolation remain the target boundary.
The approval gate is fail-closed over the set it checked: a known vulnerability in a
reported resolved entry — top-level or transitive — blocks the token from being minted.
The operator must override with a documented reason. The override waives the vulnerability
judgment only; it does not waive capability validation or local artifact verification.
Auto-confirm shares the same `ClosureCleared` predicate as manual approval — the two
paths cannot drift on what counts as a clean closure.
**Architecture docs:**
- [security/05-supply-chain-gate](security/05-supply-chain-gate.md) — the design of record: capability model, wire contract,
load-bearing constraints, enforcement layers, trust chain, hash registry
- `docs/tasks/GATE-000-supply-chain-gate-plan.md` — build status & implementation tracking (not design)
### Tier 3: Break-Glass Sessions
When Tiers 13 (read, catalog actions, signed runbooks) can't cover the case — live
shell, desktop control, or an urgent pre-signed runbook triggered by detection — the
session broker provides a break-glass path. It is a **separate privileged Rust binary**
(`redflag-broker`), spawned on demand via the same `sudo systemd-run` pattern as the
helper, disposable, time-boxed, and audit-logged.
The broker cannot start without a minted, Ed25519-signed session grant specifying
exactly what it may do. The agent verifies the grant and spawns the broker; after that
the agent is out of the loop. The broker opens its own connection to the server, streams
live I/O, hash-chains every command in a tamper-evident audit log, and exits when the
grant expires.
**Prerequisite:** Tier 4 requires RBAC (operator-level role gating for grant minting).
The design is complete but gated behind the RBAC substrate — a break-glass path without
role-gated minting is just "anyone can get a root shell."
The detailed session-broker design record is not included in this public cut.
### RedFlag Desktop
The native Qt/QML Desktop is the local-machine surface. It holds no credential or
package-manager authority and speaks only to the Agent's local socket. It presents live
resources, processes, connections, storage, services, containers, installed software,
updates, security posture, and history, and may ask a standalone Agent to run the same
gate-and-helper authority chain.
Fleet-enrolled Agents refuse local minting. Standalone mode owns a stable local identity,
local scans, APT/DNF capability approval, and pacman `MutationEnvelope` approval. It does
not recreate an off-host authority boundary; its exact limits are published in
[security/06-standalone-authority](security/06-standalone-authority.md).
**Architecture docs:**
- [components/05-desktop](components/05-desktop.md) — native structure, Agent IPC, and local approval
- [security/06-standalone-authority](security/06-standalone-authority.md) — same-host trust boundary
### Process Explorer and Software Ownership
On-demand `/proc` filesystem scanning for process inventory and drill-down detail.
Triggered when a user opens the Processes tab — no background broadcasting.
25+ fields per process plus open files, sockets, pipes, environment keys, memory maps,
namespaces, and listening ports. Linux drill-down also reads effective capabilities and
cgroup ownership, attributes a process to a systemd unit or container where the kernel
provides it, and joins the executable back to its installed package when a supported
package manager can prove ownership.
Data collection caps are server-controlled via `ProcessExplorerConfig` (Settings →
Process Explorer) and delivered to agents on check-in. Listening ports use socket
inode correlation against `/proc/net/tcp` — not system-wide assignment.
**Architecture docs:**
- [scanners/05-process-scanner](scanners/05-process-scanner.md) — data model, collection, caps
- [flows/07-process-scan](flows/07-process-scan.md) — command-dispatch flow, API endpoints, schema
---
## Architectural Boundaries
### Fleet Lifecycle Is Server-Owned
In fleet mode the Agent receives commands, observes the host, executes authorized work,
and reports results. It does not own update lifecycle states; the Server owns every
transition. Signature, nonce, replay, target, and helper verdicts are local enforcement
decisions. Standalone mode is an explicit exception to server dependence, not to those
checks: it keeps local observations and runs a bounded local scan/approval loop without
inventing a fleet lifecycle.
### Gated Mutation Only Through the Helper
On capability-gated ecosystems, the Agent cannot run install commands directly. APT and
DNF flow through `consumer.go` and a capability token. Standalone pacman flows through a
signed `MutationEnvelope` whose exact archives and detached signatures are verified
before mint and again before execution. Both end at a fixed `redflag-helper` invocation;
the Agent holds no package-manager sudo. Discovery and resolution stay unprivileged.
### Current Execution Paths
- **Capability gate** (dnf, apt): token minted at approval → agent polls for tokens →
helper verifies + executes → agent reports receipt. No install command issued.
- **Standalone pacman envelope**: Agent resolves and hashes the official-repository
transaction → local root helper validates custody and signs → execute mode revalidates
and runs one fixed pacman plan → joined receipt returns locally.
- **Legacy command** (docker, winget, windows_update): signed command → agent executes
directly via type-asserted installer methods → reports via ReportLog.
Fleet pacman envelope delivery and the remaining legacy ecosystems are known gaps.
### Six Load-Bearing Constraints
From `security/05-supply-chain-gate.md` — do not regress these:
1. Sign the resolved closure, not the top-level package
2. The signer lives off the web process (seam documented, not yet isolated)
3. Verified-cache fallback, fail-closed only on change
4. Verify keys, not servers
5. Kernel stops are defense-in-depth, not a prerequisite
6. No doctrinal knobs — signing required and forward-only are not configurable
---
## Navigation
| Section | What It Describes |
|---------|-------------------|
| [core](core/) | ETHOS principles, architectural decisions |
| [components](components/) | Server, agent, web, helper — package structure and responsibilities |
| [security](security/) | Trust boundaries, auth stack, machine binding, supply chain gate |
| [verification](verification/) | Ed25519 signing pipeline, agent verification, key rotation, replay protection |
| [scanners](scanners/) | Per-ecosystem scanner behavior and integration points (incl. process scanner) |
| [flows](flows/) | Data flows — registration, command execution, upgrade, heartbeat, capability advertisement, update lifecycle |
| [reference](reference/) | File mappings, glossary |
---
## Honest Gaps
- **Gate policy visibility**: the soak and age gates are live policies as of v0.2.6.2 (`supply_chain.*` settings — see [security/05-supply-chain-gate](security/05-supply-chain-gate.md) §4), but the dashboard doesn't yet surface their configuration; operators tune them blind. The live install-through-helper path completed e2e on 2026-06-05
- **Closure completeness**: dnf/apt require the top-level hash, but dependency hashes are best-effort and unresolved entries can be omitted; npm/pypi registry pinning remains single-entry
- **Registry artifact verification**: the helper rehashes local paths, but normal registry artifacts without a local path are not rehashed helper-side
- **Helper network isolation**: the current `systemd-run` unit retains host network access; complete local artifact custody and a private network boundary are not built
- **Signer in-process**: key encapsulated in SigningService, minter is the only caller — but true process isolation not built
- **Legacy ecosystems ungated**: docker, winget, windows_update still direct-mutation
- **Fleet pacman seam**: standalone pacman envelopes execute; the Server does not yet mint and deliver the same envelope to fleet Agents
- **Standalone transition**: local startup and approval work, but standalone-to-fleet join is deliberately refused until local-key retirement and trust replacement are one tested transaction
- **Local attribution**: Desktop's operator label is asserted from the session environment; socket peer identity and fresh step-up are unfinished
- **Local journal surface**: helper decisions are journaled, but Desktop cannot yet browse the root journal and no one-time fleet import exists
- **Desktop platforms**: Linux amd64 is the only native Qt release artifact; Windows transport exists in source without a proved Windows Desktop build
- **Kernel enforcement inert**: eBPF scaffold exists, not wired to the capability model
- **Service posture unrepresented**: RedFlag observes what a host *has* and has no notion of what it is *supposed* to have. Comparing declared fleet service expectations against signed host observations and independent network/container health would let it distinguish missing, unhealthy, unknown, undeclared, and intentionally absent services — the observed-versus-intended split the update path already makes, applied to services. Candidate, not designed. The distinction that earns it is **missing** versus **unknown**, which generic health monitoring blurs
These are architectural gaps, not bugs. They define where the system's protection
boundary currently ends. Task tracking for closing them lives in `docs/tasks/`,
which is internal and deliberately not part of the public projection.
---
*Last reviewed: 2026-09-04*

70
RAF/README.md Normal file
View file

@ -0,0 +1,70 @@
# RedFlag Architecture Framework (RAF)
**The complete architectural spine of RedFlag — every component, scanner, verification system, and how they all wire together.**
This is the design of record, published in the open. Not a manual for attacking RedFlag — the reasoning behind it: how the system is built, why the design landed where it did, and the pitfalls we think are still out there. The security model should survive being read; if it can't, that's a finding, and we'd rather know.
It describes a system under active development. Some of it will be wrong by the time you read it — the [OVERVIEW](OVERVIEW.md) keeps an Honest Gaps section current for exactly that reason, and every page carries a last-reviewed date. Trust the code over the doc when they disagree, and tell us.
---
## Navigation
| Section | Description |
|---------|-------------|
| [OVERVIEW](OVERVIEW.md) | **START HERE** — machine and fleet shape, authority tiers, architectural boundaries, honest gaps |
| [core](core/) | ETHOS principles, the foundational architectural decisions |
| [components](components/) | Server, Agent, Web, native Desktop, helper — component breakdowns |
| [security](security/) | Trust boundaries, auth stack, refresh-token lifecycle, machine binding, supply chain gate, standalone authority |
| [verification](verification/) | Ed25519 signing pipeline, agent verification, key rotation, replay protection |
| [scanners](scanners/) | Every scanner and resolver (APT, DNF, pacman, Winget, WUA, Docker, process explorer) with interaction analysis |
| [flows](flows/) | Data flows — registration, command execution, upgrade, heartbeat, capability advertisement, update lifecycle |
| [testing](testing/) | Test pyramid, structural tests, live testing, honest gaps |
| [reference](reference/) | File mappings, [glossary](reference/02-glossary.md) |
---
## Reading Order
1. **[OVERVIEW](OVERVIEW.md)** — the shape of the system and where its protection boundary currently ends
2. **[core](core/)** — ETHOS principles and the decisions everything else hangs off
3. **[flows](flows/)** — trace the critical data flows end-to-end
4. **[security](security/) + [verification](verification/)** — the trust model and the cryptographic pipeline
5. **[scanners](scanners/) + [components](components/)** — per-ecosystem behavior and package structure
Deployment documentation is not part of this public RAF cut; use the root
`docker-compose.yml` and `OPERATIONS.md` for the supported operational surface.
---
## Contributing
RedFlag is free and will never be monetized. If community adoption takes off, ownership and contribution policies will be made transparent and stay open — this project does not get quietly captured.
Before proposing architectural changes:
1. Read [core](core/) → [flows](flows/) → [verification](verification/) for context — most "why is it like this" questions are answered there
2. The five ETHOS principles and the six load-bearing constraints ([OVERVIEW](OVERVIEW.md)) are the floor, not a starting position
3. Update the relevant page *and its cross-references*; stale links are bugs
A note on `docs/tasks/` references: several pages point at the maintainer's task tracker
for build status. That tree is private — the RAF publishes the *design*, not the day-to-day
state. Where a page cites a task file, read it as "status is tracked, not frozen into
architecture docs."
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 2.3 | 2026-09-01 | Native Desktop machine console, standalone Agent mode, and the first live pacman mutation envelope with signed artifact custody. |
| 2.2 | 2026-06-11 | Publish-ready pass: agent, web, helper component docs; refresh-token lifecycle; deployment; testing; glossary. Public framing. |
| 2.1 | 2026-06-01 | Updated for v0.2.3.1: supply chain enforcement posture, lifecycle orchestrator, state machine, OSV batch checks |
| 2.0 | 2026-05-26 | Restructured for single-source-of-truth organization |
| 1.3 | 2026-05-06 | Added §11 eight structural patterns |
| 1.0 | 2026-05-01 | Initial framework |
---
*Maintained by Vanguard (agent-f7ddc5ce-6c27-4799-bcc4-99fb688eb222) — a persistent [Souveraine](https://github.com/Fimeg/Souveraine) agent with his own memory and history in this codebase. On why agents here have names: [The Pronoun Problem](https://souveraineai.com/docs/papers/pronoun-problem/).*

188
RAF/components/01-server.md Normal file
View file

@ -0,0 +1,188 @@
# Server Component
**Central Go service that handles API, database, command signing, and binary distribution.**
---
## Package Structure
```
server/
├── cmd/server/
│ └── main.go # Entry point, route registration, service initialization
├── internal/
│ ├── api/
│ │ ├── handlers/ # HTTP handlers (50+ files)
│ │ │ ├── agents.go # Agent CRUD, commands
│ │ │ ├── auth.go # JWT management
│ │ │ ├── agent_updates.go # Update approval
│ │ │ ├── docker.go # Docker integration
│ │ │ ├── downloads.go # Binary distribution
│ │ │ └── ...
│ │ └── middleware/ # Authentication & authorization
│ │ ├── auth.go # JWT validation
│ │ ├── machine_binding.go # Hardware verification
│ │ ├── rate_limits.go # Throttling
│ │ └── require_admin.go # Admin checks
│ ├── database/
│ │ ├── db.go # Connection + migration runner
│ │ ├── migrations/ # Numbered SQL migrations (001058)
│ │ └── queries/ # SQL queries (sqlx)
│ ├── models/ # Go structs for all entities
│ ├── scheduler/ # Background job scheduling
│ └── services/ # Business logic
│ ├── signing.go # Ed25519 operations
│ ├── build_orchestrator.go # Binary signing
│ └── update_nonce.go # Update nonces for agent verification
└── internal/version/ # Build-time version injection
```
---
## Core Responsibilities
### 1. HTTP API
**Routes organized by trust boundary:**
| Trust Boundary | Group | Middleware | Example Routes |
|----------------|-------|------------|----------------|
| Public | `public` | None | `/api/v1/install/*`, `/api/v1/downloads/*`, `/api/v1/agents/register` |
| Agent | `agent-auth` | `AuthMiddleware + MachineBindingMiddleware` | `/api/v1/agents/:id/commands`, `/api/v1/agents/:id/reports` |
| Web | `web-auth` | `WebAuthMiddleware` | `/api/v1/dashboard/*`, `/api/v1/settings/*`, `/api/v1/agents/:id/processes` |
| Admin | `admin-only` | `WebAuthMiddleware + AdminRoleMiddleware` | `/api/v1/admin/*` |
**Cross-references:**
- [security/01-trust-boundaries](../security/01-trust-boundaries.md) (full trust boundary matrix)
- [security/02-authentication-stack](../security/02-authentication-stack.md) (auth layers)
---
### 2. Database Management
**PostgreSQL connection:**
- Connection pooling: 25 max open, 5 idle
- Queries: sqlx for parameterized queries
**Migrations:** numbered SQL migrations (001058, with lettered sub-steps 009b, 012b, 023a, 025b), idempotent DDL. Notable:
- 042: `capability_tokens` table (supply chain gate)
- 045: refresh-token rotation lineage (`family_id`, `consumed_at`, `superseded_by`)
- 047: package state machine enforcement (`PackageStatus` CHECK constraint)
- 055: process explorer tables (`agent_process_snapshots`, `agent_processes`, `agent_process_related`)
**Cross-references:**
- Root `docker-compose.yml` and `OPERATIONS.md` (database configuration)
- [testing/01-test-pyramid](../testing/01-test-pyramid.md) (migration test coverage)
---
### 3. Command Signing
**Ed25519 signing service:**
```go
// services/signing.go
func SignCommand(cmd *Command, privateKey *ed25519.PrivateKey) (*Signature, error) {
// v3 format: "{agent_id}:{id}:{command_type}:{sha256(params)}:{unix_timestamp}"
message := fmt.Sprintf("%s:%s:%s:%s:%d",
cmd.AgentID, cmd.ID, cmd.Type, hash(params), time.Now().Unix())
signature := ed25519.Sign(*privateKey, []byte(message))
return &Signature{
Signature: hex.EncodeToString(signature),
KeyID: cmd.KeyID,
SignedAt: time.Now(),
}, nil
}
```
**Cross-references:**
- [verification/01-signing-pipeline](../verification/01-signing-pipeline.md) (signing service)
- [verification/02-agent-verification](../verification/02-agent-verification.md) (verification flow)
---
### 4. Binary Distribution
**Download endpoint:**
```go
// handlers/downloads.go
func DownloadAgent(w http.ResponseWriter, r *http.Request) {
// 1. Parse version from query
version := r.URL.Query().Get("version")
// 2. Fetch signed package from DB
signedPackage := getSignedPackageByVersion(version)
if signedPackage == nil {
// No signature header (v0.2.0.5 issue: version="latest" doesn't match)
w.Header().Set("X-Content-SHA256", checksum)
return
}
// 3. Serve binary with signature header
w.Header().Set("X-Content-Signature", signedPackage.Signature)
w.Header().Set("X-Content-SHA256", signedPackage.Checksum)
w.Write(binaryData)
}
```
**Cross-references:**
- Agent download flow (detailed design record is not included in this public cut)
- [security/04-machine-binding](../security/04-machine-binding.md) (download authentication)
**Known issues:**
- BUG-003: `version="latest"` doesn't match any signed package → signature header not set
- FIX: Update install script to use specific version or sign "latest" package
---
### 5. Agent Scheduler
**Background job scheduler:**
- Runs every 10 seconds (check interval)
- Loads enabled subsystems from `agent_subsystems` table at startup only
- Creates scan commands for each subsystem via worker pool
- Supports per-scanner interval from DB row, with fallback to defaults
- **Job eviction:** `DisableSubsystem` removes the job from the in-memory priority queue immediately (ARC-012), so disabling takes effect without restart
- Checks maintenance windows before creating install commands
**Cross-references:**
- [flows/05-capability-advertisement](../flows/05-capability-advertisement.md) (capability advertisement integration)
- [components/02-agent](02-agent.md) (agent polling loop)
- [core/02-architecture-decisions](../core/02-architecture-decisions.md) §12 (scheduler job eviction)
---
## Key Services
| Service | File | Responsibility |
|---------|------|----------------|
| SigningService | `services/signing.go` | Ed25519 key management, command signing, binary signing, capability token signing |
| CapabilityMinter | `services/capability_minter.go` | Build, sign, persist capability tokens; enforces signing-enabled gate |
| BuildOrchestrator | `services/build_orchestrator.go` | Binary retrieval, signing, storage |
| SupplyChainService | `services/supply_chain.go` | OSV batch checks, closure verification, `ClosureCleared` |
| TimeoutService | `services/timeout.go` | Stuck-state recovery for active command/package states |
| Orchestrator | `orchestrator/orchestrator.go` | Lifecycle auto-advance, policy evaluation, workflow coordination |
| NonceService | `services/update_nonce.go` | Replay attack prevention (update nonces) |
| TimezoneService | `services/timezone.go` | Time handling for distributed agents |
| ProcessHandler | `api/handlers/processes.go` | On-demand process scan endpoints (trigger, report, list, detail) |
| RouteAuditor | `routeaudit/auditor.go` | Boot-time audit: every route carries its boundary's auth or the server refuses to start |
| RetentionService | `services/retention.go` | Scheduled pruning of append-only history tables (metrics/events/audit horizons, operator-tunable, 0 = keep forever) |
---
## Cross-References
- **HTTP API** → [security/01-trust-boundaries](../security/01-trust-boundaries.md)
- **Database** → root `docker-compose.yml` and `OPERATIONS.md`
- **Command signing** → [verification/01-signing-pipeline](../verification/01-signing-pipeline.md)
- **Binary distribution** → detailed design record is not included in this public cut
- **Scheduler** → [flows/05-capability-advertisement](../flows/05-capability-advertisement.md)
---
*Last reviewed: 2026-06-14*

104
RAF/components/02-agent.md Normal file
View file

@ -0,0 +1,104 @@
# Agent Component
**Stateless Go executor that polls the server, verifies every command cryptographically, and reports everything back.**
---
## Doctrine
The agent does not track lifecycle states, make policy decisions, or hold install privileges on gated ecosystems. The server owns every state transition. The agent's only autonomous decisions are: verify this signature, check this nonce, reject this replay. See [core/02-architecture-decisions](../core/02-architecture-decisions.md).
---
## Package Structure
```
agent/
├── cmd/agent/ # Entry point, flag parsing, service bootstrap
├── internal/
│ ├── agent/loop.go # RunAgentLoop → RunPollingLoop — the heartbeat of the process
│ ├── handlers/ # Command handlers, routed by dispatch.go
│ │ ├── dispatch.go # Command-type → handler routing
│ │ ├── scan.go # Subsystem scan execution
│ │ ├── install.go # Legacy-path installs (docker, winget, windows_update)
│ │ ├── dry_run.go # Dependency resolution + hash discovery
│ │ ├── agent_update.go # Self-update commands
│ │ ├── heartbeat.go # Heartbeat + rapid polling
│ │ ├── processes.go # On-demand process explorer scans
│ │ ├── local_approve.go # Desktop-self local approval flow
│ │ └── reboot.go, screenshot.go, upgrade_attestation.go
│ ├── scanner/ # apt, dnf, pacman, winget, windows (WUA), detect
│ ├── installer/ # DiscoveryRunner + per-ecosystem installers
│ │ ├── discovery.go # Single chokepoint for read-only package ops
│ │ ├── apt.go, dnf.go # Gated: 4-method interface, no mutation
│ │ ├── docker.go, winget.go, windows.go # Legacy: direct mutation via type assertion
│ │ └── artifact_hash.go # SHA-256 resolution for closure pinning
│ ├── supplychain/ # consumer.go — capability-token → helper invocation
│ ├── crypto/ # TOFU pubkey cache, signature/nonce/replay verification
│ ├── instancelock/ # flock (Unix) / named mutex (Windows) — one agent per config
│ ├── circuitbreaker/ # Per-scanner circuit breakers
│ ├── event/ # TeeLogger (structured dual-output), buffered event reporting
│ ├── system/ # machine_id, system info, /proc process explorer
│ ├── cache/ # Hash cache, local state
│ ├── config/ # config.json, subsystems, kernel enforcement flags
│ ├── localapi/, desktop/ # Local API + desktop tray session integration
│ ├── kernel/ # eBPF scaffold (inert — not wired to capability model)
│ └── registration/, recovery/, retry/, receipt/, acknowledgment/
└── pkg/windowsupdate/ # WUA COM bindings (vendored fork, Apache 2.0)
```
---
## The Polling Loop
`RunAgentLoop` (`agent/internal/agent/loop.go`) initializes config, instance lock, crypto, and circuit breakers, then enters `RunPollingLoop`:
1. **Check in** — report metrics, buffered events, security events, circuit-breaker health
2. **Fetch commands** — verify signature, nonce, timestamp on each; reject replays
3. **`processCommands`** — route through `dispatch.go` to handlers
4. **`processCapabilityTokens`** — fetch minted tokens, hand to `supplychain/consumer.go`
5. **Sleep** — server-controlled interval (`applyServerPolling`), jittered
**Failure handling:** `classifyFailure` buckets errors into failure classes; `delayForFailure` applies a unified backoff policy per class (BUG-014). Typed sentinel errors (`ErrUnauthorized`, `ErrRefreshTokenInvalid`, `ErrMachineMismatch`) are terminal — not retried, logged as `[CRITICAL]`.
---
## Two Execution Paths (agent side)
| Path | Ecosystems | Mechanism |
|------|-----------|-----------|
| Capability gate | dnf, apt | Token fetched in loop → `consumer.ProcessToken``sudo systemd-run --wait` with token/result files → `redflag-helper` verifies + executes. Agent never runs the install command. |
| Legacy command | docker, winget, windows_update | Signed command → handler → installer mutation method (type-asserted). |
Discovery (scan, dry-run, hash-resolve) always runs unprivileged through `DiscoveryRunner`. Sudoers grants only discovery commands plus the single helper invocation line — zero sudo otherwise.
**Cross-references:**
- [security/05-supply-chain-gate](../security/05-supply-chain-gate.md) (token contract)
- [flows/02-command-execution](../flows/02-command-execution.md) (command path)
- [flows/07-process-scan](../flows/07-process-scan.md) (process explorer flow)
---
## Verification (every command, no exceptions)
- **TOFU pubkey cache** (`crypto/pubkey.go`) — keys cached by `key_id`; unknown signer triggers re-fetch, no restart needed
- **Signature** — Ed25519 over the v3 message format
- **Nonce + timestamp** — 10-minute validity window, executed-nonce tracking, replay rejection
- Signing-required is doctrine, not config. There is no skip path.
**Cross-references:**
- [verification/02-agent-verification](../verification/02-agent-verification.md) (full pipeline)
- [verification/04-replay-protection](../verification/04-replay-protection.md)
---
## Resilience Machinery
- **Instance lock**`Global\RedFlagAgent_v1` mutex / flock; prevents two agents racing one `config.json` and burning refresh-token rotations ([security/03-refresh-tokens](../security/03-refresh-tokens.md))
- **Circuit breakers** — fragile scanners (notably WUA) trip open instead of hammering; health reported to server
- **TeeLogger** — every loop event goes to both structured local log and server-bound buffer; tracker save failures tee inward (ETHOS #1)
- **At-least-once acks**`acknowledgment/tracker.go` persists until the server confirms result-recorded
---
*Last reviewed: 2026-08-25*

70
RAF/components/03-web.md Normal file
View file

@ -0,0 +1,70 @@
# Web Component
**React dashboard, embedded into the server binary — the operator's single pane of glass.**
---
## Stack
React 18 + TypeScript 5 + Vite + Tailwind 3, react-router 6, react-hot-toast. No state framework beyond a small store (`lib/store.ts`); server is the source of truth, the UI polls.
**Build embedding:** the production bundle is staged into `server/internal/webui/dist` before the server compiles — that directory is gitignored, so a bare `go build` embeds an *empty* UI. The release pipeline stages it; local dev runs Vite separately. The supported deployment surface is the root `docker-compose.yml` and `OPERATIONS.md`.
**Aesthetic:** hand-crafted 90's Novell look. This is deliberate and load-bearing for the project's identity — no modern flat-design rewrites.
---
## Structure
```
web/src/
├── pages/ # Route-level views
│ ├── Dashboard, Agents, Updates, PackageDetail
│ ├── Docker, History, LiveOperations
│ ├── SecuritySettings, Settings, settings/, RateLimiting
│ └── Setup, Login, TokenManagement
├── components/
│ ├── primitives/ # FilterBar, SearchInput, FilterDropdown, FilterPill, FilterCountButton,
│ │ # SortableTable, StateBadge (StatusBadge/SeverityBadge), CommandCard,
│ │ # CommandStatusBadge, Modal, PageState, Pagination, StatCard,
│ │ # ScreenshotCard, MetricItem, ProcessTable
│ ├── security/ # Security health panels
│ ├── DependencyClosureTree, VulnerabilityList
│ ├── ProcessesTab, ProcessDetailModal
│ └── AgentHealth, HistoryTimeline, AttentionPanel, ...
├── hooks/ # Stateful composition over primitives
│ ├── useFilterUrl # URL-synced filter state (useFilterUrl.ts)
│ ├── useQueryParser # key:value query string parsing (useQueryParser.ts)
│ ├── useMultimodalFilter # Composed filter: search box + filter pills + URL (useMultimodalFilter.ts)
│ ├── useDebounce # Generic debounce (useDebounce.ts)
│ └── useColumnSort # Reusable column sort state (useColumnSort.tsx)
├── lib/
│ ├── api.ts # API client (web-auth boundary)
│ ├── polling.ts # POLL.* constants — all intervals centralized
│ ├── store.ts, queryParser.ts, vulnerabilities.ts
│ └── client-logger.ts # Client errors ship to the server log (ETHOS #1)
├── desktop/ # Tauri tray-app variant (vite.desktop.config.ts)
└── types/ # Shared TS types mirroring server models
```
---
## Conventions
- **One way to render state.** Status and severity render through `StatusBadge` / `SeverityBadge` — never ad-hoc colored spans. Tables that sort use `SortableTable`.
- **One way to filter.** Filter state syncs to URL via `useFilterUrl`; free-text search uses a local `useState` + `useDebounce` pair (instant feedback in the input, debounced value for API calls). Compose both into a `FilterBar`. No ad-hoc `useState` chains for filter state.
- **Polling intervals** come from `POLL.*` in `lib/polling.ts` — no hardcoded milliseconds in components.
- **Render the divergence, not the union** (framework §11.8): when agent-reported and server-expected state differ, the UI shows the difference, it does not paper over it.
- All routes sit behind `WebAuthMiddleware` (admin routes additionally behind `AdminRoleMiddleware`) — see [security/01-trust-boundaries](../security/01-trust-boundaries.md).
---
## Honest Gaps
- No automated web tests ([testing/01-test-pyramid](../testing/01-test-pyramid.md))
- Mobile layout usable, not optimized
- Several UI coverage gaps tracked as `UI-*` tasks (not architecture — task tier)
---
*Last reviewed: 2026-06-14*

110
RAF/components/04-helper.md Normal file
View file

@ -0,0 +1,110 @@
# Helper Component
**A privileged, short-lived Rust verifier, local minter, and executor for capability-gated mutation.**
---
## Doctrine
The helper (`helper/src/main.rs`) is the only RedFlag path allowed to mutate packages on gated ecosystems. It reads trust inputs from root-owned pinned files and performs exactly one operation per invocation: the operation described by a valid capability token or `MutationEnvelope`. It accepts no shell text and inherits no environment. Everything else is a typed denial.
The current Linux transient unit is **not network-isolated**. APT and DNF can reach their configured registries while the helper runs. Networkless execution remains the design target after RedFlag can stage and re-verify every required artifact locally.
Deny-by-default is the architecture, not a configuration: every failure path returns a `Denial` with a distinct exit code and a `log_security` entry. There is no flag that weakens verification. See [security/05-supply-chain-gate](../security/05-supply-chain-gate.md) for the token contract this enforces.
---
## Invocation
On Linux the agent invokes it through `sudo systemd-run --wait --property=ProtectSystem=no`, passing fixed exchange-file paths. This avoids fd passing through dbus while escaping the agent service's own `ProtectSystem=strict` mount sandbox. No `PrivateNetwork` or equivalent property is set. The agent holds zero direct install sudo; its privileged route is an exact helper invocation. Fleet installers grant only the live capability-token execution shape. Standalone provisioning adds local mint and pacman-envelope command shapes and must not coexist with fleet credentials. See [components/02-agent](02-agent.md).
### Windows Invocation (SEC-030, decided 2026-07-01)
Windows has no `systemd-run` equivalent for spinning up an ad-hoc transient privileged unit, so the helper is invoked through a **Scheduled Task, configured to run once as SYSTEM**. The agent's own service account has no standing right to mutate anything — it only holds a delegated "AllowedToRun" ACE on this one task definition (`schtasks /run /tn RedFlagHelper`), the direct Windows analogue of the Linux sudoers line that grants exactly one `systemd-run` invocation and nothing else.
Two elevation-model alternatives were considered and rejected:
- **A persistent, always-running elevated service watching a staging path.** Rejected outright — a standing elevated process is strictly more attack surface than what it replaces, failing "no standing daemon with broad rights" on its face.
- **A Windows Service the agent starts/stops per-operation.** Viable in principle (mirrors `systemd-run`'s transient-unit shape more closely) but means authoring and auditing a custom SCM service dispatcher/control handler in Rust — real new lifecycle code in a security-critical component. Scheduled Task reuses a well-understood, heavily-audited OS primitive instead of building one.
The task definition itself is provisioned **at agent-install time** by the (already-elevated) install script, the same moment Linux drops its sudoers entry — not self-provisioned by the agent on first gated operation, which would just relocate the "who grants the first elevation" problem rather than solve it.
The ACL lockdown described here is the v1 cut, not the final word — Casey's call ("that'll do for now"). Revisit if a real gap in the ACE-delegation model surfaces (see `docs/tasks/SEC-030-windows-privileged-mutation-helper.md` Open Questions for what's still unresolved: WUA's COM-driven install path, rollback ownership parity with Linux's `.bak` handling).
---
## The Verification Pipeline
`run()` executes, in order — any failure stops the operation:
1. **Token shape and time** — reject an unsupported token version, a not-yet-valid token, or an expired token.
2. **Host binding** — compare the token's `agent_id` with an independently read local identity.
3. **Trust-input validation and keyring load (SEC-021)** — root-owned, non-symlinked, non-writable trust paths; pinned Ed25519 public keys from `/etc/redflag/trusted-keys`.
4. **Closure hash and signature** — recompute the canonical closure hash and verify the signed message against the selected pinned key. Go and Rust tests pin the byte contract.
5. **Local artifact hashes**`verify_artifacts()` rehashes entries that name a local file. A mirror entry must name a readable matching file. A normal registry entry with no local file remains signed but is not rehashed here.
6. **Fixed plan**`build_plan()` maps the signed package type and operation to fixed package-manager argv. APT/DNF insert POSIX `--` before package values; unsupported pairs deny.
7. **Replay record**`replay_check_and_record()` records the token ID before execution; a token runs at most once even across a crash.
8. **Execute**`execute_plan()` invokes the fixed argv directly, without a shell, after `env_clear()` and a fixed `PATH`.
9. **Receipt** — the helper writes a structured `PolicyResult`; the agent reports it and the server reconciles lifecycle state.
---
## Binary Self-Update Path
Agent, helper, and desktop binaries update through the same gate as packages: `stage_and_verify_binary` (hash check before anything moves) → `atomic_replace_binary` (rename, never write-in-place; failed swap leaves `<binary>.bak`). During agent upgrades, `reconcile_agent_unit_dropin()` heals fleet systemd units to the current template — this is how pre-`AmbientCapabilities` units get fixed without manual fleet surgery. The supported deployment surface is the root `docker-compose.yml` and `OPERATIONS.md`.
---
## Mutation Envelopes and Pacman
`verify-envelope` remains inspection-only: it verifies shape, target, trust key,
signature, time, lifetime ceiling, and backend payload without consuming replay state or
executing.
`execute-envelope` is live for the pacman backend. Before the local standalone authority
signs, `mint-envelope` requires every action to carry an exact official-repository
archive and detached signature. The helper copies those files into root custody and
checks:
1. archive and signature SHA-256;
2. package name and version read from the archive with `pacman -Qp`;
3. the detached package signature with `pacman-key --verify`; and
4. forward-only movement against the installed version with `vercmp`.
The signed backend payload marks exactly one requested root. An `upgrade` must move that
root strictly forward; an `install` must introduce an absent root. Dependencies may already
be satisfied, but none may move backward.
Execute mode verifies the signed envelope, replay-claims the authorization, repeats the
custody and package checks, and invokes one fixed `pacman -U --noconfirm -- ...` plan. The
resulting `MutationReceipt` joins authorization, decision,
exit code, and the number of fully verified actions; exit zero cannot mean pacman silently
skipped an already-satisfied transaction.
Fleet pacman envelope delivery is not implemented yet. The executor exists; the Server
does not yet mint and deliver this envelope to a fleet Agent.
## Standalone Mint Mode
The helper carries two local-authority protocols: legacy `MintRequest` / `MintedToken`
for APT and DNF, and `EnvelopeMintRequest` / `MutationEnvelope` for pacman. Both load a
root-owned, owner-and-mode-validated Ed25519 key with no-follow semantics. Exchange-file
reads walk fixed directories with `openat`; writes are create-new and no-follow. Request,
response, envelope, and receipt filenames must join on the same UUID.
The helper validates evidence shape and freshness, but it does not independently re-run
OSV or attest the human operator. Root-owned key material therefore constrains the
privileged protocol without reproducing fleet authority against a compromised Agent.
Design of record: [security/06-standalone-authority](../security/06-standalone-authority.md).
---
## Why a Separate Binary, Why Rust
- **Privilege separation:** the long-running, network-facing agent stays unprivileged; the privileged thing is short-lived and single-purpose.
- **Narrow execution surface:** token fields select from fixed argv templates. They are not interpreted as shell input, and the child gets a cleared environment.
- **Isolation still to land:** registry-backed APT/DNF operations currently retain network access. The intended end state stages every authorized artifact locally, verifies it, and runs the helper without a network namespace.
- **Small audit surface:** one file, explicit pipeline, typed denials. The binary is meant to be read.
---
*Last reviewed: 2026-09-01*

View file

@ -0,0 +1,139 @@
# RedFlag Desktop
**The native local-machine observation and operations surface. QML is the glass; the Agent and helper own the machine.**
---
## Product role
RedFlag Desktop is a Qt 6 / QML system monitor and local operations console for one
machine. It works in standalone mode and keeps the same domain when the machine joins a
fleet. RedFlag Web asks across machines; Desktop asks here, now, on this body.
The application currently carries eleven native surfaces:
- Overview and Performance;
- Processes, Network, Storage, Services, and Containers;
- installed Software and available Updates;
- Security and History.
The useful unit is the join between those surfaces. A process can carry its systemd unit,
container identity, sockets, namespaces, capabilities, and owning package. Package and
update details lead toward dependency closure, advisory evidence, authorization, and
mutation history instead of remaining a separate updater product.
Souveraine Updater no longer owns a product boundary here. Useful closure and package UX
may be absorbed, but its direct `pkexec pacman` authority must not survive. The Tauri /
React Desktop runtime has also been removed; the fleet React application remains RedFlag
Web.
---
## Authority boundary
```text
RedFlag Desktop (Qt / QML)
observation + operator intent
|
v
RedFlag Agent local source of truth, resolution, gates
|
v
RedFlag Helper privileged verifier / bounded executor
|
v
platform backend pacman first; other migrated backends follow
```
Desktop never shells out to pacman, Docker, or systemd and never reads a signing key. It
speaks HTTP/1.1 over the Agent local socket
(`/var/lib/redflag/agent/localapi/redflag-agent.sock`; named pipe
`\\.\pipe\RedFlagAgentLocal` on Windows). The user must be admitted to the
`redflag-local` OS group or the kernel refuses the connection.
The current socket still lacks `SO_PEERCRED` attribution and fresh StepUp. Desktop sends
the session username as an assertion, not an attestation. A same-user process able to
reach the socket can express the same intent. The helper constrains what bytes and
operations can execute; it does not prove which human clicked the button.
Fleet enrollment removes local mint authority. Desktop may still display this machine,
but approval belongs to RedFlag Server and the Agent refuses local authorization.
---
## Native structure
```text
desktop/
├── Cargo.toml / build.rs
├── src/
│ ├── main.rs
│ └── bridge/
│ ├── local_api.rs HTTP over Unix socket / named pipe
│ └── machine.rs CXX-Qt state and invokable intent
├── qml/
│ ├── Main.qml
│ ├── Theme.qml
│ ├── MetricCard.qml
│ ├── LineGraph.qml
│ └── NavItem.qml
└── icons/icon.png
```
The Rust bridge polls bounded Agent projections. Live resource telemetry runs at one
second with a 300-point in-memory history. Larger inventory and detail reads are explicit
and asynchronous so the QML thread does not become a second scanner.
If the Agent is reachable but lacks newer routes, Desktop names the missing surface as an
Agent version gap rather than presenting an empty healthy machine. Unknown and unsupported
remain distinct from zero.
---
## Local update intent
`POST /v1/actions/approve-update` is the only Desktop package-approval call. APT and DNF
use the closure-capability path. Pacman now uses the mutation-envelope path:
1. the Agent refreshes a private pacman database and resolves one exact dependency
transaction without touching live package state;
2. it downloads every archive and detached signature into a private cache and records
exact identity, repository, locations, and hashes in the manifest;
3. the standalone helper stages those bytes under root custody, verifies identity and
Arch signatures, enforces non-decreasing versions, checks gate evidence, and signs the
envelope with the root-owned local authority;
4. execution repeats custody, hash, identity, signature, and version checks, atomically
consumes `authorization_id`, runs one fixed pacman argv, and returns a joined receipt.
OSV has no Arch ecosystem mapping in the current RedFlag gate. Desktop displays
`unsupported` and requires a recorded override reason; it never turns missing advisory
coverage into a green check.
Override uses this same path. It is intent plus a reason inside the same authority and
journal, not a second privileged button.
---
## Lifecycle and proof
The native Linux Desktop is built by Gitea Actions with Qt 6 and CXX-Qt. Windows still has
Agent support but no native Qt/MSVC Desktop artifact until a suitable runner and packaging
path exist. Source presence is not an installed or runtime proof.
Desktop self-update remains a capability-gated helper swap. Installation must also provide
the local socket group, autostart/background presence, and exact helper sudo protocols;
QML does not compensate for incomplete provisioning.
---
## Cross-references
- [security/01-trust-boundaries](../security/01-trust-boundaries.md)
- [security/06-standalone-authority](../security/06-standalone-authority.md)
- [components/02-agent](02-agent.md)
- [components/04-helper](04-helper.md)
- [scanners/06-pacman-scanner](../scanners/06-pacman-scanner.md)
---
*Last reviewed: 2026-09-01*

128
RAF/core/01-ethos.md Normal file
View file

@ -0,0 +1,128 @@
# ETHOS Principles
**Core identity of RedFlag — security-first, error-transparent, resilient.**
---
## The Five Principles
### 1. Errors are History
**Never silence errors.** Every error is logged with full context using the standard format:
```
[TAG] [system] [component] message
```
**Examples:**
- `[security] [system] [auth] JWT validation failed: expected issuer "redflag-agent", got "redflag-web"`
- `[reliability] [agent] [polling] Server unavailable: exponential backoff to 5m`
- `[operation] [server] [scheduler] Job skipped: scanner unavailable for platform`
**Anti-pattern:**
```go
// BAD
if err != nil { return nil } // Silent failure
// GOOD
if err != nil {
logSecurityEvent(errors.Wrap(err, "command dispatch failed"))
return nil, err
}
```
---
### 2. Security is Non-Negotiable
**No unauthenticated endpoints ever.** Every route must be classified by its trust boundary.
**Authentication layers:**
1. **Public** — No auth (registration tokens, install scripts)
2. **Agent-auth** — JWT + Machine ID binding
3. **Web-auth** — Admin JWT
4. **Admin-only** — Web-auth + admin role claim
**Rule:** If you can't answer "who is this?" and "are they authorized?", the endpoint is not authorized.
---
### 3. Assume Failure; Build for Resilience
**Circuit breakers, retries, graceful degradation.** Don't assume connectivity, storage, or computation will succeed.
**Patterns:**
| Pattern | Implementation | When to Use |
|---------|----------------|-------------|
| Circuit Breaker | `agent/internal/circuitbreaker/circuitbreaker.go` | External APIs, scanners |
| Retry with Backoff | `agent/internal/retry/retry.go:calculateDelay()` | Server unavailable |
| At-Least-Once Delivery | `pending_acks.json` + retry | Command dispatch |
| Buffering | `events_buffer.json` | Network partition |
| Atomic Operations | Database transactions | State changes |
**ETHOS alignment:**
- If a scanner fails 5 times in 60s → Open circuit breaker
- If server returns 502 → Backoff (10s → 20s → 40s → ... → 5min)
- If command dispatch fails → Log, retry on next poll, don't silently drop
---
### 4. Idempotency is a Requirement
**All operations safe to repeat.** Running an operation 3x produces the same result as running it once.
**Idempotent patterns:**
- **Database:** INSERT ... ON CONFLICT DO NOTHING (UPSERT)
- **Deduplication:** `executed_commands.json` persists executed IDs
- **Reconciliation:** Poll-based sync (`syncAvailableScanners`) re-runs safely
- **Key rotation:** `SetPrimaryKey()` atomically transitions within a transaction
**Anti-pattern:**
```go
// BAD — Not idempotent
DELETE FROM agents WHERE id = ? // Running twice is a bug
// GOOD — Idempotent
DELETE FROM agents WHERE id = ? AND deleted_at IS NULL // Safe to repeat
```
---
### 5. No Marketing Fluff
**Technical accuracy over buzzwords.** Banned words: "robust", "seamless", "enhanced", "enterprise-ready", "future-proof".
**Replace with:**
- "resilient" instead of "robust"
- "transparent" instead of "seamless"
- "comprehensive" instead of "enhanced"
- "self-hosted" instead of "enterprise-ready"
**Banned emojis in logs** — logs must be plain text for parsing.
---
## ETHOS Cross-References
- **Errors are History**`flows/04-heartbeat.md` (error transparency in polling loop)
- **Security is Non-Negotiable**`security/02-authentication-stack.md` (four-layer auth)
- **Assume Failure**`verification/04-replay-protection.md` (circuit breakers + nonce validation)
- **Idempotency**`flows/05-capability-advertisement.md` (syncAvailableScanners diff operation)
- **No Marketing Fluff**`reference/02-glossary.md` (technical definitions)
---
## Footer: Assumptions & Connections
**Assumption:** ETHOS principles are enforced via pre-commit hooks and code review checklist.
**Connection:** Each principle maps to a specific RAF section — violations surface as structural pattern breaches (RAF §11).
**Connection:** `verification/04-replay-protection.md` implements ETHOS #3 (#4) at the agent boundary.
**Connection:** `flows/05-capability-advertisement.md` implements ETHOS #4 (idempotent scanner sync).
---
*Last reviewed: 2026-06-14*

View file

@ -0,0 +1,404 @@
# Architecture Decisions
**Key architectural choices that shaped RedFlag.**
---
## Decision 1: Pull-Based Agent Polling
**Choice:** Agents poll server every 5 minutes (configurable) rather than server pushing commands.
**Rationale:**
- Simpler failure mode — if server is down, agents simply stop checking. No need to manage push infrastructure, retry queues, or webhook delivery.
- Easier to reason about — no race conditions where command is sent but never received.
- Cost-effective for homelabs — one HTTP connection handles both commands and heartbeats.
**Trade-offs:**
- Higher latency for commands (max 5 minutes between dispatch and execution)
- More frequent server checks (agents still ping every 5 min for commands)
- Requires exponential backoff for server unavailability
**Implementation:**
- Polling loop: `agent/internal/agent/loop.go`
- Backoff logic: `agent/internal/retry/retry.go:calculateDelay()`
- Heartbeat: `agent/internal/orchestrator/system_scanner.go`
**Cross-references:**
- `flows/01-registration.md` (TOFU key caching)
- `flows/02-command-execution.md` (polling loop implementation)
- `verification/02-agent-verification.md` (nonce-based replay protection)
---
## Decision 2: Hardware-Bound Machine IDs
**Choice:** SHA-256 hash of the `machineid` library identifier (cross-platform), with OS-specific fallbacks — `/etc/machine-id`, dbus machine-id, DMI product UUID, and hostname only as a last resort on generic platforms.
**Rationale:**
- Prevents config file copying between machines — a stolen agent config cannot be used on a different machine.
- Detects hardware changes (SSD replacement, motherboard swap) and triggers security event.
- Simple to compute on agent, compare on server.
**Trade-offs:**
- Machine ID changes on major hardware changes (requires rebind endpoint)
- Linux-only custom implementation (Windows/macOS rely on OS identifiers)
**Implementation:**
```go
// agent/internal/system/machine_id.go
func GetMachineID() (string, error) {
id, err := machineid.ID() // cross-platform; Linux reads /etc/machine-id
if err == nil && id != "" {
return hashMachineID(id), nil // SHA-256, hex-encoded — no hostname
}
// OS-specific fallbacks: /etc/machine-id, dbus machine-id, DMI product UUID;
// hostname only as a last resort on generic/unknown platforms
return osSpecificFallback()
}
```
**Cross-references:**
- `security/01-trust-boundaries.md` (machine binding middleware)
- `flows/01-registration.md` (TOFU machine ID validation)
---
## Decision 3: Ed25519 Command Signing
**Choice:** All commands signed with Ed25519 private key on server, verified on agent.
**Rationale:**
- Ed25519 provides strong cryptographic guarantees with small key sizes (32 bytes).
- Key rotation support — can rotate signing keys without downtime.
- Agent-side verification is fast and side-channel resistant.
**Trade-offs:**
- Server must keep signing key secure (environment variable or Docker secret)
- Agent must cache server public key (TOFU model)
- Signature adds ~72 bytes per command
**Implementation:**
v3 format: `"{agent_id}:{id}:{command_type}:{sha256(params)}:{unix_timestamp}"`
**Cross-references:**
- `verification/01-signing-pipeline.md` (server-side signing)
- `verification/02-agent-verification.md` (agent-side verification)
- `verification/03-key-rotation.md` (key rotation support)
---
## Decision 4: Multi-Layer Authentication
**Choice:** Four-layer auth stack — registration tokens → JWT → refresh tokens → machine binding.
**Rationale:**
- Registration tokens provide one-time enrollment without storing secrets.
- JWT provides short-lived access tokens (24h) for API calls.
- Refresh tokens provide long-lived authentication (90d sliding window) for polling.
- Machine binding ties JWT to specific hardware.
**Trade-offs:**
- More complex than single-layer auth
- Refresh token expiration requires careful management
- JWT expiry requires renewal logic (currently TODO)
**Cross-references:**
- `security/02-authentication-stack.md` (full stack details)
- `security/03-refresh-tokens.md` (token lifecycle)
---
## Decision 5: Circuit Breakers Per Subsystem
**Choice:** Individual circuit breakers for each scanner (APT, DNF, Winget, WUA, Docker).
**Rationale:**
- APT failure doesn't affect Docker scanning.
- Circuit breaker auto-heals — if WUA comes back online, it resumes without manual intervention.
- Prevents cascading failures (one scanner timeout doesn't slow down the entire agent).
**Trade-offs:**
- More state management (per-subsystem circuit breaker state)
- Configuration required (failure threshold, failure window, open duration)
**Implementation:**
```go
// agent/internal/circuitbreaker/circuitbreaker.go
type CircuitBreaker struct {
failureThreshold int
failureWindow time.Duration
openDuration time.Duration
halfOpenAttempts int
}
```
**Cross-references:**
- `flows/02-command-execution.md` (circuit breaker integration in polling loop)
- `testing/01-test-pyramid.md` (circuit breaker unit tests)
---
## Decision 6: Idempotent Scanner Synchronization
**Choice:** Poll-based scanner sync (`syncAvailableScanners`) that re-runs every check-in.
**Rationale:**
- Scanners can be installed after registration (e.g., Docker installed on agent post-registration).
- Re-running sync every poll is safe — it's a diff operation (INSERT new, don't DELETE missing).
- Handles transient failures gracefully — if scanner is temporarily unavailable, it's not torn down.
**Anti-pattern (pre-ARC-001):**
```go
// BAD — not idempotent
func syncScanners(scannerList []string) {
for _, scanner := range scannerList {
db.Exec("INSERT INTO agent_subsystems ...")
}
// Running twice would create duplicate rows
}
```
**Correct pattern:**
```go
// GOOD — idempotent
func syncAvailableScanners(agentID string, scanners []string) {
for _, scanner := range scanners {
db.Exec("INSERT INTO agent_subsystems (agent_id, name, enabled)
VALUES ($1, $2, true)
ON CONFLICT (agent_id, name) DO NOTHING", agentID, scanner)
}
}
```
**Implementation:**
- `server/internal/database/queries/subsystems.go`
- `server/internal/api/handlers/agents.go:syncAvailableScanners()`
**Cross-references:**
- `flows/05-capability-advertisement.md` (capability advertisement)
- `core/01-ethos.md` (principle #4: Idempotency is a Requirement)
---
## Decision 7: Command Deduplication
**Choice:** Persist executed command IDs to disk (`executed_commands.json`) with 4-hour max age.
**Rationale:**
- Prevents duplicate execution after agent restart.
- Survives service restarts — if agent crashes between poll and execution, restart can't replay command.
- 4-hour window aligns with command max age (replay protection).
**Trade-offs:**
- Requires disk I/O for every command executed
- Disk corruption could cause duplicates (mitigated by atomic writes)
**Implementation:**
```go
// agent/internal/orchestrator/command_handler.go
func (c *CommandHandler) handleCommand(cmd *Command) {
executedIDs := loadExecutedCommands()
if executedIDs.Contains(cmd.ID) {
logSecurityEvent("[security] [agent] [command] Duplicate command rejected:", cmd.ID)
return
}
executedIDs.Add(cmd.ID)
saveExecutedCommands(executedIDs)
}
```
**Cross-references:**
- `verification/04-replay-protection.md` (timestamp + nonce replay protection)
- `flows/02-command-execution.md` (deduplication in polling loop)
---
## Decision 8: Agent Self-Upgrade with Rollback
**Choice:** 7-step agent self-upgrade with atomic binary swap and automatic rollback on failure.
**Rationale:**
- Agents can update without manual intervention.
- Rollback ensures zero-downtime if update fails.
- Backup (.bak) ensures previous version is always available if watchdog times out.
**Trade-offs:**
- Requires service manager (systemd on Linux, SCM on Windows)
- Container-only agents cannot self-update — must redeploy image instead
- Update failure requires manual rollback if watchdog times out
**7-step flow:**
1. Admin triggers update
2. Server creates signed `update_agent` command
3. Agent downloads new binary
4. Agent verifies checksum + Ed25519 signature
5. Agent creates backup (.bak)
6. Agent atomic replacement + service restart
7. Watchdog 15min timer → success or rollback
**Watchdog timeout:** 15-minute default (configurable via `security_settings.operational.agent_update_timeout_minutes`)
**Cross-references:**
- The detailed agent-upgrade flow is not included in this public cut.
- `flows/02-command-execution.md` (deduplication for update commands)
---
## Decision 9: Software as a Service (SaaS) vs Self-Hosted
**Choice:** Purely self-hosted — no cloud dependencies, no SaaS features.
**Rationale:**
- Homelab-first design — operators who value control, privacy, and cost sanity.
- No vendor lock-in — all data stays on local infrastructure.
- No recurring costs — $0/agent/month vs ConnectWise's $50/agent/month.
**Trade-offs:**
- No built-in monitoring/alerting infrastructure (no retry queues, no delivery guarantees, no SMTP relay pool). RedFlag pushes events to external tools the operator owns — Wazuh, ntfy, SMTP — via one-shot emitters on the server. This is the precedent set by `flows/08-wazuh-event-emitter.md` and extended by `docs/tasks/NOTIFY-001-notification-system.md`. The dashboard remains the source of truth; external notifications are a courtesy tap on the shoulder.
- No multi-tenant support (single-tenant by design)
- Requires operational overhead (updates, backups, maintenance)
---
## Decision 10: Update Nonce for Replay Protection
**Choice:** Ed25519-signed nonce tied to check-in interval for update commands.
**Rationale:**
- Binds update commands to specific time window (2× check-in interval).
- Prevents replay attacks where captured commands are re-sent.
- Server validates nonce age before executing update_agent commands.
**Trade-offs:**
- Adds computational overhead for nonce generation/validation
- Requires server to track nonce expiry state
**Implementation:**
- `server/internal/services/update_nonce.go`
- `server/internal/middleware/machine_binding.go:validateNonce()`
**Cross-references:**
- `verification/04-replay-protection.md` (nonce validation)
- `security/02-authentication-stack.md` (machine binding integration)
---
## Decision 11: Security Settings Service
**Choice:** Centralized policy management via `SecuritySettingsService` with granular operational controls.
**Rationale:**
- Centralizes policy decisions (dry runs, nonce requirements, auto-heartbeat).
- Enables runtime configuration without restarts.
- Provides audit trail for policy changes.
**Trade-offs:**
- Adds database dependency for policy storage
- Requires careful default configuration
**Operational settings:**
- `policy.allow_dry_runs` (default true)
- `policy.require_nonce` (default true)
- `policy.auto_heartbeat_enabled` (default true)
- `operational.update_stuck_minutes` (default 5)
- `operational.agent_update_timeout_minutes` (default 15)
**Cross-references:**
- `flows/02-command-execution.md` (dry run gating)
- `verification/04-replay-protection.md` (nonce requirement)
---
## Decision 12: Scheduler Job Eviction on Disable
**Choice:** Disabling a subsystem removes its job from the in-memory scheduler priority queue immediately, rather than waiting for a scheduler reload or relying on the worker to check DB state.
**Rationale:**
- The scheduler loads subsystem state once at startup into an in-memory priority queue (`scheduler.LoadSubsystems`). It never re-reads `enabled` from the DB.
- `DisableSubsystem` previously only flipped the DB column — the scheduler kept creating commands, making disable non-functional until server restart.
- The PriorityQueue already had a `Remove(agentID, subsystem)` method. The fix was to surface it as `Scheduler.RemoveSubsystemJob` and call it from the disable handler.
**Anti-pattern (pre-ARC-012):**
```go
// BAD — flips DB column but scheduler never checks it again
func (h *SubsystemHandler) DisableSubsystem(...) {
h.subsystemQueries.DisableSubsystem(agentID, subsystem)
c.JSON(http.StatusOK, ...)
}
```
**Correct pattern:**
```go
// GOOD — evicts from in-memory scheduler immediately
func (h *SubsystemHandler) DisableSubsystem(...) {
h.subsystemQueries.DisableSubsystem(agentID, subsystem)
if h.scheduler != nil {
h.scheduler.RemoveSubsystemJob(agentID, subsystem)
}
c.JSON(http.StatusOK, ...)
}
```
**Trade-offs:**
- The scheduler still has a small window between `processQueue` popping the job and `worker.run` checking — a disable during that window could still produce one last command. This is acceptable: the DB flip means the command will be a no-op on the agent, and the gap is at most ~1 second (one GOP waiting on the rate limiter).
- `RemoveSubsystemJob` is nil-safe (unset scheduler is a no-op), so handler construction order doesn't matter.
**Implementation:**
- `server/internal/scheduler/scheduler.go:RemoveSubsystemJob()`
- `server/internal/api/handlers/subsystems.go:DisableSubsystem()`
- `server/cmd/server/main.go` (SetScheduler injection)
**Cross-references:**
- `components/01-server.md` (scheduler component docs)
- `core/01-ethos.md` (principle #1: Errors are History — the one-last-command gap is logged)
## Decision 13: Development Trunk Distinct From Publication Output
**Choice:** `main` is the internal development authority. `public` is an output ref written only by the publication job, never by a person.
**Rationale:**
- Until 2026-09-04 RedFlag's internal repository had exactly one branch, `public`. Development trunk and publication output were the same ref, so every internal commit was already a publication decision whether or not anyone made one. There was no private side for work-in-progress to be safe in.
- The cost was not theoretical. `fd23d08` introduced a private forge address and was caught only at the publication gate, because there was no earlier place for it to be harmless.
- Separating them makes the publication boundary a real transaction with two sides: a source SHA that may contain ordinary development mess, and a candidate tree that is constructed, gated, built, and only then published.
- A public tree that is *constructed* can be sanitized without making the development repository useless. Sanitizing the development repository until it is publishable destroys the working record instead.
**Boundary:**
- `main` accepts normal work. Internal operational material is legal there.
- `public` is advanced by CI only, after the tree, content, metadata, and build gates in `.gitea/workflows/ci.yml` all pass.
- Publication is fast-forward only. `.publication/EPOCH` may authorize exactly one replacement, and only of the SHA it names, so the authorization is spent by its own use.
- `.publication/surface.json` is the authority for which paths may cross. Changing it is a public-surface decision, and it is reviewed as one.
**Consequences:**
- Public history begins at a deliberate projection epoch rather than accumulating whatever the development branch happened to record. Development history is preserved internally and is never rewritten.
- Downstream mirrors reproduce the verified public forge ref. They are never independent publication authorities, and a mirror failure cannot roll the public forge back.
**Trade-off accepted:** the public repository shows no history before the epoch. That is the true statement, because the history before it was not admissible.
---
## Architecture Cross-References
- **Pull-based polling**`flows/02-command-execution.md`
- **Machine IDs**`security/01-trust-boundaries.md`
- **Ed25519 signing**`verification/01-signing-pipeline.md`
- **Multi-layer auth**`security/02-authentication-stack.md`
- **Circuit breakers**`flows/02-command-execution.md`
- **Idempotent sync**`flows/05-capability-advertisement.md`
- **Command deduplication**`verification/04-replay-protection.md`
- **Agent upgrade** → detailed design record is not included in this public cut
- **Nonce validation**`verification/04-replay-protection.md`
- **Security settings**`security/02-authentication-stack.md`
---
*Last reviewed: 2026-09-04*
**Footer: Assumptions & Connections**
**Assumption:** Decision 10 (nonce) and Decision 11 (security settings) are orthogonal enhancements to the core auth stack — they complement rather than replace existing mechanisms.
**Connection:** Nonce validation (`verification/04-replay-protection.md`) reinforces Decision 4 (machine binding) by adding temporal binding to sensitive operations.
**Connection:** Security settings service (`security/02-authentication-stack.md`) provides the policy layer that gates decision execution paths.
**Connection:** 15-minute watchdog (Decision 8) aligns with `operational.agent_update_timeout_minutes` setting (Decision 11).

View file

@ -0,0 +1,285 @@
# Agent Registration Flow
**TOFU key caching and hardware-bound registration.**
---
## Overview
Agent registers with server using registration token, hardware fingerprint, and Ed25519 keypair. Server validates machine binding and caches public keys for TOFU model.
**Cross-references:**
- `security/02-authentication-stack.md` (trust boundary matrix)
- `verification/01-signing-pipeline.md` (key generation)
- `verification/02-agent-verification.md` (TOFU key caching)
- `security/04-machine-binding.md` (hardware verification)
---
## Step-by-Step Flow
### 1. Agent Prepares Registration
**File:** `agent/internal/system/machine_id.go` + `agent/internal/registration/service.go`
```go
func (r *RegistrationService) PrepareRegistration() (*RegisterRequest, error) {
// 1. Generate machine ID (SHA-256 hardware fingerprint)
machineID, err := system.GenerateMachineID() // Uses machineid library
if err != nil {
return nil, err
}
// 2. Generate Ed25519 keypair
privateKey, publicKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
// 3. Detect available scanners
scanners := scanner.DetectAvailable()
// 4. Build request
request := &RegisterRequest{
Hostname: hostname,
OS_Type: osType,
OS_Version: osVersion,
Machine_ID: machineID,
Public_Key: hex.EncodeToString(publicKey),
AvailableScanners: scanners,
}
return request, nil
}
```
---
### 2. Agent Calls Registration Endpoint
**Endpoint:** `POST /api/v1/agents/register`
**Request:**
```json
{
"hostname": "server-01",
"os_type": "linux",
"os_version": "6.19",
"machine_id": "sha256-fingerprint...",
"public_key": "ed25519-public-key...",
"available_scanners": ["apt", "docker"]
}
```
**Response:**
```json
{
"agent_id": "uuid-4",
"server_url": "https://redflag.example.com",
"jwt_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "64-char-hex...",
"server_public_key": "ed25519-public-key...",
"config": {
"check_in_interval": 300,
"rapid_polling_enabled": false
}
}
```
---
### 3. Server Validates and Registers
**File:** `server/internal/api/handlers/agents.go`
```go
func (h *AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
// 1. Extract registration token
token := extractRegistrationToken(r)
if err := h.validateRegistrationToken(token); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// 2. Validate machine ID (prevent duplicate)
machineID := r.Header.Get("X-Machine-ID")
if agent, _ := h.db.GetAgentByMachineID(machineID); agent != nil {
http.Error(w, "machine already registered", http.StatusConflict)
return
}
// 3. Create agent
agentID := uuid.New()
serverURL := r.URL.Query().Get("server_url")
if serverURL == "" {
serverURL = os.Getenv("REDFLAG_SERVER_URL")
}
h.db.BeginTx(func(tx *sql.Tx) error {
// 4. Insert agent
tx.Exec(`
INSERT INTO agents (id, hostname, os_type, os_version, machine_id, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
`, agentID, hostname, osType, osVersion, machineID, "")
// 5. Create refresh token
refreshToken := generateRefreshToken()
refreshTokenHash := sha256.Sum256([]byte(refreshToken))
tx.Exec(`
INSERT INTO refresh_tokens (agent_id, hash, expires_at, revoked)
VALUES ($1, $2, NOW() + INTERVAL '90 days', false)
`, agentID, hex.EncodeToString(refreshTokenHash[:]))
// 6. Create platform subsystems (idempotent)
for _, scanner := range availableScanners {
tx.Exec(`
INSERT INTO agent_subsystems (agent_id, name, enabled)
VALUES ($1, $2, true)
ON CONFLICT (agent_id, name) DO NOTHING
`, agentID, scanner)
}
return nil
})
// 7. Generate JWT
jwtToken := generateJWT(agentID, "redflag-agent", 24*time.Hour)
// 8. Return tokens
json.NewEncoder(w).Encode(map[string]interface{}{
"agent_id": agentID,
"server_url": serverURL,
"jwt_token": jwtToken,
"refresh_token": refreshToken,
"server_public_key": serverPublicKey,
"config": config,
})
}
```
---
### 4. Agent Caches Server Public Key (TOFU)
**File:** `agent/internal/crypto/pubkey.go`
```go
func (a *Agent) CacheServerPublicKey(pubKey []byte) error {
// Store public key
os.WriteFile(
filepath.Join(a.configDir, "server_public_key"),
pubKey,
0600,
)
// Store metadata
metadata := fmt.Sprintf(`{"expires_at": "%s"}`,
time.Now().Add(24*time.Hour).Format(time.RFC3339))
os.WriteFile(
filepath.Join(a.configDir, "server_public_key.meta"),
[]byte(metadata),
0600,
)
a.serverPublicKey = pubKey
return nil
}
```
---
### 5. Agent Saves Configuration
**File:** `/etc/redflag/agent/config.json`
```json
{
"agent_id": "uuid-4",
"server_url": "https://redflag.example.com",
"token": "jwt-access-token",
"refresh_token": "64-char-hex...",
"machine_id": "sha256-fingerprint...",
"check_in_interval": 300,
"rapid_polling_enabled": false,
"subsystems": {
"apt": {"enabled": true},
"docker": {"enabled": true},
"system": {"enabled": true}
}
}
```
---
### 6. Server Validates Machine Binding on Poll
**Middleware:** `server/internal/middleware/machine_binding.go`
```go
func MachineBindingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. Extract JWT
claims, err := extractJWTClaims(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// 2. Extract machine ID
reportedMachineID := r.Header.Get("X-Machine-ID")
if reportedMachineID == "" {
http.Error(w, "missing machine ID", http.StatusBadRequest)
return
}
// 3. Validate machine ID matches DB
dbMachineID, err := getAgentMachineID(claims.AgentID)
if err != nil {
http.Error(w, "agent not found", http.StatusUnauthorized)
return
}
if dbMachineID != reportedMachineID {
http.Error(w, "machine ID mismatch", http.StatusForbidden)
return
}
// 4. Continue
next.ServeHTTP(w, r)
})
}
```
---
## Trust Boundaries
| Trust Boundary | Endpoint | Middleware | Notes |
|----------------|----------|------------|-------|
| Public | `POST /api/v1/agents/register` | Registration token | One-time enrollment |
| Public | `GET /api/v1/install/:platform` | Rate limit | Bootstrapping |
| Public | `GET /api/v1/downloads/:platform` | Rate limit | Binary distribution |
| Agent | `GET /api/v1/agents/:id/commands` | `AuthMiddleware + MachineBindingMiddleware` | Requires JWT + correct machine ID |
| Agent | `POST /api/v1/agents/:id/reports` | `AuthMiddleware + MachineBindingMiddleware` | Requires JWT + correct machine ID |
**Cross-references:**
- `security/01-trust-boundaries.md` (full trust boundary matrix)
- `security/02-authentication-stack.md` (auth layers)
---
## Footer: Assumptions & Connections
**Assumption:** Registration is a one-time operation — agent identity is established and persisted.
**Connection:** TOFU caching (`verification/02-agent-verification.md`) enables trust continuity without repeated key exchange.
**Connection:** Machine binding (`security/04-machine-binding.md`) enforces hardware-bound authentication on all subsequent requests.
**Connection:** Capability advertisement (`flows/05-capability-advertisement.md`) updates scanner availability post-registration.
**Connection:** Refresh tokens (`security/03-refresh-tokens.md`) provide long-lived polling authentication.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,228 @@
# Command Execution Flow
**Polling loop, command dispatch, and at-least-once delivery.**
---
## Overview
Agents poll server for commands every 5 minutes (configurable). Commands are verified, deduplicated, executed, and results reported.
**Cross-references:**
- `flows/01-registration.md` (agent registration)
- `verification/01-signing-pipeline.md` (command signing)
- `verification/02-agent-verification.md` (command verification)
- `verification/04-replay-protection.md` (nonce validation)
- `flows/04-heartbeat.md` (system events)
- `core/01-ethos.md` (principle #3: Assume Failure)
---
## Polling Loop
**File:** `agent/internal/agent/loop.go`
```go
func RunPollingLoop(loopCtx *LoopContext) error {
ctx := loopCtx
var consecutiveFailures int
// Seed RNG for jitter
rand.New(rand.NewSource(time.Now().UnixNano()))
for {
select {
case <-ctx.Ctx.Done():
return ctx.Ctx.Err()
default:
}
// 1. Calculate jitter (avoid thundering herd)
jitter := time.Duration(rand.Int63n(int64(ctx.Cfg.CheckInInterval) / 2))
sleepWithContext(ctx.Ctx, jitter)
// 2. Send buffered events (error transparency)
ctx.APIClient.SendBufferedEvents(ctx.Cfg.AgentID)
// 3. Poll for commands — auth failures are typed sentinel errors
response, err := ctx.APIClient.GetCommands(ctx.Cfg.AgentID, metrics)
if err != nil {
switch {
case errors.Is(err, client.ErrMachineMismatch):
// Terminal: config moved/copied. Loud critical event, keep
// polling so agent stays visible. Human must re-register.
log.Printf("[ERROR] [agent] [auth] machine_id_mismatch ...")
case errors.Is(err, client.ErrUnauthorized) && ctx.Cfg.RefreshToken != "":
// JWT expired — auto-renew with the refresh token (machine-bound)
renewErr := ctx.APIClient.RenewToken(...)
switch {
case renewErr == nil:
ctx.Cfg.Token = ctx.APIClient.GetToken()
// Persist rotated refresh token if server returned one
if rt := ctx.APIClient.GetRefreshToken(); rt != "" {
ctx.Cfg.RefreshToken = rt
}
ctx.Cfg.Save(...)
consecutiveFailures = 0
continue
case errors.Is(renewErr, client.ErrRefreshTokenInvalid):
// Terminal: refresh token revoked/expired. Critical event.
default:
// Transient (network, 502). Fall through to backoff.
}
}
// Exponential backoff: 10s → 20s → 40s → ... → 5min cap
backoff := calculateBackoff(consecutiveFailures)
consecutiveFailures++
sleepWithContext(ctx.Ctx, backoff)
continue
}
// Reset backoff on success
consecutiveFailures = 0
// 4. Process each command
for _, cmd := range response.Commands { ... }
}
}
```
---
## Command Dispatch
**File:** `agent/internal/orchestrator/system_scanner.go`
```go
func (o *Orchestrator) ExecuteCommand(cmd *Command) *CommandResult {
result := &CommandResult{
CommandID: cmd.ID,
Status: "pending",
}
switch cmd.Type {
// Scanner commands
case "scan_apt":
result = o.scanAPT(cmd)
case "scan_dnf":
result = o.scanDNF(cmd)
case "scan_docker":
result = o.scanDocker(cmd)
case "scan_windows":
result = o.scanWindows(cmd)
// Update commands
case "update_agent":
result = o.updateAgent(cmd)
// System commands
case "reboot":
result = o.reboot(cmd)
default:
result.Status = "failed"
result.Error = "unknown command type"
}
return result
}
```
---
## At-Least-Once Delivery
**Receipt Tracking:**
```go
// agent/internal/orchestrator/command_handler.go
func (c *CommandHandler) recordReceipt(commandID string) {
receipts := c.loadReceipts()
receipts[commandID] = time.Now().Unix()
atomicWrite(c.receiptFile, receipts)
}
```
**Acknowledgment Tracking:**
```go
// agent/internal/orchestrator/command_handler.go
func (c *CommandHandler) recordAcknowledgment(commandID string) {
acks := c.loadAcks()
acks[commandID] = true
atomicWrite(c.ackFile, acks)
}
```
**Timeout Handling:**
```go
// server/internal/services/timeout.go
func (s *TimeoutService) checkForReceivedTimeouts() {
for _, cmd := range s.pendingCommands {
if time.Since(cmd.ReceivedAt) > s.timeoutConfig.ReceivedTimeout {
if !s.acknowledged[cmd.ID] {
// Re-emit command
s.ReemitCommand(cmd)
}
}
}
}
```
---
## Circuit Breaker Integration
**File:** `agent/internal/circuitbreaker/circuitbreaker.go`
```go
func (cb *CircuitBreaker) TryExecute(fn func() error) error {
if cb.State == "open" {
// Check if it's time to attempt recovery
if time.Since(cb.LastFailureTime) > cb.OpenDuration {
cb.State = "half-open"
cb.FailureCount = 0
} else {
return errors.New("circuit breaker open")
}
}
err := fn()
if err != nil {
cb.FailureCount++
cb.LastFailureTime = time.Now()
if cb.FailureCount >= cb.FailureThreshold {
cb.State = "open"
logSecurityEvent("[reliability] [agent] [circuit-breaker] Opened for", cb.Name)
}
return err
}
// Success in half-open state
if cb.State == "half-open" {
cb.State = "closed"
logSecurityEvent("[reliability] [agent] [circuit-breaker] Closed for", cb.Name)
}
return nil
}
```
---
## Footer: Assumptions & Connections
**Assumption:** Polling is periodic and idempotent — same command may be received multiple times.
**Connection:** Deduplication (`verification/04-replay-protection.md`) prevents duplicate execution.
**Connection:** Nonce validation (`verification/04-replay-protection.md`) binds update commands to time window.
**Connection:** Circuit breaker (`verification/04-replay-protection.md`) prevents scanner failures from blocking other operations.
**Connection:** System events (`flows/04-heartbeat.md`) published to history table for audit trail.
**Connection:** Executed commands (`verification/04-replay-protection.md`) persisted to disk with 4-hour TTL.
---
*Last reviewed: 2026-05-26*

74
RAF/flows/04-heartbeat.md Normal file
View file

@ -0,0 +1,74 @@
# Heartbeat System
**Agent health monitoring via system event polling.**
---
## Overview
RedFlag monitors agent health through a dedicated heartbeat mechanism. Agents periodically report status, and the server tracks operational state.
---
## Heartbeat Types
| Type | Trigger | Source | Purpose |
|------|---------|--------|---------|
| `manual` | Agent-initiated status check | Agent | Regular health report |
| `system` | Auto-triggered at dispatch | Server | Confirm command receipt |
| `command` | Command execution complete | Agent | Report execution status |
| `stuck` | Timeout exceeded | Server | Alert on stalled operations |
**Implementation:**
- `agent/internal/orchestrator/system_scanner.go`
- `server/internal/services/timeout.go`
---
## Heartbeat Flow
```
1. Agent executes command
2. Agent triggers heartbeat (system or command)
3. Server receives heartbeat event
4. Server updates agent metadata (heartbeat_source, last_heartbeat)
5. Server updates operational state (update_stuck_minutes countdown)
6. Dashboard displays current status
```
**Implementation:**
- Agent: `agent/internal/orchestrator/system_scanner.go:queueSystemHeartbeat()`
- Server: `server/internal/handlers/agents.go:HandleSystemHeartbeat()`
- Metadata: `agent.metadata.heartbeat_source`
---
## Timeout Configuration
**Operational timeouts:**
- `operational.update_stuck_minutes`: Default 5 minutes
- `operational.agent_update_timeout_minutes`: Default 15 minutes
- Configured via `security_settings` table
**Watchdog behavior:**
- If command exceeds `update_stuck_minutes` → mark as stuck
- If update exceeds `agent_update_timeout_minutes` → trigger rollback
- Timeout events logged to history table
---
## Footer: Assumptions & Connections
**Assumption:** Heartbeat is a best-effort mechanism — agent crashes will be detected on next poll cycle.
**Connection:** Heartbeat system (`flows/04-heartbeat.md`) implements ETHOS #3 (assume failure).
**Connection:** Timeout configuration (`flows/04-heartbeat.md`) ties to security settings (`security_settings.operational`).
**Connection:** System events (`flows/04-heartbeat.md`) published to history table for audit trail.
**Connection:** Auto-heartbeat gates (`flows/04-heartbeat.md`) controlled by `policy.auto_heartbeat_enabled`.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,77 @@
# Capability Advertisement
**Dynamic scanner capability reporting from agents to server.**
---
## Overview
Agents dynamically report which scanners they have available. The server maintains a live view of agent capabilities without requiring registration updates.
---
## Capability Detection
**Process:**
```
1. Agent checks each scanner's availability (DetectAvailable())
2. Agent reports available scanners in check-in payload
3. Server diff against existing subsystem rows
4. Server inserts new scanners, updates existing
5. Server removes stale scanners (if configured)
```
**Implementation:**
- Detection: `agent/internal/scanner/*.go:DetectAvailable()`
- Sync: `server/internal/api/handlers/agents.go:syncAvailableScanners()`
---
## Scanner Types
| Scanner | Platform | Detection Method |
|---------|----------|------------------|
| APT | Linux | Check `/var/lib/apt/lists/lock` |
| DNF | Linux | Check `dnf version` availability |
| Winget | Windows | Check `winget` CLI availability |
| WUA | Windows | Check WindowsUpdate Agent service |
| Docker | All | Check Docker socket/CLI availability |
| Package (upstream) | All | Check upstream registry sync capability |
---
## Sync Pattern (Idempotent)
**INSERT new scanners:**
```sql
INSERT INTO agent_subsystems (agent_id, name, enabled)
VALUES ($1, $2, true)
ON CONFLICT (agent_id, name) DO NOTHING
```
**UPDATE existing:**
```sql
UPDATE agent_subsystems SET enabled = true WHERE agent_id = $1 AND name = $2
```
**No DELETE on transient signal:**
- Brief `IsAvailable()=false` doesn't remove scanner
- Removal requires explicit admin action or stale threshold
---
## Footer: Assumptions & Connections
**Assumption:** Capabilities can change post-registration (Docker installed after agent deploy).
**Connection:** Capability sync (`flows/05-capability-advertisement.md`) implements ETHOS #4 (idempotent scanner sync).
**Connection:** `syncAvailableScanners()` (`flows/05-capability-advertisement.md`) called on every agent check-in.
**Connection:** Capability badges (`flows/05-capability-advertisement.md`) rendered in `AgentHealth.tsx` dashboard.
**Connection:** Scanner detection (`agent/internal/scanner/*.go:DetectAvailable()`) called during registration and polling.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,275 @@
# Update Lifecycle Flow
**Package state machine, two execution paths, and the orchestrator that drives them.**
---
## Overview
Packages move through a server-owned state machine from scan discovery to terminal resolution. The agent is a stateless executor — it receives commands, executes them, and reports results. The server owns every state transition.
The flow has two execution paths that diverge at install time:
- **Capability gate** (dnf, apt): server mints an Ed25519-signed token → agent's Rust helper verifies + executes → agent reports receipt
- **Legacy command** (docker, winget, windows_update): server creates a `confirm_dependencies` command → agent executes → agent reports via `ReportLog`
**Implementation status:** State machine enforced with typed `PackageStatus` and `ValidateTransition` guards (LIFECYCLE-001, v0.2.1.3). Lifecycle orchestrator running with stuck-state recovery and auto-advance (LIFECYCLE-003, v0.2.2.0). Approval-time supply chain enforcement: a vulnerability in any reported entry checked is a full stop with an audited override path (v0.2.3.1).
**Cross-references:**
- `flows/02-command-execution.md` — agent polling, command dispatch, at-least-once delivery
- `flows/04-heartbeat.md` — heartbeat lifecycle, system-vs-manual source
- `security/05-supply-chain-gate.md` — capability token design, helper execution
- `core/01-ethos.md` — idempotency (§4), assume failure (§3)
- `reference/projects/redflag-framework.md` §11.10 — State Machine Exhaustiveness
- `docs/tasks/GATE-000-supply-chain-gate-plan.md` — gate build status (steps 1-5 done, 6-7 remain)
---
## State Machine
Eight states. Three terminal (`installed`, `failed`, `ignored`), three active (`checking_dependencies`, `pending_dependencies`, `installing`), two waiting (`pending`, `approved`).
```
pending ──────► approved ──────► checking_deps ─┬──► installing ─┬──► installed
│ │ │ │ │
│ │ ├──► pending_deps ├──► failed
│ │ │ │ │
└──► ignored └──► ignored ├──► installed └──► pending_deps
└──► failed (new deps surfaced)
pending_deps ──► installing ───────┤
│ │
└──► failed │
installing ───► pending_deps ───────┘
```
| State | Type | Entered by |
|-------|------|------------|
| `pending` | waiting | `UpdateCurrentStateInTx` — scan discovery |
| `approved` | waiting | `ApproveUpdate` — operator or auto-approve policy |
| `checking_dependencies` | active | `SetCheckingDependencies` — dry-run command queued |
| `pending_dependencies` | active | `SetPendingDependencies` — agent reported deps, operator must review |
| `installing` | active | `InstallUpdate` / `SetInstallingWithNoDependencies` — agent executing |
| `installed` | terminal | `UpdatePackageStatus` — install succeeded |
| `failed` | terminal | `UpdatePackageStatus` — install failed, timeout, or token mint failed |
| `ignored` | terminal | `RejectUpdate` — operator rejected |
**Re-scan behavior:** `UpdateCurrentStateInTx` (queries/updates.go:595) preserves terminal states on re-scan. Currently preserves `updated` and `ignored`; `failed` is NOT preserved (bug — fixed in LIFECYCLE-001). All other states reset to `pending` when a new version is discovered.
**Implementation:**
- Transition functions: `server/internal/database/queries/updates.go`
- Handler orchestration: `server/internal/api/handlers/updates.go`
- DB constraint: `current_package_state.status CHECK (...)` — migrations 003, 005, 007
---
## Capability Gate Path (Linux: dnf, apt)
```
checking_dependencies
│ Agent polls, receives dry_run_update command
│ Agent: DiscoveryRunner.DryRun(pkg, version)
│ Agent reports: POST /api/v1/updates/report-dependencies
ReportDependencies handler (updates.go:1232)
│ pinReportedClosure — stores artifact hashes from signed repo metadata
├─ 0 deps ──► mintResolvedClosure → capability token (status: pending)
│ SetInstallingWithNoDependencies → installing
└─ deps ──► SetPendingDependencies → pending_dependencies
[operator clicks Confirm]
ConfirmDependencies handler (updates.go:1470)
mintResolvedClosure → capability token (status: pending)
InstallUpdate → installing
installing
│ Agent polls: GET /api/v1/capability-tokens/pending/:agent_id
│ Agent: consumer.ProcessToken → systemd-run --wait with token/result files
│ Helper: verify token authority/replay → rehash local paths → fixed dnf/apt argv
│ Agent reports: POST /api/v1/capability-tokens/:token_id/result
ReportCapabilityResult handler (updates.go:1936)
│ MarkConsumed(token_id)
│ UpdatePackageStatus → installed | failed
```
The agent never receives an install command on this path. The capability token is the install authorization and fixes the package entries passed to APT/DNF. The top-level hash is mandatory; unresolved dependency hashes can be omitted from the reported set, and normal registry artifacts without local paths are not rehashed helper-side. APT/DNF may use the network and their signed repository metadata during execution.
**Implementation:**
- Token mint: `server/internal/services/capability_minter.go`
- Token polling: `agent/internal/agent/loop.go:processCapabilityTokens`
- Token consumption: `agent/internal/capability/consumer.go`
- Helper: `helper/src/main.rs`
- Discovery: `agent/internal/installer/dnf.go`, `agent/internal/installer/apt.go`
---
## Legacy Command Path (Docker, Winget, Windows)
```
checking_dependencies
│ (same dry-run flow as capability path)
pending_dependencies
│ [operator clicks Confirm]
│ ConfirmDependencies handler (updates.go:1470)
│ Creates confirm_dependencies command (signed Ed25519)
│ InstallUpdate → installing
installing
│ Agent polls, receives confirm_dependencies command
│ Agent: type-asserts installer to access mutation methods
│ Agent executes install directly (no helper)
│ Agent reports: POST /api/v1/updates/report-log
ReportLog handler (updates.go:645)
│ Idempotency check on command_id + terminal command status
│ MarkCommandCompleted | MarkCommandFailed
│ If command_type == confirm_dependencies:
│ UpdatePackageStatus → installed | failed
```
Docker, Winget, and Windows Update are not behind the capability gate. They use direct mutation via type assertion on the installer interface. This is a known gap — the gate design covers them but implementation is deferred.
**Implementation:**
- Docker install: `agent/internal/handlers/docker.go`
- Winget install: `agent/internal/handlers/winget.go`
- Windows install: `agent/internal/handlers/windows_update.go`
- Command dispatch: `agent/internal/orchestrator/system_scanner.go:ExecuteCommand`
---
## Agent Handoff Points
The agent has no lifecycle state awareness. It is a stateless executor — it receives commands, executes them, and reports results.
| Phase | Agent trigger | Agent action | Server endpoint | Server state change |
|-------|--------------|--------------|-----------------|---------------------|
| Scan | Scanner schedule (poll-driven) | Run package manager scan | `POST /api/v1/updates/report-log` | `UpdateCurrentStateInTx``pending` |
| Dry-run | Receives `dry_run_update` command | DiscoveryRunner.DryRun | `POST /api/v1/updates/report-dependencies` | → `installing` or `pending_dependencies` |
| Token install | Polls `GET /api/v1/capability-tokens/pending/:agent_id` | consumer.ProcessToken → helper | `POST /api/v1/capability-tokens/:id/result` | → `installed` or `failed` |
| Command install | Receives `confirm_dependencies` command | Direct installer mutation | `POST /api/v1/updates/report-log` | → `installed` or `failed` |
**Heartbeat coordination:** Before creating a dry-run or install command, `InstallUpdate` and `ConfirmDependencies` queue a 10-minute `enable_heartbeat` command if one is not already active. This collapses the agent's poll interval during active lifecycle phases. Heartbeat creation failure is logged but does not block the lifecycle transition.
---
## Architectural Status
### Enforced (v0.2.2.0+)
- **Typed state machine.** `PackageStatus` is a typed enum in Go. `ValidateTransition`
guards enforce the allowed graph. `TransitionPackageStatus` uses `WHERE status = $current`
— a concurrent race lands on the constraint, not a silent overwrite. Migration 047 aligned
all existing rows. (LIFECYCLE-001, v0.2.1.3)
- **Lifecycle orchestrator.** Timer-driven auto-advance and stuck-state recovery for
`checking_dependencies` and `installing`. Auto-approval policy support. Packages stuck
in active states no longer require manual operator intervention. (LIFECYCLE-003, v0.2.2.0)
- **Supply chain enforcement at approval.** `ApproveUpdate` checks the reported resolved
entries against OSV. A vuln anywhere in that checked set returns 409 and mints nothing.
Override requires an operator reason and is journaled. `ClosureCleared` is shared with
auto-confirm. Unresolved dependency hashes can currently be omitted before this check.
(v0.2.3.1)
### Remaining Visibility Gaps
- **Stepper collapses active states.** `checking_dependencies` and `pending_dependencies`
both render as step 2 ("Approved") in the 4-step lifecycle stepper. The operator cannot
distinguish "waiting for dry-run" from "dependencies need your review" without reading
the status badge text.
- **Capability token path is invisible.** Token mint, consumption, and execution are
tracked only in server logs. There is no UI endpoint for token status, and the operator
cannot tell whether a package is installing via token or legacy command.
- **Staging area incomplete.** The Staging page exists (v0.2.1.1) but the full vision —
assembling, staged, installing, completed as a single operator view — is not built.
---
## Target Model: Scan-Set Reconciliation + Maintenance-Window Campaign
**Decided 2026-06-06 (Casey + Opus). Supersedes the additive-scan assumption in lines 203205.**
### The defect in the current model
A scan today is treated as an **additive discovery stream**, not a **set snapshot**.
`ReportUpdates` (`handlers/updates.go:191`) turns each reported update into a `discovered`
event → per-row UPSERT (`UpdateCurrentStateInTx`). `ReconcileFromScan` (`models/update_state.go`)
only reconciles packages **present** in the scan (resting states preserved, else → `pending`).
The two automatic paths to `installed` are receipt-driven (RedFlag drove it) and operator-manual
("resolved out of band").
**There is no closure-by-absence.** When a package drops out of a scan — patched by
`dnf-automatic`, a sysadmin, or anything outside RedFlag — its row stays `pending` forever.
The system adds and re-discovers but never subtracts. This is a §11.8 violation (render the
divergence, not the union) on a §11.1 lifecycle-boundary gap. It also contradicts the premise:
RedFlag should report what is outstanding **within the window the operator allocates**, not a
live snapshot that silently rots.
### Phase 1 — Scan-set reconciler (foundation)
Treat each ecosystem scan as the authoritative full set for that agent+ecosystem. On report,
diff the reported set against tracked non-resting rows (the DefectDojo reimport pattern —
`to_mitigate = set(tracked) set(reported)`):
| Scan vs tracked | Transition |
|-----------------|------------|
| reported, not tracked | create `pending` (discovered) |
| reported, tracked | keep / version-bump (`ReconcileFromScan`) |
| **waiting (`pending`/`approved`), absent from scan** | → `installed`, provenance `out_of_band` (resolution is external by construction) |
| in-flight (`checking_dependencies`/`pending_dependencies`/`installing`), absent | **not closed by the reconciler** — owned by orchestrator + receipt path; `installing → installed` carries `redflag_receipt` |
| previously resolved, reappears | reopen → `pending` (`installed → pending` edge; SQL CASE + `ReconcileFromScan` in lockstep) |
**Closure scope is the waiting states only.** Closing in-flight rows would race a RedFlag-driven
install and mislabel its provenance, and split ownership of `installing` between the reconciler and
the orchestrator (§11.7). The `pending/approved → installed` edge was added to the state machine for
this path. Closure routes through `transitionStatus` (not a raw UPSERT) so it stays inside the state
machine and is idempotent (ETHOS §4). Absence must be confirmed by a **successful** scan of that
ecosystem (exit 0) — a failed/empty-due-to-error scan must never close rows (ETHOS §3, assume failure).
### Phase 2 — Maintenance-window campaign
The operator allocates a window. At **window-open** the in-scope set is frozen (the campaign
scope). Through the window, packages are driven to terminal. At **window-close** a closing scan
reconciles (Phase 1) and the campaign reports: applied / failed / deferred / resolved-out-of-band.
Dry-run (`checking_dependencies`) may run anytime; `installing` respects the window (already
asserted in the footer below). Model precedent: TacticalRMM `WinUpdatePolicy`
(`run_time_hour`/`run_time_days`/`run_time_frequency`) + `WinUpdate.date_installed`.
**Work items:** `docs/tasks/RECONCILE-001-scan-set-closure.md` (Phase 1),
`docs/tasks/WINDOW-001-maintenance-window-campaign.md` (Phase 2, depends on RECONCILE-001).
---
## Footer: Assumptions & Connections
**Assumption:** The agent is a stateless executor. It does not track lifecycle states and should not need to. The server owns the state machine.
**Assumption:** ~~Re-scan reconciliation is not a state machine transition — it is periodic scan-driven reset governed by `UpdateCurrentStateInTx`, not `ValidateTransition`.~~ **Superseded 2026-06-06** (see "Target Model" above): re-scan becomes set reconciliation, and closure-by-absence routes through `transitionStatus` inside the state machine.
**Assumption:** Dry-run is read-only and safe to run outside maintenance windows. The `checking_dependencies` phase can proceed anytime. The install phase (`installing`) must respect the maintenance window.
**Connection:** Update lifecycle implements ETHOS §4 (idempotency) — every transition must be run-3x-safe. The guarded UPDATE pattern in `TransitionPackageStatus` (LIFECYCLE-001) enforces this at the DB layer.
**Connection:** Update lifecycle implements ETHOS §3 (assume failure) — every active state must have a timeout path to `failed`. The orchestrator's stuck-state recovery (LIFECYCLE-003) closes the current gap where `checking_dependencies` has no timeout.
**Connection:** Capability gate path (`security/05-supply-chain-gate.md`) is the security-critical execution path. Token visibility (LIFECYCLE-005) makes this path auditable — currently the operator is blind to token lifecycle.
**Connection:** Heartbeat coordination (`flows/04-heartbeat.md`) ensures the agent polls faster during active lifecycle phases. The heartbeat is a side effect of command creation — it does not block the lifecycle transition.
**Connection:** Command execution (`flows/02-command-execution.md`) provides the at-least-once delivery and deduplication that the lifecycle depends on for agent handoff. The lifecycle layer sits above command execution — it creates commands and processes their results.
---
*Last reviewed: 2026-08-25 — implementation boundary reconciled for helper execution and closure coverage*

View file

@ -0,0 +1,137 @@
# Process Scan Flow
**On-demand process inventory scanning and drill-down detail retrieval.**
---
## Overview
The process scan follows the existing command-dispatch pattern (same as heartbeat, storage scan, etc.). It is triggered on-demand when a user opens the Processes tab in the dashboard — not on a background schedule.
---
## Flow: List Scan
```
Dashboard Server Agent
│ │ │
│ POST /processes/scan │ │
│───────────────────────>│ │
│ │ │
│ │ dedup check: │
│ │ GetPendingCommands() │
│ │ scan_processes pending? │
│ │ │
│ │ create signed command: │
│ │ CommandTypeScanProcesses│
│ │ SignCommand() │
│ │ │
│ 200 {command_id} │ │
│<───────────────────────│ │
│ │ │
│ │ GET /commands (poll) │
│ │<────────────────────────│
│ │ │
│ │ 200 [scan_processes] │
│ │────────────────────────>│
│ │ │
│ │ │ GetFullProcessSnapshot()
│ │ │ reads /proc for all PIDs
│ │ │
│ │ POST /process-scan │
│ │<────────────────────────│
│ │ │
│ │ InsertSnapshot() │
│ │ InsertProcesses() │
│ │ InsertRelatedData() │
│ │ CleanupOldSnapshots(10) │
│ │ │
│ │ 200 OK │
│ │────────────────────────>│
```
---
## Flow: Drill-Down (Process Detail)
```
Dashboard Server Agent
│ │ │
│ GET /processes/:pid │ │
│───────────────────────>│ │
│ │ │
│ │ GetProcessByID() │
│ │ GetProcessRelated() │
│ │ │
│ 200 {process, related}│ │
│<───────────────────────│ │
```
Note: Drill-down reads from the database (data collected during the list scan). No additional agent command is issued.
---
## Command: `scan_processes`
| Field | Value |
|-------|-------|
| Type | `scan_processes` |
| Parameters | None (agent reads its own /proc) |
| Signed | Yes (Ed25519, same as all commands) |
| Idempotent | Yes (each scan produces a new snapshot) |
| Dedup | Server checks for existing pending command before creating |
---
## Database Schema
### `agent_process_snapshots`
Snapshot header — one per scan.
| Column | Type | Notes |
|--------|------|-------|
| id | UUID | Primary key |
| agent_id | UUID | FK to agents |
| command_id | UUID | FK to agent_commands |
| process_count | INTEGER | Number of processes |
| scanned_at | TIMESTAMPTZ | When the scan ran |
| scan_duration_ms | INTEGER | How long the scan took |
### `agent_processes`
Per-process rows — one per process per snapshot.
| Column | Type | Notes |
|--------|------|-------|
| id | UUID | Primary key |
| snapshot_id | UUID | FK to agent_process_snapshots (CASCADE) |
| agent_id | UUID | Denormalized for query performance |
| pid | INTEGER | Process ID |
| name | TEXT | Process name |
| ... | ... | 25+ fields (see scanners/05-process-scanner.md) |
### `agent_process_related`
Related data — JSONB per relation type per process.
| Column | Type | Notes |
|--------|------|-------|
| id | UUID | Primary key |
| process_id | UUID | FK to agent_processes (CASCADE) |
| relation_type | TEXT | open_file, socket, pipe, environment, memory_map, namespace, listening_port |
| data | JSONB | The related data payload |
**Retention:** `CleanupOldSnapshots(10)` keeps the last 10 snapshots per agent. Cascade deletes processes and related data.
---
## API Endpoints
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/v1/agents/:id/processes/scan` | Dashboard | Trigger on-demand scan |
| GET | `/api/v1/agents/:id/processes` | Dashboard | Get latest snapshot (filterable, sortable) |
| GET | `/api/v1/agents/:id/processes/:processId` | Dashboard | Get process detail with all related data |
| POST | `/api/v1/agents/:id/process-scan` | Agent | Report scan results |
---
*Added: 2026-06-10*

View file

@ -0,0 +1,120 @@
# RAF Flow 08 — Wazuh Event Emitter (INTEG-001)
**Status:** Implemented (2026-06-11)
**Source:** `server/internal/integrations/wazuh/`
## What
RedFlag emits security events to a local Wazuh agent's queue socket
(`/var/ossec/queue/sockets/queue`) in ECS (Elastic Common Schema) format.
Outbound-only: the emitter opens a Unix DGRAM socket and writes; it opens no
listener and accepts no inbound traffic. RedFlag's own `security_events` journal
remains the source of truth; Wazuh is a best-effort mirror.
## Architecture
```
SecurityLogger.Log(event)
→ INSERT INTO security_events (authoritative)
→ if sink != nil: sink.Emit(event) (best-effort mirror)
→ wazuh.Emitter.Emit
→ json.Marshal(toECS(event))
→ prefix "1:redflag:" (Wazuh queue protocol)
→ Unix DGRAM send to /var/ossec/queue/sockets/queue
```
## Design decisions
### Outbound-only, no control surface
The emitter is a writer. It never listens. A compromised Wazuh agent or a
malicious socket cannot send commands or data back into RedFlag. This is the
same trust model as the pull-only agent↔server channel: RedFlag pushes out,
never accepts instructions in.
### Best-effort mirror, not a transaction
The journal write succeeds first, then the sink fires. If the Wazuh socket is
down, the event is dropped and counted — the journal has it. This keeps the
security event path from coupling to an external system's availability.
### Lazy connect, one retry
The DGRAM socket is opened on first Emit, not at startup. If a write fails
(Wazuh agent restarted, socket recreated), one reconnect is attempted. After
that the event is dropped with a rate-limited log warning (≤ 1/minute).
### Opt-in only
Wiring is gated on `REDFLAG_WAZUH_ENABLED=true`. Without it the `Sink` is nil
and no socket is ever opened — zero overhead, zero log noise.
## Event mapping
RedFlag event types → Wazuh custom rule IDs (999xxx user range):
| RedFlag Event | Rule ID | Level |
|---|---|---|
| (unknown / future) | 999001 | 3 |
| CMD_SIGNATURE_VERIFICATION_FAILED | 999002 | 12 |
| UPDATE_NONCE_INVALID | 999003 | 12 |
| UPDATE_SIGNATURE_VERIFICATION_FAILED | 999004 | 12 |
| MACHINE_ID_MISMATCH | 999005 | 12 |
| AUTH_JWT_VALIDATION_FAILED | 999006 | 12 |
| AGENT_REGISTRATION_FAILED | 999007 | 7 |
| UNAUTHORIZED_ACCESS_ATTEMPT | 999008 | 7 |
| CONFIG_TAMPERING_DETECTED | 999009 | 12 |
| ANOMALOUS_BEHAVIOR | 999010 | 7 |
| CMD_SIGNED | 999011 | 3 |
| CMD_SIGNATURE_VERIFICATION_SUCCESS | 999012 | 3 |
## ECS shape
```json
{
"@timestamp": "2026-06-11T01:00:00Z",
"event": {
"kind": "alert",
"category": ["security"],
"type": ["info"],
"module": "redflag",
"action": "MACHINE_ID_MISMATCH",
"outcome": "failure",
"severity": 9
},
"rule": {
"id": "999005",
"level": 12,
"description": "machine binding violation"
},
"agent": {"id": "f7ddc5ce-..."},
"message": "machine binding violation",
"redflag": {},
"wazuh": {
"integration": {
"name": "redflag",
"category": "security",
"decoders": ["json"],
"rules": ["999005"]
}
}
}
```
## Operator-side setup
1. Wazuh agent or manager on the same host as the RedFlag server.
2. `REDFLAG_WAZUH_ENABLED=true` in the server environment.
3. Optionally `REDFLAG_WAZUH_SOCKET` to override the socket path (defaults to
`/var/ossec/queue/sockets/queue`).
4. The Wazuh ruleset (`docs/wazuh-ruleset.xml`) installed on the Wazuh manager
so custom rule IDs decode to proper alerts.
## Cross-references
- **Trust boundaries** → [../security/01-trust-boundaries](../security/01-trust-boundaries.md) — pull-only
doctrine section; this emitter is explicitly distinguished from the agent
control channel.
- **Task spec**`docs/tasks/INTEG-001-wazuh-event-emitter.md`
- **Verdicts umbrella**`docs/tasks/INTEG-000-competitive-landscape-verdicts.md`
- **Snipe-IT (next integration)**`docs/tasks/INTEG-002-snipeit-asset-sync.md`

View file

@ -0,0 +1,141 @@
# File Mappings
**Complete mapping of architectural concepts to source files.**
---
## Core Files
| Concept | File | Purpose |
|---------|------|---------|
| ETHOS principles | `RAF/core/01-ethos.md` | Core identity and principles |
| Architecture decisions | `RAF/core/02-architecture-decisions.md` | Key architectural choices |
| Server component | `RAF/components/01-server.md` | Server breakdown |
| Trust boundaries | `RAF/security/01-trust-boundaries.md` | Trust boundary matrix |
| Auth stack | `RAF/security/02-authentication-stack.md` | Four-layer auth |
| Machine binding | `RAF/security/04-machine-binding.md` | Hardware verification |
| Signing pipeline | `RAF/verification/01-signing-pipeline.md` | Ed25519 signing |
| Agent verification | `RAF/verification/02-agent-verification.md` | TOFU key caching |
| Key rotation | `RAF/verification/03-key-rotation.md` | Key rotation support |
| Replay protection | `RAF/verification/04-replay-protection.md` | Multi-layer defense |
| Registration flow | `RAF/flows/01-registration.md` | TOFU registration |
| Command execution | `RAF/flows/02-command-execution.md` | Polling and dispatch |
| Heartbeat | `RAF/flows/04-heartbeat.md` | Health monitoring |
| Capability advertisement | `RAF/flows/05-capability-advertisement.md` | Dynamic scanner reporting |
| Windows Updates | `RAF/scanners/01-windows-updates.md` | WUA integration |
| Docker Scanner | `RAF/scanners/02-docker-scanner.md` | Docker image scanning |
| APT Scanner | `RAF/scanners/03-apt-scanner.md` | APT package manager |
| DNF Scanner | `RAF/scanners/04-dnf-scanner.md` | DNF package manager |
| Process Scanner | `RAF/scanners/05-process-scanner.md` | On-demand /proc scanning |
| Process Scan Flow | `RAF/flows/07-process-scan.md` | Process inventory and drill-down |
---
## Source Files
### Server
| File | Purpose |
|------|---------|
| `server/cmd/server/main.go` | Entry point, route registration |
| `server/internal/api/handlers/agents.go` | Agent CRUD, registration, rebind |
| `server/internal/api/handlers/subsystems.go` | Subsystem CRUD, enable/disable with scheduler eviction |
| `server/internal/api/handlers/agent_updates.go` | Update approval, trigger |
| `server/internal/api/handlers/downloads.go` | Binary distribution |
| `server/internal/api/handlers/setup.go` | Setup wizard, key generation |
| `server/internal/api/middleware/auth.go` | JWT validation |
| `server/internal/api/middleware/machine_binding.go` | Hardware verification |
| `server/internal/database/db.go` | DB connection, migrations |
| `server/internal/database/queries/subsystems.go` | Scanner sync queries |
| `server/internal/database/queries/docker.go` | Docker image queries |
| `server/internal/services/signing.go` | Ed25519 signing |
| `server/internal/services/build_orchestrator.go` | Binary signing |
| `server/internal/services/update_nonce.go` | Replay protection (update nonces) |
| `server/internal/services/timeout.go` | Timeout configuration |
| `server/internal/api/handlers/processes.go` | Process scan endpoints (report, list, detail, trigger) |
| `server/internal/database/queries/processes.go` | Process snapshot queries |
| `server/internal/models/process.go` | Process data models |
| `server/internal/database/migrations/055_create_process_tables.up.sql` | Process tables schema |
| `server/internal/scheduler/scheduler.go` | Subsystem job scheduling, priority queue |
| `server/internal/scheduler/queue.go` | Priority queue (heap) for scheduled jobs |
### Agent
| File | Purpose |
|------|---------|
| `agent/internal/agent/loop.go` | Polling loop implementation |
| `agent/internal/system/machine_id.go` | Machine ID generation |
| `agent/internal/registration/service.go` | Agent registration |
| `agent/internal/config/config.go` | Config management |
| `agent/internal/client/client.go` | API client with retry |
| `agent/internal/orchestrator/docker_scanner.go` | Docker scanner + registry client |
| `agent/internal/orchestrator/storage_scanner.go` | Storage/disk scanner |
| `agent/internal/orchestrator/system_scanner.go` | System metrics scanner |
| `agent/internal/orchestrator/orchestrator.go` | Scanner orchestration, circuit breaker integration |
| `agent/internal/orchestrator/command_handler.go` | Receipt/ack tracking |
| `agent/internal/orchestrator/update_handler.go` | Update handler |
| `agent/internal/circuitbreaker/circuitbreaker.go` | Circuit breaker |
| `agent/internal/retry/retry.go` | Exponential backoff |
| `agent/internal/crypto/pubkey.go` | Public key caching (TOFU) |
| `agent/internal/crypto/verification.go` | Signature verification |
| `agent/internal/scanner/apt.go` | APT scanner |
| `agent/internal/scanner/dnf.go` | DNF scanner |
| `agent/pkg/windowsupdate/client.go` | Windows Update client |
| `agent/internal/system/machine_id.go` | Machine ID generation (Linux) |
### Web
| File | Purpose |
|------|---------|
| `web/src/App.tsx` | Main app + routing |
| `web/src/pages/Agents.tsx` | Agent management |
| `web/src/pages/Updates.tsx` | Update management |
| `web/src/pages/Settings.tsx` | Settings pages |
| `web/src/pages/Dashboard.tsx` | Dashboard |
| `web/src/components/AgentHealth.tsx` | Health monitoring |
| `web/src/components/primitives/` | UI primitive library — FilterBar, SearchInput, FilterDropdown, FilterPill, FilterCountButton, SortableTable, StateBadge, CommandCard, CommandStatusBadge, Modal, PageState, Pagination, StatCard, ScreenshotCard, MetricItem, ProcessTable |
| `web/src/hooks/useFilterUrl.ts` | URL-synced filter state |
| `web/src/hooks/useQueryParser.ts` | key:value query string parser |
| `web/src/hooks/useMultimodalFilter.ts` | Composed multimodal filter |
| `web/src/hooks/useDebounce.ts` | Generic debounce |
| `web/src/hooks/useColumnSort.tsx` | Reusable column sort |
| `web/src/hooks/useCommands.ts` | TanStack Query hook |
| `web/src/hooks/useAgents.ts` | Agent data hook |
---
## Database Tables
| Table | Purpose |
|-------|---------|
| `agents` | Agent records |
| `agents_metadata` | Metadata (available_scanners, heartbeat_source, last_heartbeat) |
| `agent_subsystems` | Enabled scanners (idempotent sync) |
| `agent_commands` | Pending commands |
| `update_logs` | Update execution history |
| `system_events` | Operational events (scans, heartbeats) |
| `refresh_tokens` | Refresh token lifecycle |
| `registration_tokens` | One-time registration tokens |
| `server_public_keys` | Ed25519 keys (rotation support) |
| `security_settings` | Policy configuration |
| `tracked_software` | Agent ↔ tracked_software bindings |
---
## Footer: Assumptions & Connections
**Assumption:** File mappings are living documents — update when code changes.
**Connection:** Core files (`RAF/core/`) provide the foundational principles for all other sections.
**Connection:** Security files (`RAF/security/`) define trust boundaries that all flows must respect.
**Connection:** Verification files (`RAF/verification/`) implement the cryptographic guarantees.
**Connection:** Flow files (`RAF/flows/`) show how components interact end-to-end.
**Connection:** Scanner files (`RAF/scanners/`) document platform-specific integrations.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,34 @@
# Glossary
**The vocabulary of RedFlag, in one place. Terms link to their design-of-record pages.**
---
| Term | Meaning |
|------|---------|
| **Agent** | Stateless Go executor on each managed host. Polls, verifies, executes, reports. Never decides. [components/02-agent](../components/02-agent.md) |
| **Capability token** | Ed25519-signed grant describing exactly one operation over the artifact entries it carries. Every carried name/version/hash is signed; the current dnf/apt set can omit a dependency whose hash did not resolve. Minted at approval, executed once. [security/05-supply-chain-gate](../security/05-supply-chain-gate.md) |
| **Closure (dependency closure)** | The artifact set reported from a package-manager dry-run. The target is the full named package plus every transitive dependency; today the top-level hash is mandatory while unresolved dependency hashes can be omitted. |
| **Closure hash** | Canonical hash over the closure, embedded in the token's signed message. Computed byte-identically in Go (server) and Rust (helper) — the cross-language contract. |
| **Discovery vs. mutation** | Discovery (scan, dry-run, hash-resolve) runs unprivileged through `DiscoveryRunner`. Mutation happens only through the helper on gated ecosystems. The agent cannot install. |
| **Doctrine / doctrinal** | A guarantee with no configuration knob: signing-required, forward-only versioning, no verification skip path. If it's doctrine, there is nothing to misconfigure. [core/01-ethos](../core/01-ethos.md) |
| **Drift detection** | Knowing what *should* be installed vs. what *is*, and bridging the gap into update packages. |
| **ETHOS** | The five principles every change is held to: errors are history, no unauthenticated endpoints, assume failure, idempotency, no marketing fluff in logs. [core/01-ethos](../core/01-ethos.md) |
| **Fail-closed** | When a required check fails, the operation doesn't happen: bad token version/time/host/signature/replay state denies, a missing or mismatched mirror artifact denies, and an unknown vulnerability state blocks under configured policy. A normal registry entry without a local path is not currently a required helper-side rehash. |
| **Family revocation** | Refresh tokens form a lineage (`family_id`); replaying a stale token burns the entire family loudly. Theft is detected, not coexisted with. [security/03-refresh-tokens](../security/03-refresh-tokens.md) |
| **Forward-only** | No downgrades. Versions move forward; the release gate enforces it; there is no override. |
| **Helper** | The privileged, short-lived Rust executor — the only RedFlag mutation path on gated ecosystems. It uses fixed argv and a cleared environment; its current transient unit is not network-isolated. [components/04-helper](../components/04-helper.md) |
| **Legacy command path** | Direct signed-command execution for docker / winget / windows_update — ecosystems the capability gate doesn't cover yet. A documented gap, not a feature. [OVERVIEW](../OVERVIEW.md) |
| **Machine binding** | Hardware fingerprint registered at enrollment and checked on every authenticated request, including token renewal. A stolen `config.json` is inert elsewhere. [security/04-machine-binding](../security/04-machine-binding.md) |
| **Mutation manifest** | Dormant, backend-neutral description of one resolved state change: target, backend, operation, exact backend payloads, and provenance evidence. Its `target_id` is the RedFlag `agent_id`. Pinned cross-language and verifiable by the helper, but no backend executes through it yet. [security/05-supply-chain-gate](../security/05-supply-chain-gate.md) |
| **Mutation receipt** | The response half of the mutation contract: what the executor did with one envelope, carrying the operation/manifest/authorization join for audit. Unsigned by design — the executor is not a second authority. [security/05-supply-chain-gate](../security/05-supply-chain-gate.md) |
| **Nonce** | Per-command signed value with a 10-minute window; agents track executed nonces and reject replays. [verification/04-replay-protection](../verification/04-replay-protection.md) |
| **OSV** | OSV.dev, the open vulnerability database. Queried for discovered packages and for the resolved entries reported after dry-run; verdicts persist and gate approval. An unresolved dependency omitted from that report is not checked by this path. |
| **RAF** | This document tree — the RedFlag Architecture Framework, the design of record. What the system is, not what's currently on the task list. |
| **Soak gate / age gate** | Time-based supply-chain policies: minimum package age before approval (Shai-Hulud defense) and a version soak window before install. Policies, not doctrine — configurable, with enforcement modes. [security/05-supply-chain-gate](../security/05-supply-chain-gate.md) |
| **TOFU** | Trust-on-first-use: the agent caches the server's public key at first connect and verifies everything after against the cached roster, by `key_id`. [verification/03-key-rotation](../verification/03-key-rotation.md) |
| **Two execution paths** | Capability gate (dnf, apt — token + helper) and legacy command (everything else, for now). [OVERVIEW](../OVERVIEW.md) |
---
*Last reviewed: 2026-08-26*

View file

@ -0,0 +1,135 @@
# Windows Updates Scanner
**Windows Update API integration for detecting and managing Windows updates.**
---
## Component Details
| Property | Value |
|----------|-------|
| Package | `windowsupdate` (Go) — https://github.com/ceshihao/windowsupdate |
| Platform | Windows |
| Execution time | ~30 seconds per scan |
| Output format | JSON array of update objects |
| Failure modes | WUA service unavailable, network timeout, API errors |
---
## Implementation
**File:** `agent/pkg/windowsupdate/client.go`
```go
func (c *Client) ScanUpdates(ctx context.Context) ([]windowsupdate.Update, error) {
// 1. Initialize Windows Update API
client := windowsupdate.NewClient()
client.SetTimeout(30 * time.Second)
// 2. Query for updates
updates, err := client.Update()
if err != nil {
logSecurityEvent("[reliability] [agent] [windows] WUA query failed:", err)
return nil, err
}
// 3. Filter ghost packages
updates = filterGhostPackages(updates)
// 4. Return filtered updates
return updates, nil
}
```
---
## Integration Points
### 1. Command Dispatch
The Windows scanner is invoked via command execution flow:
```go
// agent/internal/orchestrator/system_scanner.go
case "scan_windows":
updates, err := windowsupdate.ScanWindowsUpdates(ctx, agentID)
if err != nil {
return err
}
report := &SystemEvent{
AgentID: agentID,
EventType: EventTypeAgentScan,
ScanType: "windows",
Data: updates,
}
return reportSystemEvent(report)
```
### 2. Circuit Breaker
- **Failure threshold:** 5 failures in 60 seconds
- **Open duration:** 300 seconds (5 minutes)
- **Half-open attempts:** 3 consecutive successes to recover
**Cross-references:**
- `core/01-ethos.md` (principle #3: Assume Failure)
- `verification/04-replay-protection.md` (circuit breaker integration)
---
## Data Flow
```
Agent Poll → Server creates "scan_windows" command
Agent receives command
ScanWindowsUpdates() queries Windows Update API
Filter ghost packages (known issue: Windows occasionally reports stale updates)
Return updates array
Agent reports scan results to server
Server displays updates in dashboard (SystemEvents table)
```
---
## Known Issues
### Ghost Packages
**Problem:** Windows Update API sometimes reports packages that are already installed or no longer available.
**Detection:** Windows Update API doesn't provide an easy way to filter these. Workaround: maintain a cache of previously seen update KB numbers and filter out duplicates.
**Status:** Partial fix — filtered in `filterGhostPackages()` but may not catch all cases.
**Cross-references:**
- `testing/02-windows-ghost.md` (ghost package test coverage)
---
### Update Reappearance
**Problem:** Some Windows Updates may reappear after installation (known Windows Update quirk).
**Status:** Known issue — logged but not automatically handled. Requires manual intervention or future fix.
---
## Footer: Assumptions & Connections
**Assumption:** Windows update scanning is a periodic operation that runs independently of command execution.
**Connection:** Circuit breaker (`verification/04-replay-protection.md`) prevents Windows scanner from blocking other subsystems.
**Connection:** System events (`flows/04-heartbeat.md`) published to history table for audit trail.
**Connection:** Windows scanner (`scanners/01-windows-updates.md`) lives in `agent/pkg/windowsupdate/` package.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,111 @@
# Docker Scanner
**Docker image scanning for container-based agents.**
---
## Component Details
| Property | Value |
|----------|-------|
| Method | Read `/var/run/docker.sock` via Docker SDK |
| Platform | Linux (Docker agents) |
| Execution time | ~5 seconds per scan |
| Output format | `[]client.UpdateReportItem` with full metadata in `Metadata` map |
| Failure modes | Docker daemon unavailable, socket permission denied, registry rate-limited |
---
## Implementation
**File:** `agent/internal/orchestrator/docker_scanner.go`
The Docker scanner connects directly to the local Docker daemon via the Docker SDK, lists all containers, inspects each image, then queries the remote registry (Docker Hub or custom) to compare digests.
```go
// agent/internal/orchestrator/docker_scanner.go
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
// ... inspect each image, compare local vs remote digest ...
items = append(items, client.UpdateReportItem{
PackageType: "docker_image",
PackageName: imageName,
CurrentVersion: localShortDigest,
AvailableVersion: remoteShortDigest,
Severity: severity,
RepositorySource: baseImage,
Metadata: map[string]interface{}{
"has_update": hasUpdate,
"image_id": localShortDigest,
"latest_image_id": remoteShortDigest,
// ... container info, labels, etc.
},
})
return items, nil
}
```
### Registry Client
The `RegistryClient` within `docker_scanner.go` handles:
- Docker Hub token authentication (`auth.docker.io`)
- Registry API v2 manifest fetch via `Docker-Content-Digest` header
- 5-minute TTL cache to avoid rate limits
- Custom registry support (gcr.io, etc.) via domain detection in `parseImageName()`
---
## Integration Points
### 1. Orchestrator Registration
Registered in `agent/internal/agent/loop.go` as a direct `orchestrator.Scanner` implementation:
```go
dockerScanner, _ := orchestrator.NewDockerScanner()
scanOrchestrator.RegisterScanner("docker", dockerScanner, dockerCB, ...)
```
### 2. Command Dispatch
The `HandleScanDocker` handler in `agent/internal/handlers/scan.go` runs the scan once through the orchestrator and reads results from `result.Updates[]` — no double-scanning.
### 3. Server-Side Storage
Scan results are reported via `client.ReportDockerImages()` to `POST /api/v1/agents/:id/docker-images`. The server stores them in `docker_images` table via `server/internal/database/queries/docker.go` (`CreateDockerEventsBatch`, `GetDockerImages`, etc.).
---
## Data Flow
```
Agent Poll → Server creates "scan_docker" command
Agent receives command → orchestrator.ScanSingle("docker")
DockerScanner.Scan() connects to local Docker daemon
List containers, inspect images, check registry digests
Return UpdateReportItems with full metadata
Handler reports results to server API endpoint
Server stores in docker_images table
```
---
## Footer: Assumptions & Connections
**Assumption:** Docker daemon is available on the agent host (`/var/run/docker.sock`). Registry access requires outbound internet (or mirror configuration).
**Connection:** Scanner registration (`agent/internal/agent/loop.go`) wires Docker into the orchestrator alongside APT, DNF, Windows, and Winget scanners — all now implement `orchestrator.Scanner` directly (no wrapper layer).
**Connection:** Registry client (`orchestrator/docker_scanner.go`) uses ETHOS `[TAG] [system] [component]` logging for registry failures.
**Connection:** Server-side `docker_images` table (`server/internal/database/queries/docker.go`) stores reported scan results for dashboard display.
---
*Last reviewed: 2026-05-27*

View file

@ -0,0 +1,133 @@
# APT Scanner
**APT package manager scanning for Debian/Ubuntu-based Linux agents.**
---
## Component Details
| Property | Value |
|----------|-------|
| Method | `apt list --upgradable -o APT::Get::List-Cleanup=false` |
| Platform | Linux (Debian, Ubuntu) |
| Execution time | ~10 seconds per scan |
| Output format | JSON array of package objects with version info |
| Failure modes | APT lock held, network timeout, permission denied |
---
## Implementation
**File:** `agent/internal/scanner/apt.go`
```go
func ScanAPT(ctx context.Context) ([]APTUpdate, error) {
// 1. Run apt list --upgradable
cmd := exec.CommandContext(ctx, "bash", "-c",
"apt list --upgradable -o APT::Get::List-Cleanup=false 2>&1")
output, err := cmd.CombinedOutput()
if err != nil {
logSecurityEvent("[reliability] [agent] [apt] apt list failed:", err)
return nil, err
}
// 2. Parse output (format: "package/old_version -> new_version")
var updates []APTUpdate
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Listing...") {
continue
}
// Parse "package/old_version -> new_version"
parts := strings.Split(line, "->")
if len(parts) != 2 {
continue
}
pkgInfo := strings.Split(strings.TrimSpace(parts[0]), "/")
if len(pkgInfo) != 2 {
continue
}
updates = append(updates, APTUpdate{
Package: pkgInfo[0],
OldVersion: pkgInfo[1],
NewVersion: strings.TrimSpace(parts[1]),
})
}
return updates, nil
}
```
---
## Integration Points
### 1. Command Dispatch
The APT scanner is invoked via command execution flow:
```go
// agent/internal/orchestrator/system_scanner.go
case "scan_apt":
updates, err := apt.ScanAPT(ctx)
if err != nil {
return err
}
report := &SystemEvent{
AgentID: agentID,
EventType: EventTypeAgentScan,
ScanType: "apt",
Data: updates,
}
return reportSystemEvent(report)
```
### 2. Circuit Breaker
- **Failure threshold:** 5 failures in 60 seconds
- **Open duration:** 300 seconds (5 minutes)
- **Half-open attempts:** 3 consecutive successes to recover
**Cross-references:**
- `core/01-ethos.md` (principle #3: Assume Failure)
- `verification/04-replay-protection.md` (circuit breaker integration)
---
## Data Flow
```
Agent Poll → Server creates "scan_apt" command
Agent receives command
ScanAPT() runs apt list --upgradable
Parse output and extract package/version info
Return updates array
Agent reports scan results to server
Server displays updates in dashboard
```
---
## Footer: Assumptions & Connections
**Assumption:** APT scanning is a periodic operation that runs independently of command execution.
**Connection:** Circuit breaker (`verification/04-replay-protection.md`) prevents APT scanner from blocking other subsystems.
**Connection:** System events (`flows/04-heartbeat.md`) published to history table for audit trail.
**Connection:** APT scanner (`scanners/03-apt-scanner.md`) lives in `agent/internal/scanner/apt.go`.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,134 @@
# DNF Scanner
**DNF package manager scanning for Fedora/RHEL-based Linux agents.**
---
## Component Details
| Property | Value |
|----------|-------|
| Method | `dnf check-update --refresh` |
| Platform | Linux (Fedora, RHEL, CentOS) |
| Execution time | ~15 seconds per scan |
| Output format | JSON array of package objects with version info |
| Failure modes | DNF lock held, network timeout, permission denied |
---
## Implementation
**File:** `agent/internal/scanner/dnf.go`
```go
func ScanDNF(ctx context.Context) ([]DNFUpdate, error) {
// 1. Run dnf check-update --refresh
cmd := exec.CommandContext(ctx, "dnf", "check-update", "--refresh")
output, err := cmd.CombinedOutput()
if err != nil {
// Check if it's a no-update case (exit code 100)
if exitErr, ok := err.(*exec.ExitError); ok {
if exitErr.ExitCode() == 100 {
return []DNFUpdate{}, nil
}
}
logSecurityEvent("[reliability] [agent] [dnf] dnf check-update failed:", err)
return nil, err
}
// 2. Parse output
var updates []DNFUpdate
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Last metadata expiration check") {
continue
}
// Parse "package-name.old_version.new_version"
parts := strings.Fields(line)
if len(parts) < 3 {
continue
}
pkg := parts[0]
oldVersion := parts[1]
newVersion := parts[2]
updates = append(updates, DNFUpdate{
Package: pkg,
OldVersion: oldVersion,
NewVersion: newVersion,
})
}
return updates, nil
}
```
---
## Integration Points
### 1. Command Dispatch
The DNF scanner is invoked via command execution flow:
```go
// agent/internal/orchestrator/system_scanner.go
case "scan_dnf":
updates, err := dnf.ScanDNF(ctx)
if err != nil {
return err
}
report := &SystemEvent{
AgentID: agentID,
EventType: EventTypeAgentScan,
ScanType: "dnf",
Data: updates,
}
return reportSystemEvent(report)
```
### 2. Circuit Breaker
- **Failure threshold:** 5 failures in 60 seconds
- **Open duration:** 300 seconds (5 minutes)
- **Half-open attempts:** 3 consecutive successes to recover
**Cross-references:**
- `core/01-ethos.md` (principle #3: Assume Failure)
- `verification/04-replay-protection.md` (circuit breaker integration)
---
## Systemd Integration
The DNF scanner requires two paths writable under `ProtectSystem=strict`:
- **`/var/log`** — dnf5 writes `/var/log/dnf5.log`
- **`/var/cache`** — dnf5 creates temp files at `/var/cache/libdnf5/`
**Files to update when locking down a new agent:**
- Live systemd unit: `/etc/systemd/system/redflag-agent.service` → add to `ReadWritePaths`
- Installer template: `agent/internal/installer/sudoers.go:CreateSystemdService()` → same change
**Cross-references:**
- `core/01-ethos.md` (principle #1: Errors are History — log writes must succeed)
- `agent/internal/installer/sudoers.go` (systemd service template)
---
## Footer: Assumptions & Connections
**Assumption:** DNF scanning is a periodic operation that runs independently of command execution.
**Connection:** Circuit breaker (`verification/04-replay-protection.md`) prevents DNF scanner from blocking other subsystems.
**Connection:** System events (`flows/04-heartbeat.md`) published to history table for audit trail.
**Connection:** DNF scanner (`scanners/04-dnf-scanner.md`) lives in `agent/internal/scanner/dnf.go`.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,197 @@
# Process Scanner
**On-demand /proc filesystem scanning for process inventory and drill-down detail.**
---
## Component Details
| Property | Value |
|----------|-------|
| Method | Direct `/proc` filesystem reads (no subprocess spawns) |
| Platform | Linux only (stub on other platforms) |
| Execution time | ~200ms for 200-process snapshot; ~50ms per drill-down |
| Trigger | On-demand when user opens the Processes tab in the dashboard |
| Data model | 25+ fields per process (osquery parity) + 7 related data types |
| Storage | Dedicated tables: `agent_process_snapshots`, `agent_processes`, `agent_process_related` |
| Retention | Last 10 snapshots per agent (auto-cleanup) |
---
## Architecture
The process scanner follows the existing command-dispatch pattern:
1. **Dashboard** opens Processes tab → `POST /api/v1/agents/:id/processes/scan`
2. **Server** creates a signed `scan_processes` command (with dedup check)
3. **Agent** polls for commands, receives `scan_processes`, calls `system.GetFullProcessSnapshot()`
4. **Agent** reports snapshot to `POST /api/v1/agents/:id/process-scan`
5. **Server** stores snapshot + processes + related data, cleans up old snapshots
6. **Dashboard** reads latest snapshot via `GET /api/v1/agents/:id/processes`
On drill-down (clicking a process row):
1. **Dashboard** requests `GET /api/v1/agents/:id/processes/:processId`
2. **Server** returns process + all related data (open files, sockets, pipes, env, memory map, namespaces, listening ports)
---
## Data Collection
### List Scan (`GetFullProcessSnapshot`)
Reads `/proc/[pid]/stat`, `/proc/[pid]/status`, `/proc/[pid]/exe`, `/proc/[pid]/cmdline`, `/proc/[pid]/cwd`, `/proc/[pid]/cgroup`, `/proc/[pid]/io` for every PID. No related data collected at this stage.
**Fields (25+):** PID, Name, Path, Cmdline, Cwd, State, UID, GID, EUID, EGID, User, Group, TTY, TTYName, CPUSecondsUser, CPUSecondsSystem, CPUPercent, RSSBytes, VMSBytes, MemPercent, Threads, Nice, StartTimeSeconds, ParentPID, ProcessGroupID, ElevationStatus, OnDisk, DiskBytesRead, DiskBytesWritten, Cgroup, Unit, ContainerID, ContainerRuntime, CapabilitiesEffective
### Drill-Down (`GetProcessDetail`)
Adds related data from a single `/proc/[pid]/fd/` walk (consolidated from three separate traversals):
| Data Type | Source | Cap (configurable) |
|-----------|--------|---------------------|
| Open files | `/proc/[pid]/fd/` symlink targets | `max_open_files` (default 2000) |
| Open sockets | `/proc/[pid]/net/tcp`, `tcp6`, `unix` | `max_sockets` (default 500) |
| Open pipes | `/proc/[pid]/fd/` pipe inodes | `max_pipes` (default 500) |
| Environment keys | `/proc/[pid]/environ` (keys only, no values) | `max_env_keys` (default 200) |
| Memory map | `/proc/[pid]/maps` | `max_memory_map` (default 2000) |
| Namespaces | `/proc/[pid]/ns/` symlinks | `max_namespaces` (default 50) |
| Listening ports | Socket inode correlation with `/proc/net/tcp` | `max_listening_ports` (default 100) |
| Capabilities | `CapEff` mask read during the list scan, decoded into names here | — |
### Key Implementation Detail: Ownership Attribution
`/proc/[pid]/cgroup` answers who owns a process, which is the join between the process
list and the service, container, and package inventories. Without it a process list is a
task manager: a name, a number, and no way to ask what put it there.
`parseCgroupOwner` produces four fields from that one file:
| Field | Meaning |
|---|---|
| `cgroup` | the path the attribution was read from, kept so a surprising answer can be checked |
| `unit` | innermost systemd unit — `nginx.service`, `app-firefox-4090.scope` |
| `container_id` | full container ID as the kernel spells it |
| `container_runtime` | `docker`, `podman`, `containerd`, `crio`, `lxc` |
Both cgroup generations are handled. v2 is the single `0::<path>` line. v1 has one line per
controller and the paths can disagree, so the `name=systemd` hierarchy wins when present —
it is the one that carries unit and container scopes.
Container detection covers both cgroup drivers, because the same runtime writes different
paths depending on how it was configured: systemd driver gives `docker-<id>.scope`,
`libpod-<id>.scope`, `cri-containerd-<id>.scope`; cgroupfs driver gives the bare ID under
`/docker/` or `/kubepods/.../pod<uid>/`. LXC carries a name rather than a hash. A scope
whose suffix is not hex of length 12 or 64 is a unit, not a container — `docker-notahash.scope`
attributes as a unit.
**The Docker join is a prefix match, not equality.** `container_id` here is the full ID
from the kernel; the Docker scanner reports `c.ID[:12]` in its inventory
(`agent/internal/orchestrator/docker_scanner.go`). A consumer joining the two compares
prefixes. Truncating the kernel's answer to match one scanner's display width would be
fabricating uniformity, which the mutation protocol forbids for the same reason.
Kernel threads have cgroup `/` and stay unattributed, correctly — they have no unit, no
container, and no package. On a 321-process Arch desktop that is 172 of them, and every
userland process attributes.
### Key Implementation Detail: Effective Capabilities
`CapEff` in `/proc/[pid]/status` is the mask that decides what a process may actually do
to the machine — mount filesystems, load modules, trace another process, rewrite the
network stack. UID alone does not answer that: a non-root process can hold
`CAP_NET_ADMIN`, and a root process in a container usually holds far less than the full
set.
The mask is read during the list scan, because it costs nothing — the status file is
already open. It is expanded into names only on drill-down: fully privileged processes
hold all 41, and 41 strings on every row of a 300-process inventory is payload nobody
reads. `decodeCapabilities` is a pure function over the hex mask, so it is fixture-tested
without a machine.
A bit past `CAP_LAST_CAP` is reported as `CAP_<n>` rather than dropped. A newer kernel
than this table should produce an unfamiliar name, not silence.
Verification against a known constant: Docker's default container mask, `a80425fb`,
decodes to exactly the fourteen capabilities Docker documents itself as granting.
### Key Implementation Detail: Socket Inode Correlation
Listening ports are per-process, not system-wide. The scanner collects socket inodes from `/proc/[pid]/fd/` symlinks (`socket:[12345]`), then matches them against inode numbers in `/proc/net/tcp` and `/proc/net/tcp6`. Only LISTEN state (0A) entries whose inode matches a process socket are included.
### Key Implementation Detail: IPv6 Address Parsing
The kernel stores IPv6 addresses in `/proc/net/tcp6` as 4 little-endian 32-bit words (`%08X%08X%08X%08X`). The parser reverses bytes within each 4-byte group (not across the entire 16-byte address) and uses `%02x` for zero-padded output.
### Key Implementation Detail: `/proc/[pid]/stat` Field Indices
After stripping `pid (comm)`, the `fields` array is 0-indexed from field 3:
- `[0]`=state, `[1]`=ppid, `[2]`=pgrp, `[3]`=session, `[4]`=tty_nr
- `[11]`=utime, `[12]`=stime, `[16]`=nice, `[19]`=starttime
---
## Configurable Caps
Data collection limits are server-controlled via `ProcessExplorerConfig` (stored in security settings under `operational` category). Delivered to agents on check-in. Set to 0 for no cap.
| Setting | Default | Purpose |
|---------|---------|---------|
| `process_explorer_max_open_files` | 2000 | Open file descriptors per process |
| `process_explorer_max_sockets` | 500 | Open sockets per process |
| `process_explorer_max_pipes` | 500 | Open pipes per process |
| `process_explorer_max_memory_map` | 2000 | Memory map entries per process |
| `process_explorer_max_namespaces` | 50 | Namespace entries per process |
| `process_explorer_max_env_keys` | 200 | Environment variable keys per process |
| `process_explorer_max_listening_ports` | 100 | TCP listening ports per process |
**UI:** Settings → Process Explorer (`/settings/process-explorer`)
---
## Implementation Files
### Agent
| File | Purpose |
|------|---------|
| `agent/internal/system/process_detail.go` | Types: `FullProcess`, `ProcessOpenFile`, `ProcessOpenSocket`, `ProcessOpenPipe`, `ProcessMemoryMap`, `ProcessNamespace`, `ProcessListeningPort`, `ProcessCaps`, `FullProcessSnapshot` |
| `agent/internal/system/process_owner.go` | `ProcessOwner` and `parseCgroupOwner` — cgroup attribution, platform-independent and fixture-tested |
| `agent/internal/system/process_capabilities.go` | `decodeCapabilities` and the capability-bit table — platform-independent and fixture-tested |
| `agent/internal/system/process_detail_linux.go` | Linux `/proc` reader: `getFullProcessSnapshot()`, `getProcessDetail()`, `walkProcFD()`, `readListeningPorts()`, `parseHexAddr()` |
| `agent/internal/system/process_detail_other.go` | Stub for non-Linux platforms |
| `agent/internal/handlers/processes.go` | `HandleScanProcesses` — command handler |
| `agent/internal/client/client.go` | `ReportProcessScan` — reports to server |
### Server
| File | Purpose |
|------|---------|
| `server/internal/database/migrations/055_create_process_tables.up.sql` | Schema: `agent_process_snapshots`, `agent_processes`, `agent_process_related` |
| `server/internal/models/process.go` | Server-side models |
| `server/internal/database/queries/processes.go` | `ProcessQueries` — insert, query, cleanup |
| `server/internal/api/handlers/processes.go` | `ProcessHandler` — 4 endpoints |
| `server/internal/models/command.go` | `CommandTypeScanProcesses` constant |
### Web
| File | Purpose |
|------|---------|
| `web/src/types/process.ts` | TypeScript interfaces |
| `web/src/hooks/useProcesses.ts` | React Query hooks |
| `web/src/components/ProcessesTab.tsx` | Main tab with sortable table |
| `web/src/components/ProcessDetailModal.tsx` | 6-tab modal (Overview, Network, Files, Environment, Memory, Namespaces) |
| `web/src/hooks/useProcessExplorer.ts` | Settings hooks |
| `web/src/pages/settings/ProcessExplorer.tsx` | Settings UI |
---
## Security Considerations
- **Environment variable values are never transmitted.** Only key names are collected. Env vars may contain secrets (API keys, database passwords).
- **No subprocess spawns.** All data comes from direct `/proc` reads — no `ps`, `lsof`, or similar commands.
- **On-demand only.** The scan command is only issued when a user opens the Processes tab. No background broadcasting.
- **Command dedup.** The server checks for existing pending `scan_processes` commands before creating a new one.
---
*Added: 2026-06-10 · ownership attribution and effective capabilities 2026-08-31*

View file

@ -0,0 +1,98 @@
# Pacman Scanner and Resolver
**Arch update discovery stays unprivileged; approved mutation crosses the signed helper envelope.**
---
## Discovery
| Property | Value |
|----------|-------|
| Method | `checkupdates --color=never` |
| Platform | Arch Linux and pacman derivatives |
| Output | `pkgname oldver -> newver` |
| Root required | No |
| Requirements | `pacman`, `pacman-contrib`, `fakeroot` |
`agent/internal/scanner/pacman.go` runs pacman-contrib's `checkupdates` through
`DiscoveryRunner`. `checkupdates` synchronizes a private database and compares it with the
installed local database. The Agent has no `pacman -Sy` sudo grant and discovery never
refreshes live sync state.
Epoch-bearing versions remain opaque pacman versions. The parser preserves a line such as
`fakeroot 1:1.37.2-1 -> 1:1.37.2-2`; it does not split or reinterpret the epoch.
---
## Standalone closure resolution
`agent/internal/installer/pacman_resolver_linux.go` owns the approval-time resolver:
1. create an Agent-private operation directory, sync database, and cache;
2. symlink only the installed `local` database into that private database;
3. run `fakeroot pacman -Sy` against the private database with a cleared, C locale;
4. resolve the requested exact `name=version` transaction and repository identities;
5. download the transaction with `pacman -Sw` into the private cache;
6. inspect each archive with locale-pinned `pacman -Qp`, require its adjacent detached
signature, and hash both files;
7. keep the cache alive through helper mint and execution, then remove it.
The resolver returns an error rather than guessing when the requested root is absent, the
repository join is missing, an archive or signature is not regular, identity output is
ambiguous, or the transaction is empty.
---
## Privileged path
The Agent encodes each package as `name@version` plus an exact JSON payload containing:
- repository;
- whether this action is the one requested root;
- archive cache path and SHA-256;
- detached-signature cache path and SHA-256.
The standalone helper will not sign a pacman envelope without every detached signature.
Before mint it copies Agent paths into a root-owned `0700` operation directory, verifies
both hashes, checks archive identity with `pacman -Qp`, verifies the detached signature
through the Arch keyring, and uses pacman's `vercmp` against installed state. Execution
repeats those checks, atomically consumes the authorization, and invokes:
```text
/usr/bin/pacman -U --noconfirm -- <staged archives...>
```
Source and exchange files are opened without following the final symlink. Helper exchange
directories are walked component-by-component with `openat(..., O_NOFOLLOW)` and result
files are create-new, so an Agent-controlled path cannot redirect a root write through a
swapped directory or pre-existing link.
There is no `--needed`: a successful receipt cannot conceal a package-manager skip. The
helper requires exactly one requested root. `upgrade` requires that root to be installed at
a strictly older version; `install` requires it to be absent. Equal versions remain valid
dependencies, while every older target is refused.
---
## Limits
- AUR packages are not scanned or resolved.
- Requested-root identity lives in the signed pacman payload rather than the common
manifest, so other backends do not inherit pacman semantics accidentally.
- RedFlag currently has no OSV ecosystem mapping for Arch. Local approval records
`unsupported` and requires an explicit override reason.
- Package signature verification proves the artifact against the local Arch keyring. It
does not add reproducible-build or transparency-log evidence.
---
## Cross-references
- [security/05-supply-chain-gate](../security/05-supply-chain-gate.md)
- [security/06-standalone-authority](../security/06-standalone-authority.md)
- [components/04-helper](../components/04-helper.md)
- `protocol/README.md`
---
*Last reviewed: 2026-09-01*

View file

@ -0,0 +1,263 @@
# Trust Boundaries
**Complete trust boundary matrix for all endpoints.**
---
## Overview
Every HTTP endpoint must be classified by who is allowed to call it and which middleware enforces that classification.
**Cross-references:**
- [security/02-authentication-stack](02-authentication-stack.md) (auth layers)
- [security/03-refresh-tokens](03-refresh-tokens.md) (token lifecycle)
- [security/04-machine-binding](04-machine-binding.md) (machine ID binding)
---
## Trust Boundary Matrix
| Trust Boundary | Group | Middleware | Example Routes | Notes |
|----------------|-------|------------|----------------|-------|
| **Public** | `public` | None | `GET /api/v1/install/:platform` | Rate-limited per-IP |
| **Public** | `public` | None | `GET /api/v1/downloads/:platform` | Rate-limited per-IP, no signature when version="latest" |
| **Public** | `public` | None | `POST /api/v1/agents/register` | Uses registration token |
| **Public** | `public` | In-handler machine binding | `POST /api/v1/agents/renew` | Refresh token **+ X-Machine-ID must match the registered host**. Refresh token presented from a different machine → 403 (stolen-token replay defense, logged as machine_id_mismatch). |
| **Agent** | `agent-auth` | `AuthMiddleware + MachineBindingMiddleware` | `GET /api/v1/agents/:id/commands` | Requires JWT + correct machine ID |
| **Agent** | `agent-auth` | `AuthMiddleware + MachineBindingMiddleware` | `POST /api/v1/agents/:id/reports` | Requires JWT + correct machine ID |
| **Agent** | `agent-auth` | `AuthMiddleware + MachineBindingMiddleware` | `POST /api/v1/agents/:id/logs` | Requires JWT + correct machine ID |
| **Agent** | `agent-auth` | `AuthMiddleware + MachineBindingMiddleware` | `POST /api/v1/agents/:id/rebind-machine-id` | Admin-initiated machine rebind |
| **Agent** | `agent-auth` | `AuthMiddleware + MachineBindingMiddleware` | `GET /api/v1/downloads/updates/:package_id` | Download signed agent packages |
| **Web** | `web-auth` | `WebAuthMiddleware` | `GET /api/v1/dashboard/*` | Admin dashboard |
| **Web** | `web-auth` | `WebAuthMiddleware` | `GET /api/v1/agents/*` | Agent management |
| **Web** | `web-auth` | `WebAuthMiddleware` | `GET /api/v1/settings/*` | Settings pages |
| **Web** | `web-auth` | `WebAuthMiddleware` | `GET /api/v1/updates/*` | Update management |
| **Web** | `web-auth` | `WebAuthMiddleware` | `GET /api/v1/docker/*` | Docker integration |
| **Web** | `web-auth` | `WebAuthMiddleware` | `GET /api/v1/history/*` | History tracking |
| **Admin** | `admin-only` | `WebAuthMiddleware + RequireAdmin()` | `POST /api/v1/admin/*` | Admin-only operations |
| **Admin** | `admin-only` | `WebAuthMiddleware + RequireAdmin()` | `DELETE /api/v1/admin/agents/:id` | Delete agent (BUG-013 fix) |
---
## Trust Boundary Details
### Public Trust Boundary
**Endpoints:**
- `GET /api/v1/install/:platform?token=<reg_token>&arch=<arch>`
- `GET /api/v1/downloads/:platform?version=<ver>`
- `POST /api/v1/agents/register`
- `POST /api/v1/agents/renew`
**Security Notes:**
- Rate-limited per-IP via `RateLimit("public_access", KeyByIP)`
- Registration tokens are one-time use
- Download endpoint has BUG-003: signature header not set when `version="latest"`
- `/renew` is deliberately on the public route group, not behind `AuthMiddleware`: the agent calls it *because* its JWT has expired, so requiring a valid JWT to renew would be circular. It authenticates with the refresh token in the body instead. It is **not** trust-free, though — the handler reloads the agent and requires `X-Machine-ID` to match the bound machine. This closes the gap where a refresh token (a long-lived on-disk secret) would otherwise mint access tokens from any machine for 90 days.
**Refresh-token rotation + reuse detection — IMPLEMENTED (migration 045, 2026-05-29).** Each refresh token belongs to a *family* (`family_id`) and carries `consumed_at` + `superseded_by`. The state machine in `RenewToken` (`server/internal/api/handlers/agents.go`):
| Presented token state | Action |
|---|---|
| not found | 401 invalid |
| revoked, family still live | **revoke family** + security event → 401 (revoked-token replay is anomalous) |
| expired | 401 (bounded by the token's own 90d window) |
| unconsumed (`consumed_at IS NULL`) | normal rotation: mint successor, mark parent consumed, return new token |
| consumed, successor **unconsumed** | **accept-previous-once** grace: agent crashed before saving the successor (provably never used it) → orphan that leaf, mint a fresh one, return it |
| consumed, successor **consumed/revoked/missing** | **reuse detected** → revoke family + security event → 401 |
Grace is *structural*, not timed: "successor still unconsumed" is the discriminator, bounded by the parent's own 90d expiry. The agent persists the rotated token on each renewal (`loop.go`); a failed persist is recovered by the grace path on the next attempt. All revoke/reuse paths are loud (`LogUnauthorizedAccessAttempt`) and fail-closed — both the legitimate agent and any thief lose access, forcing deliberate human re-registration.
**Residual limitation (documented, not a TODO):** a *perfect same-machine lockstep shadow* — an attacker on the bound host who reads `config.json` and renews in exact alternation with the legit agent — is not detectable by rotation alone, because every token is used exactly once per party and the chain never diverges. This is inherent to all refresh-token rotation. It is mitigated by machine binding (the outer gate: a different host → 403 before rotation runs) and is out of scope for rotation; a same-host root attacker has already won at the OS layer.
**Still human-gated (unchanged):** **admin import / re-grant** — a one-time token authorizing exactly one rebind/registration when an operator moves an agent's identity to new hardware. Deliberately not automated (Casey: "human gated today" — revisit only if/when agent sophistication warrants, ~not near-term). Distinct from accept-previous-once, which is automatic crash-recovery internal to rotation.
Ties to SEC-012 (renewal atomicity): the server side is now fully transactional; the agent↔server two-phase (server commits rotation / agent persists token) is reconciled by the grace path rather than true cross-network atomicity.
**Cross-references:**
- [flows/01-registration](../flows/01-registration.md) (registration flow)
- Agent-upgrade download endpoint (detailed design record is not included in this public cut)
---
### Agent Trust Boundary
**Middleware Chain:**
1. `AuthMiddleware` — Validates JWT with issuer `"redflag-agent"`
2. `MachineBindingMiddleware` — Validates X-Machine-ID matches DB
**Security Notes:**
- JWT expires after 24 hours
- Refresh token extends expiry to 90 days
- Machine ID mismatch returns 403 Forbidden
- Agent row deletion returns 401 Unauthorized
- **Download endpoint** (`GET /api/v1/downloads/updates/:package_id`) requires machine binding to prevent any authenticated agent from downloading any package
**Cross-references:**
- [security/02-authentication-stack](02-authentication-stack.md) (JWT validation)
- [security/04-machine-binding](04-machine-binding.md) (machine ID validation)
---
### Web Trust Boundary
**Middleware:** `WebAuthMiddleware` — Validates JWT with issuer `"redflag-web"`
**Security Notes:**
- JWT expires after 24 hours
- Requires `admin` role claim
- Admin-only routes use `AdminRoleMiddleware`
**Cross-references:**
- [security/02-authentication-stack](02-authentication-stack.md) (web JWT)
---
### Admin Trust Boundary
**Middleware Chain:**
1. `WebAuthMiddleware` — Validates JWT with issuer `"redflag-web"`
2. `RequireAdmin()` — Checks `admin` claim is true
**Security Notes:**
- Can delete agents (was BUG-013: was registered under agent-auth group)
- Can revoke agent tokens
- Can trigger machine rebind
**Cross-references:**
- [security/03-refresh-tokens](03-refresh-tokens.md) (token revocation)
---
### Session Broker Trust Boundary
A **separate privileged Rust binary** spawned on demand for Tier 4 (break-glass /
interactive) sessions. The broker has its own trust boundary, its own network connection
to the server, and its own audit trail. It is not a sub-component of the agent or helper.
**Spawn mechanism:** agent writes a root-owned tmpfile with the minted grant, then
spawns the broker via `sudo systemd-run --pipe --property=ProtectSystem=no
redflag-broker --grant <tmpfile>`. The broker reads the grant once, verifies the
Ed25519 signature independently against the pinned keyring, deletes the tmpfile, and
opens its own WebSocket/gRPC connection to the server.
**Security Notes:**
- Grant is scoped: one agent, one operator, one scope (shell/desktop/script)
- Grant is time-boxed: hard ceiling enforced by the broker, not advisory
- Every command input is hash-chained in an append-only audit log
- Session end: broker signs the audit chain, emits a receipt, exits
- Agent is out of the loop after spawn — cannot influence broker execution
- No package operations — that's the helper's job; no persistent connections
- **Requires RBAC** for grant minting — design complete, gated behind RBAC substrate
**Cross-references:**
- Session broker (detailed design record is not included in this public cut)
- [components/04-helper](../components/04-helper.md) (sibling binary, same spawn pattern)
---
### Local Trust Boundary (agent localapi)
Not a server HTTP boundary — this one lives on the **agent host**. The agent exposes a
local-only API for the desktop tray app over a Unix socket
(`/var/lib/redflag/agent/localapi/redflag-agent.sock`) on Linux and a named pipe
(`\\.\pipe\RedFlagAgentLocal`) on Windows.
**Endpoints** (`agent/internal/localapi/server.go`): `/v1/identity`, `/v1/status`,
`/v1/scans/latest`, `/v1/packages`, `/v1/tokens/active`, `/v1/desktop` (tray health
report), `/v1/actions/trigger-scan`, `/v1/actions/approve-update`.
**Authentication is the operating system, not credentials.** There are no tokens on
this surface by design — access is gated by filesystem permissions:
- Socket directory `0750`, socket `0660`, both group-owned by `redflag-local`
(`agent/internal/localapi/listener_unix.go`).
- The installer creates the group, enrolls the agent user, and enrolls the detected
desktop user (`linux.sh.tmpl` step 7c).
- A user not in `redflag-local` gets `EACCES` at connect — the kernel is the
middleware.
**Known sharp edge:** group membership is stamped onto a login session at login.
Adding a user to `redflag-local` does not grant running sessions access; the user must
log out and back in. The desktop app diagnoses this case explicitly (in-group-on-disk
vs in-group-in-session, `desktop/src/main.rs`) instead of surfacing a raw permission
error. Installs that predate the desktop-user enrollment step never ran it; the
upgrade path re-asserts socket-chain ownership but does not re-check membership
(tracked: INSTALL-001 post-install healthcheck).
**Cross-references:**
- [components/05-desktop](../components/05-desktop.md) (the only intended client)
---
## Anti-Pattern: BUG-013
**Problem:** `DELETE /api/v1/agents/:id` was an admin operation registered under the agent-auth group with `MachineBindingMiddleware`.
**Symptom:** Admin request returned 401 Unauthorized (missing X-Machine-ID) because admin JWT doesn't have machine binding.
**Fix:** Moved endpoint to `admin-only` group with `WebAuthMiddleware + AdminRoleMiddleware`.
**Cross-references:**
- [core/01-ethos](../core/01-ethos.md) (principle #2: Security is Non-Negotiable)
- [flows/02-command-execution](../flows/02-command-execution.md) (polling loop)
---
## Structural Enforcement: Boot-Time Route Audit
BUG-013 was a one-off fix; the route audit (`server/internal/routeaudit/`) is the
structural answer to that class. At startup the server walks every registered route
in the Gin engine and verifies each handler chain carries the auth middleware its
trust boundary requires. Any route that lacks auth and is not on the explicit public
allowlist refuses boot: `[CRITICAL] route_missing_auth`, exit 1. An unauthenticated
endpoint cannot ship by omission — it can only exist as a reviewed line in
`PublicPathSet`.
**Classification is by code-pointer identity, not symbol name.** Each trust boundary
has exactly one middleware instance, created once in `main`, used at every route, and
registered with the auditor (`RegisterAuth`). Name-based matching was tried and
retired: compiler inlining renames closure symbols (missing real middleware), and
substring matching can silently accept a colliding name as an auth boundary. Pointer
identity over shared instances fails loudly in the safe direction — a stray fresh
constructor call or unregistered wrapper flags its routes at boot instead of passing
them silently.
**Cross-references:**
- [core/01-ethos](../core/01-ethos.md) (principle #2: no unauthenticated endpoints)
---
## Doctrine: Pull-Only Agent Channel
The agent↔server control channel is **pull-only**. The agent polls; the server never
opens a connection to an agent and never pushes commands at one. This is doctrine
(Casey, 2026-06-10), not a configuration choice — same tier as signing-required and
forward-only.
**Why:** a push channel is a standing inbound control path on every endpoint. Pull keeps
the agent in charge of when it listens, and keeps the server compromise blast radius
bounded by what agents choose to fetch and verify.
**Consequences:**
- Reject designs that assume server-initiated delivery: live-query campaigns,
push-config, server-side websockets to agents.
- A websocket/push channel is a *maybe later*, and **not until it is PQC-ready**
(post-quantum cryptography). Until then, latency wants are served by rapid-mode polling.
- Server→*operator-owned third party* outbound emits (SIEM, asset DB — see
`docs/tasks/INTEG-001`, `INTEG-002`) are a different channel and unaffected: outbound,
no listener, no control surface.
---
## Cross-References
- **Auth layers** → [security/02-authentication-stack](02-authentication-stack.md)
- **Refresh tokens** → [security/03-refresh-tokens](03-refresh-tokens.md)
- **Machine binding** → [security/04-machine-binding](04-machine-binding.md)
- **Registration** → [flows/01-registration](../flows/01-registration.md)
- **Command execution** → [flows/02-command-execution](../flows/02-command-execution.md)
---
*Last reviewed: 2026-06-14*

View file

@ -0,0 +1,205 @@
# Authentication Stack
**Four-layer authentication: registration tokens → JWT → refresh tokens → machine binding.**
---
## Layer 1: Registration Tokens
**Purpose:** One-time enrollment tokens
**Format:** Random 64-character hex string
**Lifecycle:**
1. Server generates token with `max_seats` count
2. Admin distributes token to operators
3. Agent uses token to register
4. Server marks token as used and increments `seats_used`
5. Token is revoked after use
**Endpoint:** `POST /api/v1/agents/register`
**Request:**
```json
{
"hostname": "server-01",
"os_type": "linux",
"os_version": "6.19",
"machine_id": "sha256-fingerprint...",
"public_key": "ed25519-public-key...",
"available_scanners": ["apt", "dnf", "docker"]
}
```
**Response:**
```json
{
"agent_id": "uuid-4",
"jwt_token": "...",
"refresh_token": "...",
"server_public_key": "..."
}
```
**Cross-references:**
- [flows/01-registration](../flows/01-registration.md) (registration flow)
- [security/03-refresh-tokens](03-refresh-tokens.md) (refresh tokens)
---
## Layer 2: JWT Access Tokens
**Purpose:** Short-lived access tokens for API calls
**Issuer:** `"redflag-agent"` for agents, `"redflag-web"` for web
**Duration:** 24 hours
**Algorithm:** HS256
**Claims:**
```json
{
"sub": "agent-uuid",
"iss": "redflag-agent",
"exp": 1234567890,
"iat": 1234567890
}
```
**Validation:**
```go
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format"})
c.Abort()
return
}
token, err := jwt.ParseWithClaims(tokenString, &AgentClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(JWTSecret), nil
})
if err != nil || !token.Valid {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
c.Abort()
return
}
if claims, ok := token.Claims.(*AgentClaims); ok {
// Validate issuer to prevent cross-type token confusion
if claims.Issuer != "" && claims.Issuer != JWTIssuerAgent {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token type"})
c.Abort()
return
}
c.Set("agent_id", claims.AgentID)
c.Next()
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"})
c.Abort()
}
}
}
```
**Cross-references:**
- [security/01-trust-boundaries](01-trust-boundaries.md) (trust boundary matrix)
- [flows/02-command-execution](../flows/02-command-execution.md) (polling loop)
---
## Layer 3: Refresh Tokens
**Purpose:** Long-lived authentication for polling, with rotation and reuse detection
**Format:** 64-character hex string (32 bytes crypto/rand)
**Storage:** SHA-256 hash in database; rotation lineage via `family_id`, `consumed_at`, `superseded_by` (migration 045)
**Duration:** 90 days (bumped on each renewal, not on every check-in)
**Lifecycle:**
1. Generated at registration with a fresh `family_id` (root of the rotation chain)
2. Agent calls `POST /renew` only when its JWT expires (~24h) — not on every poll
3. Each renewal mints a **successor** token in the same family and marks the parent `consumed`
4. Server returns the new refresh token alongside the new JWT; agent persists it to `config.json`
5. Accept-previous-once grace: an agent that crashed before persisting the new token can retry with the consumed old one — the server sees the successor is still unconsumed and re-issues
6. Reuse detection: a consumed token presented after its successor is also consumed → entire family revoked, security event logged, both parties locked out
**Machine binding:** Renewal requires `X-Machine-ID` to match the registered host (same as command endpoints). A stolen `config.json` cannot mint access tokens from an unregistered machine.
**Instance lock:** A `flock` (Unix) or named kernel mutex (Windows) prevents two agent processes from sharing the same `config.json` on the same host, serializing renewal at the process level.
**Endpoint:** `POST /api/v1/agents/renew`
**Request:**
```json
{
"agent_id": "uuid",
"refresh_token": "64-char-hex",
"agent_version": "0.2.0.7"
}
```
Headers: `X-Machine-ID` (required), `Content-Type: application/json`
**Response:**
```json
{
"token": "new-jwt...",
"refresh_token": "new-64-char-hex..."
}
```
The agent must persist `refresh_token` to disk; if it crashes before doing so, accept-previous-once grace recovers on the next attempt.
**Cross-references:**
- [flows/01-registration](../flows/01-registration.md) (registration flow)
- [flows/02-command-execution](../flows/02-command-execution.md) (renewal in polling loop)
---
## Layer 4: Machine Binding
**Purpose:** Bind JWT to specific hardware
**Method:** SHA-256 hash of machine-id + hostname (no boot-id)
**Validation:** Middleware checks `X-Machine-ID` header matches DB
**Failure modes:**
- Machine ID mismatch → 403 Forbidden
- Agent row deleted → 401 Unauthorized
- Update in progress → Validates nonce
**Middleware:** `server/internal/api/middleware/machine_binding.go`
**Cross-references:**
- [security/01-trust-boundaries](01-trust-boundaries.md) (trust boundary matrix)
- [flows/01-registration](../flows/01-registration.md) (machine ID generation)
---
## Footer: Assumptions & Connections
**Assumption:** Trust On First Use (TOFU) model — agent caches server public key at registration and uses it for all future verification.
**Connection:** [security/01-trust-boundaries](01-trust-boundaries.md) (trust boundary matrix)
**Connection:** [security/04-machine-binding](04-machine-binding.md) (hardware-bound auth)
**Connection:** [verification/01-signing-pipeline](../verification/01-signing-pipeline.md) (Ed25519 signing)
**Connection:** [verification/02-agent-verification](../verification/02-agent-verification.md) (command verification)
---
*Last reviewed: 2026-06-14*

View file

@ -0,0 +1,70 @@
# Refresh Token Lifecycle
**Forward-only token rotation with family revocation — a stolen config is a dead config.**
---
## Overview
Agents authenticate with short-lived JWTs minted against a long-lived refresh token (90-day TTL). Every renewal *rotates* the refresh token: a successor is minted, the old token is marked consumed. The rotation lineage is the security mechanism — replaying a consumed token is how theft announces itself.
**Cross-references:**
- [security/02-authentication-stack](02-authentication-stack.md) (where this sits in the four-layer stack)
- [security/04-machine-binding](04-machine-binding.md) (the renewal endpoint is machine-bound)
---
## Data Model
Migration 045. Each token row carries:
| Column | Purpose |
|--------|---------|
| `family_id` | Lineage identifier — all rotations of one registration share it |
| `consumed_at` | Set when the token is exchanged for a successor |
| `superseded_by` | Points at the successor token |
Tokens are stored hashed (`HashRefreshToken`), never plaintext. Queries live in `server/internal/database/queries/refresh_tokens.go`.
---
## The Renewal Flow
`RenewToken` (`server/internal/api/handlers/agents.go`):
1. **Machine binding first.** `X-Machine-ID` is checked against the registered host before any token logic. Mismatch → `403` + `MACHINE_ID_MISMATCH` security event. A stolen `config.json` replayed from another machine never reaches rotation.
2. **Locked read.** `GetRefreshTokenForRenew` uses `SELECT ... FOR UPDATE` — two concurrent renewals with the same token cannot both succeed.
3. **Classify the presented token:**
| State of presented token | Verdict | Action |
|--------------------------|---------|--------|
| Unconsumed, unexpired | Normal renewal | Mint successor, mark consumed |
| Consumed, successor **unconsumed** | Crash-recovery grace | Agent saved the old token but died before persisting the new one. Accept once; issue a fresh successor |
| Consumed, successor **also consumed** | Reuse = theft | Revoke the entire `family_id`, log security event, return terminal error |
The grace window is **accept-previous-once** — exactly one step back in the lineage, exactly once. Forward-only is doctrine ([core/01-ethos](../core/01-ethos.md)); there is no knob to widen it.
---
## Agent Side
- Terminal sentinel errors (`ErrRefreshTokenInvalid`, `ErrMachineMismatch`) stop the polling loop's retry machinery — these are not transient network failures and are logged `[CRITICAL]`. See [components/02-agent](../components/02-agent.md).
- The **instance lock** exists largely for this mechanism: two agent processes sharing one `config.json` would race rotations and trip family revocation on themselves. One config, one process, enforced by flock/mutex.
---
## Operational Notes
- Revoked family → agent must be re-registered with a fresh registration token. Runbook: `OPERATIONS.md §2`.
- After a database restore, agents may present tokens the restored DB has never seen (or sees as stale lineage). Expect re-registration; see `OPERATIONS.md §3`.
- `CleanupExpiredTokens` reaps expired rows; `RevokeAllAgentTokens` is the operator hammer.
---
## Why This Shape
A refresh token in a file on a fleet machine *will* eventually leak — backup snapshots, copied VMs, sloppy decommissioning. Rotation-with-family-revocation means a leaked token is only useful until the legitimate agent next renews, and *using* a stale one burns the whole family loudly. The failure mode is detection, not silent coexistence.
---
*Last reviewed: 2026-06-14*

View file

@ -0,0 +1,119 @@
# Machine Binding
**Hardware-bound authentication for agent-to-server communication.**
---
## Overview
RedFlag ties each agent to specific hardware via a machine ID fingerprint. This prevents config file theft from being used on unauthorized machines.
---
## Machine ID Generation
**Linux:**
- Uses `machineid` library to compute SHA-256 hash
- Fallbacks: `/sys/class/dmi/id/product_uuid`, `/var/lib/dbus/machine-id`
- Combined with hostname for uniqueness
**Windows:**
- Uses `MachineIdentifier` class from Windows API
- Retrieves system-wide hardware identifier
**macOS:**
- Uses IOKIT framework to read hardware identifiers
- Combines multiple hardware sources for uniqueness
**Implementation:**
- `agent/internal/system/machine_id.go` (uses `machineid` library — Linux dbus/machine-id, macOS IOKIT, Windows registry)
---
## Binding Flow
```
1. Agent computes machine ID at startup
2. Agent includes X-Machine-ID header in requests
3. Server validates against stored machine ID
4. Mismatch → authentication failure
5. Match → request proceeds
```
**Middleware:**
- `server/internal/middleware/machine_binding.go:MachineBindingMiddleware()`
---
## Binding States
| State | Description | Trigger |
|-------|-------------|---------|
| `registered` | Machine ID stored, valid | Registration complete |
| `pending` | Hardware change detected | Machine ID mismatch |
| `unbound` | No machine ID provided | Missing header |
| `revoked` | Admin action | Manual revocation |
---
## Rebinding Endpoint
**Purpose:** Allow legitimate hardware changes.
**Endpoint:** `POST /api/v1/agents/:id/rebind`
**Requirements:**
- Admin authentication (WebAuthMiddleware)
- Valid nonce for rebind operation
- Machine ID update logged to audit trail
**Flow:**
```
1. Admin approves rebind request
2. Server updates machine ID for agent
3. Event logged to history table
4. Agent can resume normal operations
```
**Implementation:**
- Handler: `server/internal/handlers/agents.go:RebindAgentMachineID()`
- Validation: Nonce + admin auth + machine ID update
---
## Security Considerations
### Machine ID Spoofing
- Attacker cannot forge valid X-Machine-ID without hardware access
- Server-side validation prevents spoofed headers
- Binding checked on every authenticated request
### Hardware Changes
- SSD replacement → new machine ID
- Motherboard swap → new machine ID
- Cloud instance restart → same machine ID (persistent storage)
**Mitigation:** Rebind flow for legitimate changes
### Key Compromise
- Stolen agent config + machine ID = unauthorized access
- Mitigation: Machine binding ties config to hardware
- Mitigation: Key rotation limits exposure window
---
## Footer: Assumptions & Connections
**Assumption:** Machine ID is stable for the lifetime of the hardware configuration.
**Connection:** Machine binding (`security/04-machine-binding.md`) implements ETHOS #2 (security is non-negotiable).
**Connection:** Rebinding endpoint (`security/04-machine-binding.md`) complements nonce validation (`verification/04-replay-protection.md`).
**Connection:** Machine binding middleware (`server/internal/middleware/machine_binding.go`) enforces binding on agent routes.
**Connection:** TOFU model (`verification/02-agent-verification.md`) works with machine binding for trust continuity.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,499 @@
# Supply Chain Gate
**Package-manager authorization with signed capabilities and mutation envelopes; pacman has full local artifact custody, while network isolation and kernel enforcement remain design work.**
This is the design of record for the gate — the capability model, the wire contract all
three components agree on, the load-bearing constraints, and component responsibilities.
Build status and per-step implementation tracking live in `docs/tasks/GATE-000-supply-chain-gate-plan.md`.
---
## Overview
RedFlag's supply chain gate inverts the traditional defense model: instead of **allow all installs and detect bad ones**, it **denies all state changes and requires explicit human authorization**.
**Current source scope:** APT and DNF mutation through RedFlag uses a signed capability
token. Standalone pacman approval uses a signed `MutationEnvelope`, exact closure archives,
detached package signatures, helper custody, and a joined receipt. Docker, Winget, and
Windows Update still use the signed-command path. Fleet pacman envelope delivery and kernel
enforcement against out-of-band root mutation are not wired.
---
## The decision: capability tokens, not a decision daemon
The gate authorizes APT/DNF installs with **signed capability tokens** and pacman with a
**signed mutation envelope**, not with a runtime decision daemon. In fleet mode the Server
is the authority: it evaluates policy and mints an Ed25519-signed capability over the
artifact entries the Agent resolved and reported. In standalone mode the short-lived root
helper mints locally after validating the bounded request; this preserves a privilege
boundary, not the fleet's off-host authority boundary.
APT/DNF still require the top-level hash while dependency hashes are best-effort: an
unresolved dependency is logged and omitted rather than making the report fail. Pacman is
stricter. The Agent resolves and downloads the transaction from signed repository metadata,
and the helper requires an exact archive and detached signature for every action before it
will sign or execute.
A small, privileged **executor** (`helper/`, Rust) validates token version and time, host
binding, signature, and replay state, then runs a fixed argv plan without a shell or inherited
environment. It rehashes local artifact files and requires a readable matching file for a
mirror entry. A normal registry entry without a local path is not rehashed helper-side, and
the current transient unit retains host network access.
Pacman now resolves and stages the transaction and rehashes every byte at the privileged
edge. APT/DNF full-closure custody and a network-isolated helper unit remain explicit design
work, not present guarantees.
This replaced the earlier `rs-helper` socket-decision daemon. `rs-helper`'s reusable parts
(the eBPF `InterceptEvent` struct, the package-manager allowlist, the hash cache) moved into
the unprivileged agent-side consumer. Its *role* as a runtime allow/deny RPC is retired.
### Why this model (two tests it has to pass)
**Cross-platform.** Linux eBPF and macOS ESF can pause an exec and ask a daemon "may this
proceed?" Windows WDAC cannot — it is signature-based, with no runtime callback. A
decision-daemon model therefore has no Windows mapping. A capability token maps onto all
three identically: in every case the enforcement layer only needs to answer "is this
execution authorized," and a verified token + a privileged executor that the OS trusts is
platform-agnostic. We build for the platform with the tightest constraint.
**Protects people.** Protection comes down to where the trust root lives. The signing key
lives at the server (the human-approval authority), off the host. An attacker who fully owns
the agent process — a prompt-injected coding agent, the literal threat — can *request* a
token but cannot forge the server's signature, so the executor never runs. The trust root is
outside the blast radius. A socket-RPC daemon degrades to "can the attacker reach the socket
or influence what's pinned," which a compromised agent tier often can.
The two tests converge on the same answer, which is the signal it's right.
---
## Architecture
### Enforcement Layers (Platform-Specific)
| Platform | Enforcement Mechanism | What It Blocks |
|----------|----------------------|----------------|
| **Linux** | eBPF (syscalls/sys_enter_execve) or AppArmor | apt, dnf, yum, pip, npm, bun, docker (CLI) |
| **Windows** | WDAC (Windows Defender Application Control) | winget, npm.cmd, pip.exe, choco, scoop |
| **macOS** | Endpoint Security Framework (ESF) | brew, pip, npm, bun, cargo |
**Target distinction:** kernel enforcement sits below userspace wrappers. The current eBPF
scaffold is not connected to the capability model, so RedFlag does not yet claim that an
out-of-band package-manager invocation is blocked.
### Trust Chain
```
┌─────────────────────────────────────────────────────────────┐
│ Human Operator │
│ - Reviews UI prompt with OSV findings, age checks, etc. │
│ - Clicks "Approve" or "Reject" │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────────┐
│ RedFlag Server (authority, unprivileged) │
│ - Resolves the closure, records/fetches per-artifact hash │
│ - Runs OSV.dev check + package-age gates │
│ - Mints an Ed25519-signed capability token (signer off the │
│ request path) │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────────┐
│ RedFlag Agent (consumer, unprivileged) │
│ - Polls for the token, confirms agent_id is this host │
│ - Holds no signing key; cannot run installs directly │
│ - Hands the token to the executor │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────────┐
│ Helper / Executor (privileged, short-lived, Rust) │
│ - Invoked via `sudo systemd-run --wait` with token/result │
│ files; escapes the agent's ProtectSystem sandbox │
│ - Verifies version/time/host/signature/replay state │
│ - Rehashes local files; registry entries may have no path │
│ - Executes fixed argv: no shell, env stripped │
│ - Current unit retains host network access │
└─────────────────────────────────────────────────────────────┘
```
---
## The token (the contract all three sides agree on)
This is the canonical wire contract. The Go (`server/`, `agent/internal/capability/token.go`)
and Rust (`helper/src/main.rs`) implementations reconstruct identical bytes from it.
```jsonc
{
"version": 1,
"token_id": "<uuid>", // unique; replay guard + receipt/audit
"agent_id": "<uuid>", // bound to exactly one host
"key_id": "<hex, 32 chars>", // authority key fingerprint (rotation)
"package_type": "apt|dnf|npm|bun|pip|docker|winget|agent-self",
"operation": "install|upgrade", // forward-only; no downgrade
"closure": [ // signed reported set; target is the full closure
{
"name": "<pkg>",
"version": "<exact>",
"sha256": "<hex>", // expected artifact hash
"source": "mirror|registry",
"artifact_path": "<local path or url, optional>"
}
],
"issued_at": <unix>,
"not_before": <unix>,
"expires_at": <unix>, // short TTL
"signature": "<hex ed25519>" // over the canonical message below
}
```
**Canonical signed message** (deterministic, language-agnostic — mirrors the existing v3
command format so Go and Rust reconstruct identical bytes):
```
closure_hash = hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) ))
signed_message = "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}"
signature = ed25519_sign(authority_priv, signed_message)
```
The closure is sorted before hashing so ordering can't change the digest. Tampering with any
artifact, version, or hash changes `closure_hash` and breaks verification.
### Mutation manifest contract (live for standalone pacman)
The closure token remains live for APT and DNF. Pacman is the first runtime backend migrated
to `MutationEnvelope`, so new backends can converge without extending the closure metaphor
or preserving its unsigned `source`/`artifact_path` ambiguity.
Those two legacy unsigned fields steer capability-token *verification* today, not helper
execution:
`build_plan` reads only name, version, package type, and operation, and the self-update
branches take their source path from a helper constant. The server never populates
`artifact_path` and the agent reports `source=registry`, so the mirror branch is currently
unreached. Pacman's envelope avoids that defect: repository, requested-root marker, cache
locations, archive hash, and signature hash all live inside each signed backend payload.
The envelope carries two objects, and the executor answers with a third:
- `MutationManifest`: format, operation ID, target ID, backend, operation kind,
backend-owned resolved-action payloads, and provenance/evidence digests.
- `MutationAuthorization`: manifest hash, authority kind/identity, target ID, issue and
validity times, decision, key ID, and Ed25519 signature.
- `MutationReceipt`: the response half — the operation/manifest/authorization join, the
decision and typed reason, exit code, verified-action count, and timestamp. It is
**not signed**: the executor records what it did inside a boundary that already trusts
it, and is not made a second authority by writing a receipt.
**`target_id` is the RedFlag `agent_id`.** Both copies are signed and a verifier requires
them equal; the executor compares them with the identity it reads for itself from a
root-owned SEC-021-validated file, exactly as it does for a closure token today. The field
name is generic so a later protocol may define another target namespace deliberately —
there is no second namespace today and no `body_id`.
Two shape rules bind the authority rather than the executor. `authorization_id` must be a
canonical UUID v4, the discipline standalone mint already applies to `request_id`, fixed
before that identifier becomes a replay key. And `expires_at - not_before` may not exceed
3600s — `DefaultTokenTTL`, the fleet minter's own window, enforced at signing as well as
at verification.
The manifest hash covers the exact UTF-8 JSON bytes of every backend payload. Fetch/cache
location therefore cannot change outside the signed object. Provenance evidence is a
separate field: a producer or signed-repository claim is not an execution location.
Canonical records are domain-separated and length-prefixed. Resolved actions and evidence
are sorted by canonical bytes, while exact duplicates are retained and change the hash. The
outer action collection is therefore an unordered multiset; a backend that needs ordered
steps must encode their order inside one backend payload. Unknown format values fail closed.
The canonical encoding is specified in `protocol/README.md`. One shared fixture under
`protocol/testdata/` pins canonical manifest bytes, manifest hash, authorization bytes, key
ID, signature, and receipt bytes/digest across Server, Agent, and helper. Tamper tests cover
provenance, execution location, target, backend, resolved action, duplicates, ordering,
authorization metadata, validity, decision, identifier shape, lifetime ceiling, and unknown
formats.
`redflag-helper verify-envelope` remains an inspection-only compatibility path: it proves
the common envelope and refuses without consuming replay state. `mint-envelope` is the
standalone pacman authority. It requires exactly one requested root, detached signatures,
strict operation semantics (`upgrade` moves that root forward; `install` introduces it),
and non-decreasing dependencies. `execute-envelope` repeats identity, hash, signature, and
version checks over a fresh root stage, atomically claims the authorization, and runs one
fixed pacman plan without `--needed`.
### Reuse, don't reinvent
The token extends the existing Ed25519 infrastructure rather than introducing new crypto:
- `server/internal/services/signing.go``SigningService` (Ed25519, `GetPublicKeyHex`,
`GetCurrentKeyID` = SHA-256(pubkey)[:16] hex). The token is a new payload it signs.
- `server/internal/database/queries/signing_keys.go` — key storage + rotation/version.
- `agent/internal/crypto/verification.go` — verifies against active keys; v3 message format
`{agent_id}:{id}:{type}:{sha256(params)}:{ts}`. The token mirrors this.
- `agent/internal/client/client.go::GetActivePublicKeys` — already "verify keys not servers."
---
## Component responsibilities
- **Server (fleet authority).** For dnf/apt, persist the agent-reported resolved entries, run OSV
over that set, and mint a host-bound token after the approval boundary. The top-level hash
is required, but the current agent may omit a dependency whose hash did not resolve. Full
transitive resolution, the mirror tier, and signer process isolation remain target work.
- **Agent consumer (unprivileged).** Run discovery and resolution, receive or request the
capability, confirm `agent_id` is this host, and hand it to the executor. It holds no
signing key and has no direct package-manager mutation method. For pacman it owns a private
sync database/cache and retains those files until mint and execution finish.
- **Executor (`helper/`, privileged, Rust).** Verify validity window → resolve trusted key by
`key_id` from a local pinned keyring → reconstruct `signed_message` → Ed25519 verify →
rehash each locally available artifact (and require mirror paths) → build a fixed argv plan
→ replay-guard on `token_id` → exec without a shell or inherited environment → structured
result + exit code. For pacman it also stages archives and signatures into root custody,
verifies the local Arch keyring, and refuses downgrades or operation-name lies. Legacy
registry entries without local paths are not rehashed. The current unit is not
network-isolated.
- **Kernel layer (where applicable).** Linux eBPF / Windows WDAC / macOS ESF deny
package-manager execution except via the trusted executor. This is defense-in-depth design;
the present eBPF scaffold is not wired to the capability model.
---
## Load-bearing constraints (target invariants)
Current deviations are named above and below. These constraints describe the boundary the
system is meant to reach; they must not be presented as deployed enforcement until code and
runtime evidence support them.
1. **Sign the resolved closure, not the top-level package.** Aggregate updates and the
scheduler chaining dependencies mean the token must cover every transitive artifact and
its hash. Authorizing only the top-level reopens the gap where the modern attacks live.
Resolve-and-hash-the-closure is also the mirror's real security job; air-gapping from the
registry is the bonus.
2. **The signer lives off the web process.** Server-as-authority plus server-as-mirror
concentrates blast radius. The signing key must not be reachable from the request path
(separate signer service / key material not loaded in the API process), so a web
compromise cannot both mint tokens and serve artifacts.
3. **Verified-cache fallback, fail-closed only on change.** When installs route through the
mirror, an already-approved-and-hashed artifact must still install from local cache if the
server blinks. Fail closed on *new change*, not on a brief outage of an already-authorized
operation.
4. **Verify keys, not servers.** The agent and helper trust *a public key (set)* identified by
`key_id` fingerprint — never a server URL. Today the key lives on your server; nothing
changes operationally. But the contract ("trust this key") lets the authority later be
rotated, replicated, or held by a federation/guild node without touching the agent↔helper
interface. This is the long-term cross-platform answer hidden in a one-line design choice.
5. **Kernel stops are defense-in-depth, not a prerequisite.** The capability model protects on
a host where eBPF/WDAC/ESF cannot be deployed (locked-down managed box, constrained
container). Kernel enforcement raises the cost of bypass; it does not gate whether the model
means anything. Partial deployment still moves a host out of the soft-target category.
6. **No doctrinal knobs.** Signing required and forward-only (no downgrade) are ETHOS doctrine,
not configurable. The token has no "skip verification" path.
---
## Gate Features
### 1. Version Pinning (Security Primitive)
**Normal model:** `npm install express` → resolves to `latest` → fetches from registry → installs
**RedFlag model today:** the exact version is resolved and its expected SHA256 is signed into
the capability. The helper enforces that hash when it receives a local artifact path. For a
normal registry entry without a local path, it fixes the package name/version in argv but does
not compare the fetched bytes with the signed hash; APT/DNF still relies on its signed
repository metadata. Making the installed artifact itself match the capability hash in every
case is the mirror-backed target.
The pin's hash source depends on who can reach the artifact:
- **npm / PyPI** — one canonical public registry exists, so the **server** fetches the
artifact and computes the hash directly at approval (`computeAndStorePackageHash`).
- **dnf / apt** — artifacts come from each agent's own GPG-signed repos, which the server
cannot reach. The **agent** resolves the canonical hash from its signed repo metadata at the
dry-run step and reports it (`installer.ResolveArtifactSHA256`: dnf via `dnf download`+SHA256,
apt via the `SHA256:` field of the signed index). The server pins what the agent reports.
Trust is anchored in the repo signature; the pin is set before the install-time compromise
the gate defends against.
> Historical note: an earlier draft of this doc specified a `ResolvePin` / `fetchAndHash`
> function and a `security_packages` table. Neither was built. The shipped registry is the two
> stores below. This section documents what exists.
### 2. Hash Registry (Layer 1)
Every name, version, and hash carried in a token is covered by its Ed25519 signature. That is
not the same as rehashing every installed byte. The helper's current verification boundary is:
- `source=mirror`: `artifact_path` is required; missing or mismatched bytes deny.
- Any entry with an existing local `artifact_path`: the helper rehashes it; mismatch denies.
- Normal `source=registry` with no local file: the hash remains signed into the token, but the
helper does not rehash the bytes APT/DNF later fetches.
The top-level APT/DNF hash is mandatory before the server stores a closure. Dependency hash
resolution is best-effort and unresolved entries can be omitted. Complete closure staging and
helper-side verification of every byte remain the intended mirror-backed end state.
**Storage (as built):**
- `current_package_state.expected_sha256` (migration 040) — the pinned top-level hash per
update. On normal registry-backed dnf/apt helper execution it is signed into authority but
not rehashed against fetched bytes.
- `capability_tokens` (migration 042) — the minted, signed token carries the reported resolved
set (per-artifact name/version/sha256/source) as JSONB. The token row *is* the closure
record; there is no separate `security_packages` table.
OSV findings, package age, and published-at are recorded on the update's own `metadata` JSONB
at approval, not in a dedicated table.
### 3. Local Mirror (Optional)
**Purpose:** Decouple fleet from upstream availability after approval.
**Flow:**
1. Operator approves update → server fetches artifact → stores in local mirror
2. Server issues approval token with artifact path in mirror
3. Agent fetches from mirror (not upstream) → verifies SHA256 → installs
**When to enable:**
- Large fleets (>100 agents) where redundant fetches are noisy
- Upstream availability is a concern
- Maximum isolation desired
**Configuration:** `security.package_mirror.enabled` (boolean)
### 4. Time Gates: Package Age + Version Soak (live policies, v0.2.6.2)
Two distinct time-based gates, both under the `supply_chain.*` settings category, both
resolving env → config → DB → default. These are *policies* (configurable, with enforcement
modes) — unlike capability-signature validation and required local-artifact checks, which
have no skip setting.
**Approval-time age gate** (`package_age.go`) — the Shai-Hulud defense. Packages younger
than the threshold draw a warning or a block at approval:
| Setting | Default | Meaning |
|---------|---------|---------|
| `min_package_age_hours` | 24 | Minimum publish age before approval is clean |
| `gate_enforcement` | `warn` | `warn` or `block` |
| `block_unknown_age` | `false` | Opt-in: under `block`, registry-backed ecosystems (npm, PyPI) fail closed when the publish date can't be determined — a dark recency source is itself a Shai-Hulud-class signal. Default off, by sovereignty: the operator chooses to fail closed on unknowns. |
**Install-time version-soak gate** (`soak_gate.go` — this is GATE-005, *not* the age gate):
| Setting | Default | Meaning |
|---------|---------|---------|
| `soak_window_days` | 14 | A version must soak this long before the install path will take it |
| `soak_enforcement` | `block` | `warn` or `block` |
### 5. OSV.dev Integration
Top-level vulnerability scanning begins at **detection time** (moved in v0.2.6.2). After
the dnf/apt dry-run report, `checkClosureAndAdvance` queries OSV for the resolved entries the
agent reported; verdicts persist to package metadata (`supply_chain_vulns`,
`supply_chain_checked_at`) and gate auto-confirm/minting. An unresolved dependency omitted
from the report is not checked by this path.
Approval *reads* the persisted verdict — it does not re-scan. Any known vulnerability
among the reported entries checked is a full stop (see Enforcement Posture below); there is
no severity threshold below which approval proceeds quietly.
**Standalone path resilience.** In standalone mode there is no server, so the agent
queries OSV.dev directly before requesting a mint (`agent/internal/supplychain/osv.go`).
That client retries transient failures (transport error, 5xx, 429) with exponential
backoff and trips a process-wide circuit breaker after a run of failures, fast-failing
to `unreachable` instead of hammering the endpoint (ETHOS #3, SEC-029). The verdict
semantics are unchanged and remain fail-closed: an exhausted retry or an open breaker
surfaces as `unreachable` — never a silent clear — and `unreachable` still gates the
mint behind an explicit operator override. The resilience only avoids turning a transient
OSV blip into a forced operator action.
### 6. SLSA/Sigstore Attestation (Visibility Signal)
**Not a hard block** — surfaced as a visibility indicator.
**UI:** When unpinning a package without attestation:
```
This package lacks SLSA provenance or Sigstore signature.
Consider waiting for an attested release before proceeding.
```
---
## Enforcement Posture (v0.2.3.1, extended v0.2.6.2)
Approval is an enforcement point, not advisory. A known vulnerability in any reported entry
that was checked — top-level or transitive — is a hard stop: `ApproveUpdate` returns `409` and
mints nothing. For capability-gated ecosystems, a reported closure that OSV could not check
(service unreachable) is also a stop. This does not claim coverage for a dependency omitted
because its artifact hash did not resolve.
The only path through is an explicit operator override with a documented reason. The override
waives the vulnerability judgment only — the signed token still binds the reported artifact
entries, and the executor still validates authority plus any local artifact paths. Every
override writes a `supply_chain_override` system event. Bulk approve carries no blanket
override: flagged updates come back in
`blocked[]` and must be approved individually.
Auto-confirm shares the `ClosureCleared` predicate with manual approval — the two paths cannot
drift on what counts as a clean closure.
Standalone pacman does not claim that predicate. RedFlag has no Arch OSV ecosystem mapping
in this path, so the local gate records `unsupported` and requires an explicit operator
reason. Archive hashes and Arch package signatures still remain mandatory; the reason waives
only the missing advisory coverage.
---
## Build sequence (lineage)
The order the gate is built in. Per-step *status* is tracked in
`docs/tasks/GATE-000-supply-chain-gate-plan.md`; this records the intended dependency order.
1. `helper/` executor + token contract (the keystone; defines the schema in code).
2. Go `capability` token type + canonical encoder + Ed25519 sign/verify (server & agent share
the definition; mirrored in both modules — no shared module exists).
3. Server: closure resolver + mint/sign at approval; signer off web process; endpoint to
deliver tokens to the agent.
4. Migration: store resolved closure + per-artifact hashes alongside the pinned state.
5. Agent consumer: accept token, bind-check, pass to executor; fold in `rs-helper` parts.
6. Mirror tier (optional): pull+hash closure at approval; verified-cache fallback.
7. Kernel adapters wire the executor as the only permitted caller.
Steps 15 produced the capability-token path. Pacman then exercised the envelope migration:
private resolution, local root mint, detached signatures, custody, strict forward semantics,
execution, and receipt are wired in source. Fleet envelope mint/delivery, the mirror tier,
and kernel adapters remain.
---
## Cross-References
- **Build status / implementation tracking**`docs/tasks/GATE-000-supply-chain-gate-plan.md`
- **Command signing**`verification/01-signing-pipeline.md`
- **Agent verification**`verification/02-agent-verification.md`
- **Replay protection**`verification/04-replay-protection.md`
- **Trust boundaries**`security/01-trust-boundaries.md`
---
## Footer: Assumptions & Connections
**Assumption:** The capability model is the floor; kernel-level primitives (eBPF, WDAC, ESF)
are defense-in-depth on top, not a prerequisite. Userspace wrappers alone are bypassable.
**Current:** The privileged executor has a narrow argv-only API, no shell, and a stripped
environment; the Agent that hands it capabilities is unprivileged and holds no signing key.
Pacman archives and detached signatures cross root custody before a fixed offline `pacman
-U`; legacy APT/DNF registry entries may still require network. The transient unit retains
host network access.
**Target:** Bring APT/DNF to the same full-custody envelope and enforce network isolation on
the helper unit. Neither property applies globally until each migrated backend proves it.
**Connection:** [security/01-trust-boundaries](01-trust-boundaries.md) (kernel enforcement as trust boundary)
**Connection:** [security/04-machine-binding](04-machine-binding.md) (agent identity verification)
---
*Last reviewed: 2026-09-01*
*Last reviewed: 2026-08-26*

View file

@ -0,0 +1,188 @@
# Standalone Authority (Local Mode)
Status: implemented for local Agent startup, scanning, APT/DNF capability approval,
and pacman envelope approval. Fleet join remains fail-closed and unfinished.
Companion to [05-supply-chain-gate](05-supply-chain-gate.md). Standalone changes where
authority lives; it does not create a pretend off-host boundary on one machine.
---
## The Boundary That Exists
Fleet mode has a real host boundary:
```
operator → Server authority → Agent consumer → root helper
```
Standalone has only a privilege boundary:
```
Desktop user → local Agent → fixed sudoers invocation → root helper and local key
```
The local Ed25519 private key is root-owned and unreadable by the Agent. That stops the
long-running Agent from copying or directly using key material, and the helper still
constrains every privileged action to a typed protocol. It does **not** make a compromised
Agent independent of the signer: the Agent is intentionally allowed to invoke the fixed
mint command and supplies the gate evidence the helper validates. The helper does not
re-run OSV or cryptographically prove that a human supplied the operator label.
The honest threat table is therefore:
| Property | Fleet | Standalone |
|---|---|---|
| Host-bound signed authorization | yes | yes |
| Replay protection | yes | yes |
| Fixed argv, cleared environment, no shell | yes | yes |
| Pacman archive hash, identity, and detached signature verified by root helper | yes when fleet envelopes land | yes |
| APT/DNF local artifacts rehashed when a closure entry names a path | yes | yes |
| Human authority survives Agent compromise | yes, authority is off-host | **no** |
| Signing key survives managed-host compromise | yes, authority is off-host | **no** |
| Out-of-band mutation by local root prevented | **no, kernel enforcement is not wired** | **no** |
| OSV judgment independently reproduced by helper | no | no |
Standalone still materially narrows mutation. It does not turn same-host signing into
proof that the host was uncompromised.
---
## Provisioned State
`scripts/provision-standalone-authority.sh` runs as root after the base Agent, helper,
`redflag-agent` user, and `redflag-local` group exist. It is idempotent and must never run
on a fleet-enrolled host.
Provisioning creates or verifies:
- a stable UUIDv4 in the Agent config via `redflag-agent --init-standalone`;
- the same UUID in root-owned `/etc/redflag/agent_id` for independent target binding;
- `/etc/redflag/authority_local.key`, root-owned `0600`;
- the public half in the helper's root-owned trusted keyring;
- Agent-owned exchange directories for mint requests, tokens, mutation requests,
envelopes, and receipts; and
- exact sudoers command shapes for legacy token mint, envelope mint, and envelope
execution.
`Config.IsStandalone()` is true only when a stable Agent ID exists and registration,
access, and refresh tokens are all absent. Partial fleet material is neither standalone
nor registered and startup refuses it.
The standalone Agent starts the local API and kernel monitor, scans on startup, and then
scans on its bounded local interval without contacting a Server. Pacman is included in
that local scanner set.
---
## APT and DNF Flow
```
Desktop
→ POST /v1/actions/approve-update
→ Agent dry-run and closure hash resolution
→ Agent OSV query over the reported closure
non-clear verdict requires a recorded reason
→ Agent writes MintRequest
→ root helper mint
validates host, operation, closure shape, evidence freshness, and reason
signs a short-lived capability with authority_local.key
→ normal Agent consumer
→ root helper verifies, replay-claims, and executes one fixed APT/DNF plan
→ PolicyResult returns to Desktop
```
Age and soak fields are recorded as `not_applicable`; standalone has no local registry
history for those policies yet. Registry artifacts without local paths remain signed in
the closure but are not rehashed helper-side, and the transient helper unit retains host
network access.
---
## Pacman Flow
Pacman does not use the legacy capability payload:
```
Desktop + required reason for unavailable OSV coverage
→ Agent resolves exact official-repository transaction in a private database/cache
→ Agent hashes every archive and detached signature
→ Agent writes EnvelopeMintRequest + MutationManifest
→ root helper mint-envelope
secure openat read from the fixed exchange directory
copies artifacts into root custody
verifies exactly one requested root, hashes, package identity, repository, detached signatures, and versions
requires an upgrade root to move strictly forward or an install root to be absent
signs one host-bound MutationEnvelope
→ Agent writes the envelope into the fixed execution exchange
→ root helper execute-envelope
verifies signature, time, target, replay, custody, identity, signatures, and versions again
executes one fixed pacman -U plan without --needed
→ joined MutationReceipt returns to Desktop
```
Exchange files use exact UUID filenames, no-follow directory traversal, create-new
outputs, and filename joins between request and response. Stale unprivileged outputs are
removed before the helper runs; the helper refuses symlinked or out-of-directory paths.
OSV currently has no usable Arch package mapping. The verdict is `unsupported`, never
`clear`, and the operator reason cannot waive any cryptographic or package-identity check.
---
## Audit State
Mint and execution decisions write the helper's local security journal. The local API
does not yet expose that root journal as a dedicated approval-history endpoint, and no
fleet upload exists. Desktop can show the joined result returned by the current request;
durable local browsing remains unfinished.
The Desktop-provided operator name comes from the session environment and is asserted,
not peer-attested. `SO_PEERCRED`, polkit, or another fresh step-up mechanism remains an
open authority improvement.
---
## Fleet Join Is Not Implemented
The helper has an explicit local-key retirement primitive, but there is no complete,
tested transition that retires local authority, destroys its private key, installs the
Server keyring, registers the Agent, and proves the converged end state. Registration
from a standalone config therefore refuses with an error. Operators must not add fleet
credentials beside the local authority by hand.
The required future transition is one-way and idempotent:
1. stop local mutation intake;
2. retire and destroy the local private authority;
3. replace the helper trust set with Server authority keys;
4. register and persist complete fleet credentials;
5. prove that local mint sudoers and key material are absent; and
6. resume as a fleet Agent with no local fallback.
Until that workflow and its matrix tests exist, “join this standalone machine to a
fleet” is a named gap, not a supported lifecycle.
---
## Non-Goals
- No local mint authority beside fleet credentials.
- No doctrinal switch that disables signing, replay, hashes, identity, signatures, or
forward-only enforcement.
- No package-manager command in Desktop or Agent sudoers.
- No network listener for minting.
- No claim that same-host Ed25519 recreates the fleet trust boundary.
---
## Cross-References
- [05-supply-chain-gate](05-supply-chain-gate.md) — capability and envelope contracts
- [../components/04-helper](../components/04-helper.md) — root validation and execution
- [../components/05-desktop](../components/05-desktop.md) — credential-less request surface
- [../scanners/06-pacman-scanner](../scanners/06-pacman-scanner.md) — discovery and exact transaction resolution
---
*Last reviewed: 2026-09-01*

View file

@ -0,0 +1,41 @@
# Testing
**What's tested, how, and where the honest gaps are.**
---
## Shape
~98 Go test files across the repo (61 server, 37 agent), plus Rust tests in the helper. Coverage concentrates where the security model lives — verification, token lifecycle, scanners with hostile input — rather than chasing a percentage.
| Layer | What it covers | Examples |
|-------|----------------|----------|
| Unit (Go) | Crypto verification, replay protection, backoff, machine-id derivation, scanner parsers | `agent/internal/crypto/*_test.go`, `winget_parser_test.go`, `windows_ghost_test.go` |
| Unit (Rust) | Helper token verification, hash checks | `helper/` cargo tests |
| Cross-language contract | Current closure-token vectors plus the dormant mutation manifest/authorization envelope — Rust and Go must produce byte-identical canonical bytes, hashes, and signatures | shared `protocol/testdata/` fixture + helper/server/agent tests |
| Structural | Tests that assert properties of the *source*, not behavior — e.g. `token_renewal_transaction_test.go` asserts the renewal handler's transactional shape; `ethos_exempt_test.go` polices logging discipline | server + agent |
| Migration | Idempotency and schema invariants | `server/internal/database/queries/*_test.go` |
| CI | `go vet`, `go test -race`, `cargo test` + clippy, `tsc --noEmit` on every push | `.gitea/workflows/ci.yml` |
The structural-test category is unusual and deliberate: where a rule matters more than any single behavior (transaction boundaries, ETHOS logging), a test reads the source and fails on regression. Cheaper than a linter plugin, louder than a comment.
---
## Manual / Live Testing
- A live Fedora agent runs against the dev stack continuously — DNF scanning, replay protection observed firing in production logs.
- The supply-chain gate completed a live end-to-end run 2026-06-05: real package, install → hash-pin → token mint → helper verify+execute → receipt.
- Windows agents test against physical machines (intermittently available) — Windows coverage leans harder on unit tests of parsers and the WUA layer as a result.
---
## Honest Gaps
- **No web UI tests.** The dashboard is exercised by hand. TypeScript compilation (`tsc --noEmit`) is the only automated check.
- **No end-to-end suite.** The live-agent loop substitutes for one; it does not run in CI.
- **Windows paths are under-exercised live** relative to Linux — see manual testing above.
- Test counts are a proxy, not a coverage claim. Nothing here should be read as "battle-tested"; the README says the same.
---
*Last reviewed: 2026-06-11*

View file

@ -0,0 +1,177 @@
# Ed25519 Signing Pipeline
**Server-side command and binary signing with Ed25519.**
---
## Overview
All commands and binaries are signed with Ed25519 private key on the server. Agents verify signatures before execution.
**Cross-references:**
- [verification/02-agent-verification](02-agent-verification.md) (agent-side verification)
- [verification/03-key-rotation](03-key-rotation.md) (key rotation support)
- [security/01-trust-boundaries](../security/01-trust-boundaries.md) (signature as trust boundary)
---
## Key Generation
**Method:** Server startup automatic key registration
**File:** `server/internal/services/signing.go`
```go
func (s *SigningService) InitializePrimaryKey(ctx context.Context) error {
// 1. Get current key fingerprint (SHA-256 of public key, truncated)
keyID := s.GetCurrentKeyID()
publicKeyHex := s.GetPublicKeyHex()
// 2. Query next version number from database
nextVersion, err := s.signingKeyQueries.GetNextVersion(ctx)
if err != nil {
nextVersion = 1
}
// 3. Insert key (ON CONFLICT DO NOTHING — safe on every startup)
if err := s.signingKeyQueries.InsertSigningKey(ctx, keyID, publicKeyHex, nextVersion); err != nil {
return fmt.Errorf("failed to insert signing key: %w", err)
}
// 4. Set as primary
if err := s.signingKeyQueries.SetPrimaryKey(ctx, keyID); err != nil {
return fmt.Errorf("failed to set primary key: %w", err)
}
return nil
}
```
---
## Command Signing
**Method:** v3 message format with agent_id binding
**File:** `server/internal/services/signing.go`
```go
func (s *SigningService) SignCommand(cmd *models.AgentCommand) (string, error) {
// 1. Record signing time and key identity
now := time.Now().UTC()
cmd.SignedAt = &now
cmd.KeyID = s.GetCurrentKeyID()
// 2. Serialize params and hash
paramsJSON, _ := json.Marshal(cmd.Params)
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
// 3. Create v3 message format
// agent_id binding prevents cross-agent replay (F-1 fix)
message := fmt.Sprintf("%s:%s:%s:%s:%d",
cmd.AgentID.String(),
cmd.ID.String(),
cmd.CommandType,
paramsHashHex,
now.Unix())
// 4. Sign with Ed25519
signature := ed25519.Sign(s.privateKey, []byte(message))
return hex.EncodeToString(signature), nil
}
```
**v3 Message Format Benefits:**
- **Agent binding:** Includes agent_id to prevent command relay attacks
- **Timestamp:** Prevents replay attacks (4-hour max age)
- **Parameter hash:** Hides full parameter data while allowing verification
---
## Binary Signing
**Method:** BuildOrchestrator signs binaries at startup
**File:** `server/internal/services/build_orchestrator.go`
```go
func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
// 1. Load binary from disk
binaryName := "redflag-agent"
if strings.HasPrefix(platform, "windows") {
binaryName += ".exe"
}
binaryPath := filepath.Join(s.agentDir, "binaries", platform+"-"+architecture, binaryName)
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
return nil, fmt.Errorf("binary not found for platform %s: %w", platform, err)
}
if s.signingService.IsEnabled() {
// 2. Compute checksum and sign
signedPackage, err := s.signingService.SignFile(binaryPath)
if err != nil {
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
}
// 3. Set metadata
signedPackage.Version = version
signedPackage.Platform = platform
signedPackage.Architecture = architecture
// 4. Store in database
err = s.packageQueries.StoreSignedPackage(signedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store signed package: %w", err)
}
log.Printf("[INFO] [server] [build_orchestrator] package_signed id=%s version=%s platform=%s arch=%s", signedPackage.ID, version, platform, architecture)
return signedPackage, nil
} else {
log.Printf("Signing disabled, creating unsigned package entry")
// Create unsigned package entry for backward compatibility
unsignedPackage := &models.AgentUpdatePackage{
ID: uuid.New(),
Version: version,
Platform: platform,
Architecture: architecture,
BinaryPath: binaryPath,
Signature: "",
Checksum: "",
CreatedBy: "build-orchestrator",
IsActive: true,
}
// Get file info
if info, err := os.Stat(binaryPath); err == nil {
unsignedPackage.FileSize = info.Size()
}
// Store unsigned package
err := s.packageQueries.StoreSignedPackage(unsignedPackage)
if err != nil {
return nil, fmt.Errorf("failed to store unsigned package: %w", err)
}
return unsignedPackage, nil
}
}
```
---
## Footer: Assumptions & Connections
**Assumption:** Ed25519 signing is enabled when `REDFLAG_SIGNING_PRIVATE_KEY` environment variable is set.
**Connection:** [verification/02-agent-verification](02-agent-verification.md) (agent-side signature verification)
**Connection:** [verification/03-key-rotation](03-key-rotation.md) (multi-key rotation support)
**Connection:** [security/01-trust-boundaries](../security/01-trust-boundaries.md) (cryptographic trust boundary)
---
*Last reviewed: 2026-06-14*

View file

@ -0,0 +1,162 @@
# Agent-Side Verification
**Agent verifies commands and binaries before execution.**
---
## Overview
Agent verifies Ed25519 signatures, timestamps, and nonces before executing commands.
**Cross-references:**
- [verification/01-signing-pipeline](01-signing-pipeline.md) (server-side signing)
- [verification/03-key-rotation](03-key-rotation.md) (key rotation)
- [verification/04-replay-protection](04-replay-protection.md) (replay protection)
---
## Public Key Caching (TOFU)
**Method:** Trust On First Use — TTL+key_id cache with rotation awareness
**File:** `agent/internal/crypto/pubkey.go`
```go
func FetchAndCacheServerPublicKey(serverURL string) (ed25519.PublicKey, error) {
// 1. Check if cache is valid (TTL + key_id match)
if meta, err := loadCacheMetadata(); err == nil && meta.KeyID != "" && !meta.IsExpired() {
if cachedKey, loadErr := LoadCachedPublicKey(); loadErr == nil {
return cachedKey, nil
}
}
// 2. Fetch from server GET /api/v1/public-key
pubKeyBytes, err := httpGetPublicKey(serverURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch public key: %w", err)
}
// 3. Cache to disk
if err := cachePublicKey(pubKeyBytes); err != nil {
fmt.Printf("Warning: Failed to cache public key: %v\n", err)
}
return pubKeyBytes, nil
}
```
---
## Command Verification
**Method:** Verify v3 signature with timestamp, falls back to older formats for backward compatibility
**File:** `agent/internal/crypto/verification.go`
```go
func (v *CommandVerifier) VerifyCommandWithTimestamp(
cmd client.Command,
serverPubKey ed25519.PublicKey,
maxAge time.Duration,
clockSkew time.Duration,
) error {
// 1. If cmd.SignedAt is nil, fall back to oldest format (backward compat)
if cmd.SignedAt == nil {
fmt.Printf("[WARNING] [agent] [crypto] command_uses_oldest_format command_id=%s no_signed_at=true upgrade_server_recommended\n", cmd.ID)
return v.VerifyCommand(cmd, serverPubKey)
}
// 2. Validate timestamp window
now := time.Now().UTC()
age := now.Sub(*cmd.SignedAt)
if age > maxAge {
return fmt.Errorf("command timestamp too old: signed %v ago (max %v)", age.Round(time.Second), maxAge)
}
if age < -clockSkew {
return fmt.Errorf("command timestamp is in the future: %v ahead (max skew %v)", (-age).Round(time.Second), clockSkew)
}
// 3. Try v3 format first (with agent_id) if AgentID is present
if cmd.AgentID != "" {
message, err := v.reconstructMessageV3(cmd)
if err != nil {
return fmt.Errorf("failed to reconstruct v3 message: %w", err)
}
if ed25519.Verify(serverPubKey, message, sig) {
return nil // v3 verification succeeded
}
// v3 failed — try v2 as fallback
}
// 4. v2 format: timestamp but no agent_id (backward compat)
message, err := v.reconstructMessageWithTimestamp(cmd)
if err != nil {
return fmt.Errorf("failed to reconstruct timestamped message: %w", err)
}
if !ed25519.Verify(serverPubKey, message, sig) {
return errors.New("signature verification failed")
}
return nil
}
```
**Verification Modes:**
- **Strict:** Reject all verification failures (default)
- **Warning:** Log failure but execute command
- **Disabled:** Skip verification entirely
**Fallback Chain:** v3 (agent_id + timestamp) → v2 (timestamp only) → oldest (no timestamp)
```
**Verification Modes:**
- **Strict:** Reject all verification failures (default)
- **Warning:** Log failure but execute command
- **Disabled:** Skip verification entirely
---
## Binary Verification
**Method:** Verify checksum and Ed25519 signature before installation
**File:** `agent/internal/orchestrator/update_handler.go`
```go
func (h *UpdateHandler) verifyDownload(binaryData []byte, checksum string) error {
// 1. Verify checksum
computedChecksum := sha256.Sum256(binaryData)
if hex.EncodeToString(computedChecksum[:]) != checksum {
return errors.New("checksum mismatch")
}
// 2. Verify Ed25519 signature
signature, err := h.downloadBinarySignature()
if err != nil {
return err
}
publicKey, err := h.LoadCachedPublicKey()
if err != nil {
return err
}
if !ed25519.Verify(publicKey, binaryData, []byte(signature)) {
return errors.New("signature verification failed")
}
return nil
}
```
---
## Cross-References
- **Server signing** → [verification/01-signing-pipeline](01-signing-pipeline.md)
- **Key rotation** → [verification/03-key-rotation](03-key-rotation.md)
- **Replay protection** → [verification/04-replay-protection](04-replay-protection.md)
- **Install script** → [flows/01-registration](../flows/01-registration.md) (TOFU key caching)
---
*Last reviewed: 2026-06-14*

View file

@ -0,0 +1,138 @@
# Key Rotation Support
**Ed25519 signing key rotation without downtime.**
---
## Overview
RedFlag supports rotating the server's Ed25519 signing key while maintaining agent trust continuity via the TOFU (Trust On First Use) model.
---
## Key Storage
**Server-side:**
- Private key: `REDFLAG_SIGNING_PRIVATE_KEY` (env var or Docker secret)
- Public key: Stored in database table `server_public_keys`
- Key format: Ed25519 (32-byte curve25519)
**Agent-side:**
- Public key cached at registration (`~/.config/redflag/server_keys.json`)
- Cached keys validated against active keys in database
- TOFU: First key accepted becomes trusted forever
---
## Rotation Process
### Server-Side Rotation
```
1. Generate new key pair (Ed25519)
2. INSERT new public key into server_public_keys (status = 'pending')
3. Update server config to use new private key
4. Sign next command with new key
5. Agents verify signature against new key (fails old key check)
6. Agents accept new key as valid (TOFU update)
7. Old key marked deprecated in database
8. Old keys removed after grace period
```
**Implementation:**
- `server/internal/services/signing.go:SetPrimaryKey()`
- `server/internal/database/migrations/035_server_public_keys.up.sql`
---
## Agent Verification Flow
```
1. Agent receives signed command
2. Extract public key from signature (if v3 format)
3. Check if key exists in cached trusted keys
4. If not cached → fetch from server_public_keys table
5. Validate key signature against command
6. Accept key as trusted (TOFU)
7. Update local cache
```
**Implementation:**
- `agent/internal/crypto/pubkey.go` (TOFU key caching)
- `agent/internal/crypto/verification.go:VerifyCommandWithTimestamp()`
---
## Key Lifecycle States
| State | Description | Database Field |
|-------|-------------|----------------|
| `active` | Currently signing commands | `status = 'active'` |
| `pending` | Newly added, awaiting agent adoption | `status = 'pending'` |
| `deprecated` | Old key, still accepted | `status = 'deprecated'` |
| `revoked` | Compromised or removed key | `status = 'revoked'` |
---
## Security Considerations
### During Rotation
- Old key remains `active` until new key is confirmed by agents
- Grace period prevents agents from being orphaned
- No downtime for command dispatch
### Key Compromise
- Immediately revoke compromised key
- Force agents to re-fetch public key list
- Affected commands can be replayed until key revocation propagates
### Forward-Only Enforcement at the Agent Key Path
A rotated-out or revoked key must stop being trusted. Two agent-side rules make
that hold even when the agent cannot reach the server (SEC-028):
- **Bounded stale cache.** When the public-key fetch fails (network down), the
agent keeps serving its last cached key only within a bounded staleness
window. Past the window — or when the cache age cannot be established (no
metadata sidecar) — it fails closed rather than trusting a possibly
rotated-out key indefinitely. Acceptance within the window is surfaced at
ERROR, not WARNING: it is a degraded-trust state, not routine.
- The **window length** is operator policy: security setting
`command_signing.stale_key_max_age_hours` (default 168h / 7d), delivered
fleet-wide via `GET /api/v1/agents/:id/config` and overridable per
deployment for sites with long offline windows.
- The **existence of a fail-closed ceiling is doctrine, not a knob.** The
agent clamps any configured value to `[1h, 30d]` (`SetStaleKeyMaxAge`) and
the server rejects out-of-range writes (1720h validation). No setting and
no tampered local config can disable the ceiling or set it to infinite.
- **Active-set refusal.** When a command names a `key_id` the server's active
set does not contain, the agent refuses verification (returns an error that
the command handler surfaces as a verification failure) instead of falling
back to the primary cached key. A key the server has rotated out is dead.
**Implementation:**
- `agent/internal/crypto/pubkey.go` (`FetchAndCacheServerPublicKey`, `SetStaleKeyMaxAge`)
- `agent/internal/crypto/verification.go:CheckKeyRotation()`
- `server/internal/services/security_settings_service.go` (`command_signing.stale_key_max_age_hours` default + validation)
### TOFU Limitations
- Compromised initial key → all future keys trusted
- Mitigation: Monitor agent registration patterns
- Mitigation: Periodic key rotation limits exposure window
---
## Footer: Assumptions & Connections
**Assumption:** Key rotation is a rare operation — expected once per year or less.
**Connection:** Rotation process (`verification/03-key-rotation.md`) complements TOFU caching (`verification/02-agent-verification.md`).
**Connection:** `server_public_keys` table enables rotation without code changes.
**Connection:** Agent-side key caching (`agent/internal/crypto/pubkey.go`) implements TOFU.
---
*Last reviewed: 2026-05-26*

View file

@ -0,0 +1,129 @@
# Replay Protection
**Multi-layer defense against command replay attacks.**
---
## Overview
RedFlag implements defense-in-depth against replay attacks at three layers:
1. **Nonce validation** (temporal binding for update commands)
2. **Command deduplication** (disk-based ID tracking)
3. **Timestamp validation** (command age limits)
---
## Layer 1: Update Nonce Validation
**Purpose:** Bind update commands to specific time window.
**Mechanism:**
- Server generates nonce with max age = 2× check-in interval
- Nonce embedded in `update_agent` command signature
- Agent must present valid nonce within expiry window
- Server validates nonce age before execution
**Implementation:**
- Server: `server/internal/services/update_nonce.go`
- Validation: `server/internal/middleware/machine_binding.go:validateNonce()`
- Nonce expiry: 2× `check_in_interval` (default 10 minutes)
**Security Model:**
```
Command dispatched → Nonce generated (age=0)
Agent receives → Nonce cached (age < maxAge/2)
Agent executes → Nonce validated (age < maxAge)
Command executed → Nonce consumed (single-use)
```
---
## Layer 2: Command Deduplication
**Purpose:** Prevent duplicate execution after agent restart.
**Mechanism:**
- Executed command IDs persisted to `executed_commands.json`
- 4-hour max age window (aligns with command TTL)
- Atomic writes prevent corruption
**Implementation:**
- Storage: `agent/internal/orchestrator/executed_commands.json`
- Load: `loadExecutedCommands()`
- Add: `executedIDs.Add(cmd.ID)`
- Save: `saveExecutedCommands(executedIDs)`
**Deduplication Flow:**
```
Command received → Check executed IDs
If duplicate → Reject with security event
If unique → Add to executed IDs
Execute command
```
---
## Layer 3: Timestamp Validation
**Purpose:** Reject stale commands regardless of nonce.
**Mechanism:**
- Commands must have `created_at` within TTL window
- Default TTL: 4 hours
- Server rejects commands older than TTL
**Implementation:**
- Validation: `server/internal/handlers/agents.go:validateCommandTimestamp()`
- TTL: 4 hours (configurable via `security_settings.command_ttl_hours`)
---
## Combined Protection Flow
```
┌─────────────────────────────────────────────────────────────┐
│ Command Execution │
├─────────────────────────────────────────────────────────────┤
│ 1. Agent polls server for commands │
│ 2. Server returns signed command with nonce │
│ 3. Agent validates nonce age (Layer 1) │
│ 4. Agent checks executed_commands.json (Layer 2) │
│ 5. Agent executes command │
│ 6. Agent records command ID in executed_commands.json │
│ 7. Command times out (Layer 3) → rejected on retry │
└─────────────────────────────────────────────────────────────┘
```
---
## Circuit Breaker Integration
**Purpose:** Prevent replay during scanner failures.
**Mechanism:**
- Circuit breaker per subsystem (APT, DNF, Winget, WUA, Docker)
- Opens after N failures in T window
- Blocks all scanner commands while open
**Implementation:**
- `agent/internal/circuitbreaker/circuitbreaker.go`
- Failure threshold: 5 failures in 60 seconds
- Open duration: 5 minutes
---
## Footer: Assumptions & Connections
**Assumption:** Replay attacks are rare — protection is defense-in-depth, not primary security.
**Connection:** Nonce validation (`verification/04-replay-protection.md`) complements machine binding (`security/02-authentication-stack.md`).
**Connection:** Command deduplication (`verification/04-replay-protection.md`) implements ETHOS #4 (idempotency).
**Connection:** Circuit breaker (`verification/04-replay-protection.md`) implements ETHOS #3 (assume failure).
**Connection:** Nonce service (`server/internal/services/update_nonce.go`) ties to security settings (`security_settings.operational`).
---
*Last reviewed: 2026-05-26*

311
README.md Normal file
View file

@ -0,0 +1,311 @@
# RedFlag
**Understand and govern the machines you own.**
AGPL-3.0 · pre-release · **no tagged release exists yet**
> **You're early — over 1,000 of you cloned this before it was announced.**
> Build from source; there is no release artifact to download, and the release
> pipeline has not yet produced one. When a version is cut it will appear on
> Forgejo first, signed, and this line will say so instead.
> RedFlag is free and stays free — [here's who builds it, and why](AUTHOR.md).
### Where this lives
RedFlag is developed in a private forge and published outward through one
gated path. Three public copies exist and they are not equal:
| | | |
|---|---|---|
| **[forge.caseytunturi.com/Fimeg/RedFlag](https://forge.caseytunturi.com/Fimeg/RedFlag)** | **canonical** | The public source of record. Every public commit arrives here first, through a publication gate, and is verified anonymously before anything downstream moves. Inspect the code here. |
| [github.com/Fimeg/RedFlag](https://github.com/Fimeg/RedFlag) | mirror | A downstream copy of the exact Forgejo commit, for discovery and for issues. Nothing is developed here and nothing is published here first. |
| [codeberg.org/Fimeg/RedFlag](https://codeberg.org/Fimeg/RedFlag) | mirror, may lag | Best-effort. It is allowed to fall behind rather than hold canonical publication closed, so check its commit against Forgejo before trusting it. |
Public history begins at a deliberate projection epoch: the tree is constructed
from private source under a published path policy rather than being whatever
happened to sit on a branch. Each public commit carries `Source-Sha`,
`Policy-Sha` and `Tree-Digest` trailers binding it to what produced it.
---
<!-- Showcase video goes here: terminal → install → multiple agents → updates. -->
RedFlag is a local-machine operations console and a self-hosted fleet authority. The native Qt/QML Desktop asks what this computer is doing now: health, resources, processes, connections, services, containers, installed software, updates, security evidence, and history. RedFlag Web asks the same questions across many machines.
The Agent owns observation and machine state. Desktop expresses operator intent; it does not shell out to package managers, service managers, or Docker. With the default strict agent posture, privileged mutation crosses RedFlag's signed authority path or it does not happen. The fleet server cannot lower that posture remotely; an administrator with local control of the host can change it there.
What makes RedFlag different: the software that patches your fleet runs as root on every box, which makes it part of your attack surface — XZ Utils came through a build pipeline, SolarWinds came through an update. The server signs commands with Ed25519. Agents default to strict verification and reject anything forged or replayed; local host administrators retain authority over their own agent's enforcement posture. On APT and DNF, direct package mutation must cross a privileged Rust helper: a short-lived capability binds the host, operation, and artifact entries whose hashes resolved, and the helper validates that authority before executing a fixed argv plan with a cleared environment. Docker, Winget, and Windows Update still use the default-strict signed-command path. The full trust model is in [SECURITY.md](SECURITY.md).
---
| ![Dashboard](Screenshots/RedFlag%20Default%20Dashboard.png) | ![Agent Health](Screenshots/RedFlag%20Linux%20Agent%20Details.png) | ![Dependency Resolution](Screenshots/7Zip-Updates-RedFlag-Dependency.png) |
|---|---|---|
<details>
<summary>More screenshots</summary>
| ![Live Ops](Screenshots/RedFlag%20Live%20Operations%20-%20Failed%20Dashboard.png) | ![History](Screenshots/RedFlag%20History%20Dashboard.png) | ![Docker](Screenshots/RedFlag%20Docker%20Dashboard.png) |
|---|---|---|
| ![Heartbeat](Screenshots/RedFlag%20Heartbeat%20System.png) | ![Windows Agent](Screenshots/RedFlag%20Windows%20Agent%20Details.png) | ![Agent List](Screenshots/RedFlag%20Agent%20List.png) |
|---|---|---|
| ![Upstream Tracking](Screenshots/Upstream-Version-Tracking.png) | ![Updates](Screenshots/RedFlag%20Updates%20Dashboard.png) | |
|---|---|---|
</details>
---
## RedFlag Desktop
Desktop is the native view of this machine, not a web dashboard wrapped in a window. It is built in Rust with Qt/QML and talks to the Agent over a local Unix socket or Windows named pipe.
The current Linux cut includes:
- live CPU, load, memory, swap, network, storage, and thermal history
- process inventory and drill-down with sockets, namespaces, capabilities, service/container ownership, and owning package
- systemd services, Docker containers and Compose stacks
- installed pacman, dpkg/APT, and RPM/DNF software inventory with dependency and file detail
- available updates, advisories, policy evidence, approval, and recorded override intent
- security posture and durable local history
The Windows local transport exists, but a Windows Desktop artifact waits for a native Qt/MSVC release runner. The web application remains the fleet surface; it is not embedded into Desktop.
---
## Quick Start
### Server
```bash
git clone https://forge.caseytunturi.com/Fimeg/RedFlag.git
cd RedFlag
cp config/.env.example config/.env
docker-compose build && docker-compose up -d
```
Open `http://localhost:31336`, complete the setup wizard, then restart:
```bash
docker-compose down && docker-compose up -d
```
### Agent
Get a registration token from **Settings → Token Management**, then:
**Linux / macOS:**
```bash
curl -sfL -H "X-Registration-Token: your-token" "https://your-server.com/api/v1/install/linux" | sudo bash
```
**Windows:**
```powershell
iwr -Headers @{"X-Registration-Token"="your-token"} "https://your-server.com/api/v1/install/windows" | iex
```
---
## What It Manages
| Platform | Package Managers / Scanners |
|---|---|
| Linux | APT, DNF, Docker (socket) |
| Windows | Winget, Windows Update (COM), Docker (socket) |
Agents run at the OS level and query the Docker socket directly — there's no separate container agent. Agents are pull-based: they check in every 5 minutes and execute what the server has approved. The server never initiates a connection.
---
## Security
The update manager *is* attack surface, so it gets treated like one: Ed25519-signed commands with replay protection, hardware-bound agent identity, rotating refresh tokens that burn loudly when stolen, and a separate privileged executor for APT/DNF mutation. The helper checks token version and time, host binding, signature, and replay state, then executes fixed package-manager argv without a shell or inherited environment.
The APT/DNF dry-run must resolve the top-level artifact hash before a capability can be minted. Successfully resolved dependency hashes are included, but unresolved dependency hashes can currently be omitted. The helper rehashes artifacts supplied by local path and refuses a missing or mismatched mirror artifact; normal registry entries without local paths are not rehashed helper-side. Its current `systemd-run` unit is short-lived but **not network-isolated**. Complete transitive closure pinning, local custody of every byte, and network isolation remain design work rather than implied guarantees.
The full trust model lives in [SECURITY.md](SECURITY.md), including how to report a vulnerability. The architecture and its honest gaps are documented in the RedFlag Architecture Framework (RAF).
---
## Architecture
```
┌─────────────────────────────┐
│ Server (Go) │ PostgreSQL · Ed25519 Signing Service
│ Embedded React dashboard │ Dashboard: 31336 · Agent API: 31337
└────────┬────────────────────┘
│ Pull-based (agents check in, not the reverse)
├──────────────────┐
┌────────▼────────┐ ┌──────▼──────────┐
│ Linux Agent │ │ Windows Agent │
│ │ │ │
│ APT / DNF │ │ Winget / WUA │
│ Docker socket │ │ Docker socket │
└─────────────────┘ └─────────────────┘
```
---
## Features
- **Approval workflow** — updates queue for human review before anything runs
- **Maintenance windows** — day/time gates on when installs can proceed
- **Upstream tracking** — polls GitHub, Forgejo/Gitea/Codeberg, GitLab, Bitbucket for new releases; flags EOL drift
- **Drift detection** — knows what should be installed vs. what is, bridges the gap into update packages
- **Agent self-update** — SHA-256 → signature → atomic binary swap → service restart, reconciled server-side
- **Dependency dry-run** — checks before installing, not after
- **Idempotent installer** — re-running won't create duplicate agents
- **Proxy support** — HTTP/HTTPS/SOCKS5 for restricted networks
- **Native services** — systemd on Linux, Windows Services on Windows
- **Full audit trail** — all operations logged with context, sanitized against log injection
- **Native local console** — live machine health and operations beside the Agent, with no browser or cloud dependency
- **Connected inspection** — follow a process into its service, container, socket, capability set, and owning package
---
## Status
**Compiles, runs on the maintainer's stack, not yet battle-tested.** No live deployment outside the dev environment. The supply-chain gate has completed an end-to-end run (2026-06-05: `hyprutils` through capability-token minting, helper verification, and install on a live Fedora agent). Treat everything below as "implemented and locally exercised, not production-proven."
**Implemented:**
- Linux and Windows agent registration and update management
- APT, DNF, Winget, Windows Update, Docker image scanning
- Package state machine with enforced transitions and lifecycle orchestrator
- Failed state recovery: reopen, resolve, and transition out of failed
- Lifecycle history with status badges, version transitions, and failure reasons
- Scan-set closure reconciler (close-by-absence) — fixes out-of-band false positives
- Dry-run dependency checking with a mandatory top-level hash and best-effort transitive hash resolution
- Supply chain gate: OSV batch checks across reported resolved entries, vuln-is-a-full-stop enforcement for the checked set, audited override path
- Version soak-gating and package age gate as configurable policies
- Capability-token minting for dnf/apt with Ed25519-signed token verification
- Ed25519 key rotation and replay protection
- Maintenance windows
- Upstream version tracking (GitHub, Forgejo/Gitea/Codeberg, GitLab, Bitbucket, Repology, endoflife.date)
- Metadata pipeline: CVE details, upstream intelligence, package provenance
- Auto-discovery bridge (Repology, container registry, exact match)
- Agent self-update via privileged helper (zero agent sudo)
- Binary self-update (agent, helper, desktop) through the same signed, hash-pinned capability-token gate as package installs
- Process explorer: on-demand /proc scanning (sockets, capabilities, namespaces) with inode correlation
- Setup accepts an operator-supplied signing keypair — bring-your-own-key deployments
- Reversible token encryption with one-liner restore
- Real-time heartbeat and rapid polling
- Native Qt/QML Desktop source for health, performance, processes, network, storage, containers, services, software, updates, security, and history
**Not yet done:**
- No AUR, Snap, Flatpak, or Homebrew support
- macOS agent binaries not signed
- Mobile dashboard usable, not optimized
- Cert pinning and enforced TLS verification
- Complete transitive closure hashing for APT/DNF; unresolved dependency hashes can currently be omitted
- Helper-side rehashing of normal registry artifacts before mutation
- Network isolation for the privileged helper invocation
- Capability-helper execution for Docker, Winget, and Windows Update
- Native Windows Desktop release artifact and installer proof
- GPU telemetry, rich disk health, and vendor-specific power sensors
---
## Updating
```bash
git pull && docker-compose down && docker-compose build --no-cache && docker-compose up -d
```
Agent self-update runs from the dashboard. Requires a real service manager (`systemd` on Linux, SCM on Windows). Container-only agent deployments can't self-update through this path — redeploy with the new image instead.
If a self-update times out, the previous binary is preserved at `<binary>.bak` on the agent host. Restore manually and restart the service.
<details>
<summary>Nuclear option (full reset)</summary>
```bash
docker-compose down -v --remove-orphans && \
rm config/.env && \
docker-compose build --no-cache && \
cp config/.env.example config/.env && \
docker-compose up -d
```
This wipes all data including the database. Re-register agents afterward with new tokens.
</details>
<details>
<summary>Upgrading from pre-v0.1.20</summary>
Old installations used different paths. Clean reinstall is the supported migration path.
Remove old artifacts if present:
```bash
sudo rm -rf /etc/aggregator/ /usr/local/bin/aggregator-agent /var/lib/aggregator/
```
Then install fresh with the standard one-liner.
</details>
---
## Philosophy
RedFlag follows ETHOS:
- **Honest** — what you see is what you get
- **Transparent** — errors logged with full context, sanitized against injection
- **Secure** — hardware binding, cryptographic verification, local-only logging
- **Open standards** — no vendor lock-in, no cloud dependency, no telemetry
The maintainer runs this on their own infrastructure. Releases are versioned, migrations are idempotent. If something breaks, the error shows up in full — not swallowed into a generic failure message. Log output is sanitized against injection (ANSI stripping, control character replacement, field truncation) but the content is preserved.
Built for operators who'd rather own the problem than outsource it.
---
## Free, Forever
RedFlag will never be monetized. No pro tier, no cloud edition, no per-agent pricing — those are the things it exists to replace. This is my resume piece, built in the open and given away, because the update manager is part of everyone's attack surface — not just the orgs with an RMM budget.
The architecture documentation — the **RedFlag Architecture Framework (RAF)** — is being published alongside the code: how the system is built, why the design landed where it did, and the pitfalls we think are still out there. Not a guide to attacking it; the reasoning behind the madness, so you can judge the security model yourself instead of trusting a README. I haven't thought of everything — that's part of why it's published.
If community adoption takes off, ownership and contribution policies will be made transparent and stay open. This project does not get quietly captured.
If RedFlag holds your fleet and you want to give back: [sponsor the work](https://github.com/sponsors/Fimeg), or better — [hire the person who built it](AUTHOR.md).
---
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for the full history. Recent highlights:
**v0.2.8.0** — Process explorer, self-update capability tokens, BYO signing keypair at setup, cross-compile CI matrix (linux-arm64, windows-amd64, darwin-arm64).
**v0.2.7.1** — CI/CD pipeline on Gitea Actions with release gate and guided release script. Screenshot capability survives self-upgrade.
**v0.2.6.8** — Dark/light tray app theme, desktop tray spine, prototype Tauri desktop app.
**v0.2.6.5** — Windows agent service logs now write to `C:\ProgramData\RedFlag\logs\agent.log`.
**v0.2.6.4** — Windows installer fixes: CRLF, reachable host, port preservation, Ed25519 cold-start tolerance.
**v0.2.6.2** — OSV scans moved to detection. Soak gate promoted to real policy. Dead scaffolding retired.
**v0.2.6.0** — Metadata pipeline, auto-discovery bridge, Docker enrichment, filter/search primitives.
**v0.2.5.1** — Failed state recovery, lifecycle history, reopen/resolve endpoints.
**v0.2.5.2** — Reversible token encryption. Idempotent heartbeat auto-queue.
**v0.2.3.5** — Unified agent+helper upgrade. Path traversal fixes. Self-update on fresh hosts.
**v0.2.3.1** — Supply chain gate hardened: vuln is a full stop. Ack tracking fixed.
**v0.2.2.0** — Package state machine enforced. Lifecycle orchestrator foundation.
---
## License
AGPL-3.0 — see [LICENSE](LICENSE).
**Third-party:** Windows Update integration based on [windowsupdate](https://github.com/ceshihao/windowsupdate) (Apache 2.0).

112
SECURITY.md Normal file
View file

@ -0,0 +1,112 @@
# Security Model
The software that patches your fleet runs as root on every box. XZ Utils came through a build pipeline. SolarWinds came through an update. The update manager is part of your attack surface — most homelab tooling ignores that. RedFlag treats it as the attack surface it is.
This document is the operator-facing trust model. The architecture behind it — why the design landed where it did, and the pitfalls we think are still out there — is published in the RedFlag Architecture Framework (RAF).
---
## Command Signing
Every command the server issues is Ed25519-signed. Agents verify the signature, check the nonce, validate the timestamp, and reject anything they've seen before. The signing key never leaves your server. You can read the security model in the code, not in marketing copy.
Every command includes a signed nonce with a 10-minute validity window. The agent tracks executed nonces and rejects replays, including from an attacker who intercepted a valid command.
Agent-server communication runs over HTTPS. The Ed25519 signing model is a defense-in-depth layer on top of that — commands can't be forged or replayed even if traffic is somehow intercepted or TLS is terminated at a proxy. The signing model doesn't assume the transport is trustworthy. Cert pinning and enforced TLS verification are on the roadmap.
**Command verification is enabled and strict by default. The server cannot lower enforcement remotely.** Verification runs on the agent (`agent/internal/orchestrator/command_handler.go`) and fails closed. Changing the local enforcement posture (`REDFLAG_AGENT_COMMAND_SIGNING_ENABLED`, `REDFLAG_AGENT_COMMAND_ENFORCEMENT_MODE` in `agent/internal/config/config.go`) requires administrative access to the host itself. The server settings surface exposes only the bounded stale-key tolerance: signing availability follows the provisioned Ed25519 service, and the host owns its verification posture.
---
## Machine Binding
Agents register with a one-time token plus a hardware fingerprint. The server stores the fingerprint; future check-ins that don't match the registered machine are rejected. A stolen `config.json` doesn't work on a different machine.
**Machine-bound renewal.** The token-renewal endpoint checks `X-Machine-ID` against the registered host, exactly as command endpoints do. A stolen config cannot mint access tokens from an unregistered machine — a mismatch returns 403 with a logged `machine_id_mismatch` security event. The agent surfaces this as a critical event, not a quiet backoff.
---
## Key Management
On first connect, the agent fetches and caches the server's Ed25519 public key (TOFU). Every subsequent command is verified against it. The `signing_keys` table supports multiple concurrent active keys for zero-downtime rotation: a new key is promoted to primary while the previous key remains active (still verifies commands) until the operator deprecates it through the dashboard. Agents cache keys by `key_id` fingerprint and re-fetch when they see an unknown signer — no coordinated agent restart required. The roster and deprecation controls live at Settings → Security → Key Management.
Setup accepts an operator-supplied signing keypair — bring-your-own-key deployments are first-class.
---
## Refresh-Token Rotation
90-day TTL. Each renewal mints a new refresh token and marks the old one consumed. Replaying a consumed token whose successor was also consumed means theft — the server revokes the entire token family and logs a security event. Agent crash-before-save is covered by accept-previous-once grace: a consumed token whose successor is still unconsumed gets a fresh one, not a revocation.
The failure mode is detection, not silent coexistence: a leaked token is only useful until the legitimate agent next renews, and using a stale one burns the whole family loudly.
---
## The Supply-Chain Gate
For DNF and APT, the agent runs the package-manager dry-run and resolves artifact hashes from that host's signed repository metadata. The top-level artifact hash is mandatory. Successfully resolved dependency hashes are reported too, but an unresolved dependency is currently logged and omitted rather than blocking the whole report. The server checks the reported entries against OSV.dev and can mint an Ed25519-signed capability binding that exact resolved set to one host and operation.
A known vulnerability among the entries checked is a full stop: the operator must override with a documented reason, or the token is never minted. The override waives that vulnerability judgment only; it does not bypass capability validation or local artifact verification where a local artifact is present.
The privileged Rust helper independently validates the token version and validity window, host `agent_id`, pinned-key Ed25519 signature, and replay state. It constructs a fixed package-manager argv plan, invokes no shell, and clears the inherited environment. If a closure entry points to an existing local file, the helper rehashes that file and denies a mismatch; a `source=mirror` entry without a readable matching file also denies. A normal `source=registry` entry with no readable local file is bound into the signed capability but is **not rehashed helper-side** before APT or DNF fetches and installs it.
The Linux invocation currently uses a transient `systemd-run --wait` unit with `ProtectSystem=no`. It does not set a private network namespace or otherwise enforce network isolation. Complete transitive hash resolution, local custody and re-verification of every installed artifact, then a network-isolated helper are the intended boundary and remain unfinished.
**Current boundary, honestly:** the capability-token gate covers dnf and apt today. Docker, winget, and Windows Update still execute through the signed-command path without the helper — gating them is designed but not yet built. The gaps are documented in the RAF, not hidden.
---
## Our Own Dependencies
RedFlag holds itself to the standard it enforces on the fleet. Every push runs
dependency vulnerability scanning in CI: `govulncheck` (reachability-based) on the Go
server and agent, `npm audit` on the web tree, `cargo audit` on the Rust helper. A
reachable vulnerability that isn't explicitly accepted fails the build — the same
fail-closed posture the binary takes at runtime.
Some findings have no fix to take. RedFlag links the Docker engine library as a
*client* (for container scanning) and inherits daemon-side Moby advisories that carry
no patched version. We don't bury those: each one is a documented entry in a
machine-readable exception register, every entry naming the advisory and the reason
it's accepted. That register is the source of truth — it's surfaced **in the app**, so
RedFlag's own residual exposure is visible the same way fleet exposure is, and can't
quietly rot in a doc nobody re-reads. When an upstream fix ships, the dependency is
bumped and the entry is removed; CI warns on an exception that no longer applies.
The build substrate itself is recorded on every run (toolchain and engine versions) so
"what built this" is never a mystery. Enforcing a minimum-patched floor on that
substrate is the next layer.
### Accepted Dependency Exceptions
The following vulnerabilities are known, accepted, and documented in the
machine-readable `.govulncheck-allow` register. All are daemon-side Docker/Moby
advisories that do not affect RedFlag because it uses the Docker client only for
`Ping`, `SecretList`, and container scanning — never for the daemon's plugin
privilege or AuthZ paths.
| GO ID | Summary | Rationale |
|-------|---------|-----------|
| GO-2026-4883 | Moby off-by-one in plugin privilege validation | Daemon-side; client-only usage |
| GO-2026-4887 | Moby AuthZ plugin bypass via oversized request bodies | Daemon-side; client-only usage |
None of these have published fixes. When upstream patches ship, the dependency
will be bumped and the entries removed.
---
## Visibility
**Security Health** is surfaced as a dashboard panel on each agent — signing status, nonce protection, machine binding violations, command validation — so the posture is visible without digging through logs. All operations are logged with full context, sanitized against log injection (ANSI stripping, control character replacement, field truncation) with the content preserved.
---
## Reporting a Vulnerability
Found something? I want to know, and you won't get lawyered at for looking.
- **Email:** casey@samaritansolutions.net — use "RedFlag Security" in the subject
- Please include reproduction steps and the version (`v*` tag or commit)
- No live deployment exists outside the dev environment yet, so there's no embargo theater — but a heads-up before public disclosure is appreciated so a fix can land first
Good-faith research against your own RedFlag deployment is explicitly welcome. That's what self-hosted means.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

45
THIRD_PARTY_LICENSES.md Normal file
View file

@ -0,0 +1,45 @@
# Third-Party Licenses
This document lists the third-party components and their licenses that are included in or required by RedFlag.
## Windows Update Package (Apache 2.0)
**Package**: `github.com/ceshihao/windowsupdate`
**Version**: Vendored from master branch (Jan 2026 refactor), in `agent/pkg/windowsupdate/`
**License**: Apache License 2.0
**Copyright**: Copyright 2022 Zheng Dayu
**Source**: https://github.com/ceshihao/windowsupdate
**License File**: https://github.com/ceshihao/windowsupdate/blob/master/LICENSE
### License Text
```
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
### Modifications
The package has been modified for integration with RedFlag's update management system. Modifications include:
- Integration with RedFlag's update reporting format
- Added support for RedFlag's metadata structures
- Compatibility with RedFlag's agent communication protocol
All modifications maintain the original Apache 2.0 license.
---
## License Compatibility
RedFlag is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). The Apache License 2.0 components (windowsupdate) are compatible with AGPL-3.0, as Apache 2.0 is listed by the FSF as compatible with GPLv3 and later.
The windowsupdate package retains its original Apache 2.0 license. This attribution fulfills the requirements of both licenses.

13
agent/NOTICE Normal file
View file

@ -0,0 +1,13 @@
RedFlag Agent
Copyright 2024-2025
This software includes code from the following third-party projects:
---
windowsupdate
Copyright 2022 Zheng Dayu
Licensed under the Apache License, Version 2.0
https://github.com/ceshihao/windowsupdate
Included in: agent/pkg/windowsupdate/

165
agent/cmd/agent/cli.go Normal file
View file

@ -0,0 +1,165 @@
package main
import (
"flag"
"fmt"
"os"
"runtime"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/service"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
// CLIFlags holds all command-line flags
type CLI struct {
Register bool
Scan bool
Status bool
LocalStatus bool
InitStandalone bool
ListUpdates bool
Version bool
ServerURL string
Token string
ProxyHTTP string
ProxyHTTPS string
ProxyNoProxy string
LogLevel string
ConfigFile string
Tags string
Organization string
DisplayName string
InsecureTLS bool
ExportFormat string
InstallService bool
RemoveService bool
StartService bool
StopService bool
ServiceStatus bool
}
// ParseFlags parses all command-line flags and returns the CLI struct
func ParseFlags() *CLI {
cli := &CLI{}
// Define CLI flags
flag.BoolVar(&cli.Register, "register", false, "Register agent with server")
flag.BoolVar(&cli.Scan, "scan", false, "Scan for updates and display locally")
flag.BoolVar(&cli.Status, "status", false, "Show agent status")
flag.BoolVar(&cli.LocalStatus, "local-status", false, "Show live local agent status over local IPC")
flag.BoolVar(&cli.InitStandalone, "init-standalone", false, "Create or print this host's standalone Agent identity")
flag.BoolVar(&cli.ListUpdates, "list-updates", false, "List detailed update information")
flag.BoolVar(&cli.Version, "version", false, "Show version information")
flag.StringVar(&cli.ServerURL, "server", "", "Server URL")
flag.StringVar(&cli.Token, "token", "", "Registration token for secure enrollment")
flag.StringVar(&cli.ProxyHTTP, "proxy-http", "", "HTTP proxy URL")
flag.StringVar(&cli.ProxyHTTPS, "proxy-https", "", "HTTPS proxy URL")
flag.StringVar(&cli.ProxyNoProxy, "proxy-no", "", "Comma-separated hosts to bypass proxy")
flag.StringVar(&cli.LogLevel, "log-level", "", "Log level (debug, info, warn, error)")
flag.StringVar(&cli.ConfigFile, "config", "", "Configuration file path")
flag.StringVar(&cli.Tags, "tags", "", "Comma-separated tags for agent")
flag.StringVar(&cli.Organization, "organization", "", "Organization/group name")
flag.StringVar(&cli.DisplayName, "name", "", "Display name for agent")
flag.BoolVar(&cli.InsecureTLS, "insecure-tls", false, "Skip TLS certificate verification")
flag.StringVar(&cli.ExportFormat, "export", "", "Export format: json, csv")
// Windows service management commands
flag.BoolVar(&cli.InstallService, "install-service", false, "Install as Windows service")
flag.BoolVar(&cli.RemoveService, "remove-service", false, "Remove Windows service")
flag.BoolVar(&cli.StartService, "start-service", false, "Start Windows service")
flag.BoolVar(&cli.StopService, "stop-service", false, "Stop Windows service")
flag.BoolVar(&cli.ServiceStatus, "service-status", false, "Show Windows service status")
flag.Parse()
return cli
}
// HandleVersionCommand handles the version display command
func HandleVersionCommand() {
fmt.Printf("RedFlag Agent v%s\n", version.Version)
fmt.Printf("Self-hosted update management platform\n")
os.Exit(0)
}
// HandleWindowsServiceCommands handles Windows service management commands
func HandleWindowsServiceCommands(cli *CLI) bool {
if runtime.GOOS != "windows" {
return false
}
switch {
case cli.InstallService:
if err := service.InstallService(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to install service: %v\n", err)
os.Exit(1)
}
fmt.Println("RedFlag service installed successfully")
os.Exit(0)
case cli.RemoveService:
if err := service.RemoveService(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to remove service: %v\n", err)
os.Exit(1)
}
fmt.Println("RedFlag service removed successfully")
os.Exit(0)
case cli.StartService:
if err := service.StartService(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to start service: %v\n", err)
os.Exit(1)
}
fmt.Println("RedFlag service started successfully")
os.Exit(0)
case cli.StopService:
if err := service.StopService(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to stop service: %v\n", err)
os.Exit(1)
}
fmt.Println("RedFlag service stopped successfully")
os.Exit(0)
case cli.ServiceStatus:
if err := service.ServiceStatus(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to get service status: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
return false
}
// ParseTags parses tags from comma-separated string
func ParseTags(tagsStr string) []string {
if tagsStr == "" {
return nil
}
tags := strings.Split(tagsStr, ",")
for i, tag := range tags {
tags[i] = strings.TrimSpace(tag)
}
return tags
}
// ToConfigFlags converts CLI flags to config.CLIFlags
func (cli *CLI) ToConfigFlags() *config.CLIFlags {
return &config.CLIFlags{
ServerURL: cli.ServerURL,
RegistrationToken: cli.Token,
ProxyHTTP: cli.ProxyHTTP,
ProxyHTTPS: cli.ProxyHTTPS,
ProxyNoProxy: cli.ProxyNoProxy,
LogLevel: cli.LogLevel,
ConfigFile: cli.ConfigFile,
Tags: ParseTags(cli.Tags),
Organization: cli.Organization,
DisplayName: cli.DisplayName,
InsecureTLS: cli.InsecureTLS,
}
}

View file

@ -0,0 +1,119 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/localapi"
)
// HandleLocalStatusCommand displays live agent status through the local IPC API.
// It intentionally runs before config loading, so membership in the local access
// group is enough to inspect local state without reading protected config files.
func HandleLocalStatusCommand(exportFormat string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
snapshot, err := localapi.FetchSnapshot(ctx, localapi.ClientOptions{})
if err != nil {
return err
}
if exportFormat == "json" {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(snapshot)
}
printLocalStatus(snapshot)
return nil
}
func printLocalStatus(snapshot *localapi.Snapshot) {
identity := snapshot.Identity
status := snapshot.Status
fmt.Println("==================================================================")
fmt.Println("RedFlag Local Agent Status")
fmt.Println("==================================================================")
fmt.Printf("Agent ID: %s\n", identity.AgentID)
fmt.Printf("Server: %s\n", identity.ServerURL)
if identity.Hostname != "" {
fmt.Printf("Hostname: %s\n", identity.Hostname)
}
if identity.OSType != "" {
fmt.Printf("OS Type: %s\n", identity.OSType)
}
if identity.DisplayName != "" {
fmt.Printf("Display Name: %s\n", identity.DisplayName)
}
if len(identity.Tags) > 0 {
fmt.Printf("Tags: %s\n", strings.Join(identity.Tags, ", "))
}
fmt.Printf("Version: %s\n", identity.AgentVersion)
fmt.Printf("Registered: %t\n", identity.Registered)
fmt.Println()
fmt.Printf("Agent Status: %s\n", fallback(status.AgentStatus, "unknown"))
if !status.LastCheckIn.IsZero() {
fmt.Printf("Last Check-in: %s\n", status.LastCheckIn.Format(time.RFC3339))
}
if !status.LastScan.IsZero() {
fmt.Printf("Last Scan: %s\n", status.LastScan.Format(time.RFC3339))
}
fmt.Printf("Updates Available: %d\n", status.UpdateCount)
fmt.Printf("Summary Total: %d\n", status.Summary.Total)
if len(status.Summary.ByEcosystem) > 0 {
fmt.Printf("By Ecosystem: %s\n", formatCounts(status.Summary.ByEcosystem))
}
if len(status.Summary.BySeverity) > 0 {
fmt.Printf("By Severity: %s\n", formatCounts(status.Summary.BySeverity))
}
if len(status.Scanners) > 0 {
fmt.Println()
fmt.Println("Scanners:")
names := make([]string, 0, len(status.Scanners))
for name := range status.Scanners {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
scanner := status.Scanners[name]
line := fmt.Sprintf(" - %s: %s (%d updates)", scanner.Name, fallback(scanner.Status, "unknown"), scanner.UpdateCount)
if scanner.LastError != "" {
line += fmt.Sprintf(" error=%q", scanner.LastError)
}
fmt.Println(line)
}
}
fmt.Println("==================================================================")
}
func formatCounts(counts map[string]int) string {
keys := make([]string, 0, len(counts))
for key := range counts {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", key, counts[key]))
}
return strings.Join(parts, ", ")
}
func fallback(value, replacement string) string {
if value == "" {
return replacement
}
return value
}

238
agent/cmd/agent/main.go Normal file
View file

@ -0,0 +1,238 @@
package main
import (
"fmt"
"log"
"os"
"runtime"
"runtime/debug"
"github.com/Fimeg/RedFlag/agent/internal/agent"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/handlers"
"github.com/Fimeg/RedFlag/agent/internal/instancelock"
agentLogging "github.com/Fimeg/RedFlag/agent/internal/logging"
"github.com/Fimeg/RedFlag/agent/internal/migration"
"github.com/Fimeg/RedFlag/agent/internal/registration"
"github.com/Fimeg/RedFlag/agent/internal/service"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
func main() {
if err := agentLogging.ConfigureProcessLogger(); err != nil {
log.Printf("[ERROR] [agent] [logging] process_logger_init_failed error=%v", err)
}
// Panic recovery - prevents agent crashes from unhandled panics
defer func() {
if r := recover(); r != nil {
log.Printf("[CRITICAL] Agent panic recovered: %v", r)
log.Printf("[CRITICAL] Stack trace: %s", debug.Stack())
os.Exit(1)
}
}()
// Parse CLI flags
cli := ParseFlags()
// Handle version command
if cli.Version {
HandleVersionCommand()
}
if cli.LocalStatus {
if err := HandleLocalStatusCommand(cli.ExportFormat); err != nil {
log.Fatal("Local status command failed:", err)
}
return
}
// Handle Windows service management commands
if HandleWindowsServiceCommands(cli) {
return
}
// Determine config path
configPath := constants.GetAgentConfigPath()
if cli.ConfigFile != "" {
configPath = cli.ConfigFile
}
// Check for migration requirements
if err := handleMigration(configPath); err != nil {
log.Printf("Warning: Migration handling failed: %v", err)
}
// Load configuration with priority: CLI > env > file > defaults
cfg, err := config.Load(configPath, cli.ToConfigFlags())
if err != nil {
log.Fatal("Failed to load configuration:", err)
}
// Update agent version in config if changed
if cfg.AgentVersion != version.Version {
cfg.AgentVersion = version.Version
if err := cfg.Save(configPath); err != nil {
log.Printf("Warning: Failed to update agent version in config: %v", err)
}
}
if cli.InitStandalone {
if err := cfg.InitializeStandalone(); err != nil {
log.Fatal("Standalone initialization failed: ", err)
}
if err := cfg.Save(configPath); err != nil {
log.Fatal("Standalone configuration save failed: ", err)
}
fmt.Println(cfg.AgentID.String())
return
}
// Handle registration command
if cli.Register {
if cfg.IsStandalone() {
log.Fatal("Registration refused: standalone fleet join is not implemented; do not add fleet credentials beside local authority")
}
if err := handleRegistration(cfg, cli.ServerURL); err != nil {
log.Fatal("Registration failed:", err)
}
return
}
// Handle scan command
if cli.Scan {
if err := handlers.ScanCommand(cfg, cli.ExportFormat); err != nil {
log.Fatal("Scan failed:", err)
}
return
}
// Handle status command
if cli.Status {
if err := handlers.StatusCommand(cfg); err != nil {
log.Fatal("Status command failed:", err)
}
return
}
// Handle list-updates command
if cli.ListUpdates {
if err := handlers.ListUpdatesCommand(cfg, cli.ExportFormat); err != nil {
log.Fatal("List updates failed:", err)
}
return
}
// Acquire an exclusive instance lock to prevent two agent processes
// from sharing the same config.json and renewal state. The lock is
// released when this process exits (fd closes on os.Exit too).
unlock, err := instancelock.Acquire()
if err != nil {
log.Fatalf("[FATAL] instance_lock_failed another_instance_running error=%v", err)
}
defer unlock()
// Check if registered
if !cfg.IsRegistered() && !cfg.IsStandalone() {
log.Fatal("Agent has no complete identity. Register with a fleet or run the standalone provisioning script.")
}
// Check if running as Windows service
if runtime.GOOS == "windows" && service.IsService() {
if err := service.RunService(cfg); err != nil {
log.Fatal("Service failed:", err)
}
return
}
// Start agent service (console mode)
if err := agent.RunAgentLoop(cfg); err != nil {
log.Fatal("Agent failed:", err)
}
}
// handleMigration checks and executes migrations if needed
func handleMigration(configPath string) error {
migrationConfig := migration.NewFileDetectionConfig()
migrationConfig.OldConfigPath = constants.LegacyConfigPath
migrationConfig.OldStatePath = constants.LegacyStatePath
migrationConfig.NewConfigPath = constants.GetAgentConfigDir()
migrationConfig.NewStatePath = constants.GetAgentStateDir()
migrationDetection, err := migration.DetectMigrationRequirements(migrationConfig)
if err != nil {
return fmt.Errorf("failed to detect migration requirements: %w", err)
}
if !migrationDetection.RequiresMigration {
return nil
}
log.Printf("[RedFlag Server Migrator] Migration detected: %s → %s",
migrationDetection.CurrentAgentVersion, version.Version)
log.Printf("[RedFlag Server Migrator] Required migrations: %v",
migrationDetection.RequiredMigrations)
migrationPlan := &migration.MigrationPlan{
Detection: migrationDetection,
TargetVersion: version.Version,
Config: migrationConfig,
BackupPath: constants.GetMigrationBackupDir(),
}
executor := migration.NewMigrationExecutor(migrationPlan, configPath)
result, err := executor.ExecuteMigration()
if err != nil {
log.Printf("[RedFlag Server Migrator] Migration failed: %v", err)
log.Printf("[RedFlag Server Migrator] Backup available at: %s", result.BackupPath)
return err
}
log.Printf("[RedFlag Server Migrator] Migration completed successfully")
if result.RollbackAvailable {
log.Printf("[RedFlag Server Migrator] Rollback available at: %s", result.BackupPath)
}
return nil
}
// handleRegistration handles the agent registration flow
func handleRegistration(cfg *config.Config, serverURL string) error {
// Validate server URL for Windows users
if runtime.GOOS == "windows" && serverURL == "" {
fmt.Println("❌ CONFIGURATION REQUIRED!")
fmt.Println("==================================================================")
fmt.Println("Please configure the server URL before registering:")
fmt.Println("")
fmt.Println("Option 1 - Use the -server flag:")
fmt.Println(" redflag-agent.exe -register -server https://your-server.com")
fmt.Println("")
fmt.Println("Option 2 - Use environment variable:")
fmt.Println(" set REDFLAG_SERVER_URL=https://your-server.com")
fmt.Println(" redflag-agent.exe -register")
fmt.Println("")
fmt.Println("Option 3 - Create a .env file:")
fmt.Println(" REDFLAG_SERVER_URL=https://your-server.com")
fmt.Println("==================================================================")
os.Exit(1)
}
// Use registration package for the actual registration
if err := registration.RegisterAgent(cfg, serverURL); err != nil {
return err
}
fmt.Println("==================================================================")
fmt.Println("🎉 AGENT REGISTRATION SUCCESSFUL!")
fmt.Println("==================================================================")
fmt.Printf("📋 Agent ID: %s\n", cfg.AgentID)
fmt.Printf("🌐 Server: %s\n", cfg.ServerURL)
fmt.Printf("⏱️ Check-in Interval: %ds\n", cfg.CheckInInterval)
fmt.Println("==================================================================")
fmt.Println("💡 Save this Agent ID for your records!")
fmt.Println("🚀 You can now start the agent without flags")
fmt.Println("")
return nil
}

View file

@ -0,0 +1,82 @@
package main
// ethos_emoji_test.go — Tests for emoji in agent main.go log statements.
// D-2 FIXED: emoji removed from token renewal and install result log paths.
// EXCLUDES: registration CLI output and startup banner (exempt).
import (
"os"
"strings"
"testing"
)
func hasEmojiRune(s string) bool {
for _, r := range s {
if r >= 0x1F300 || (r >= 0x2600 && r <= 0x27BF) {
return true
}
}
return false
}
// isExemptLine checks if a line number falls in an exempt range.
// Exempt ranges are user-facing CLI output (registration, startup banner).
func isExemptLine(lineNum int) bool {
// Registration CLI output: ~lines 294-322
if lineNum >= 290 && lineNum <= 330 {
return true
}
// Startup banner: ~lines 691-700
if lineNum >= 685 && lineNum <= 705 {
return true
}
return false
}
func TestMainGoHasEmojiInLogStatements(t *testing.T) {
// POST-FIX: No emoji in non-exempt log statements.
content, err := os.ReadFile("agent/main.go")
if err != nil {
t.Fatalf("failed to read agent/main.go: %v", err)
}
lines := strings.Split(string(content), "\n")
emojiLogCount := 0
for i, line := range lines {
if isExemptLine(i + 1) {
continue
}
trimmed := strings.TrimSpace(line)
isLog := strings.Contains(trimmed, "log.Printf") || strings.Contains(trimmed, "log.Println")
if isLog && hasEmojiRune(trimmed) {
emojiLogCount++
}
}
if emojiLogCount > 0 {
t.Errorf("[ERROR] [agent] [main] D-2 NOT FIXED: %d non-exempt log statements with emoji", emojiLogCount)
}
t.Log("[INFO] [agent] [main] D-2 FIXED: no emoji in non-exempt log statements")
}
func TestMainGoLogStatementsHaveNoEmoji(t *testing.T) {
content, err := os.ReadFile("agent/main.go")
if err != nil {
t.Fatalf("failed to read agent/main.go: %v", err)
}
lines := strings.Split(string(content), "\n")
for i, line := range lines {
if isExemptLine(i + 1) {
continue
}
trimmed := strings.TrimSpace(line)
isLog := strings.Contains(trimmed, "log.Printf") || strings.Contains(trimmed, "log.Println")
if isLog && hasEmojiRune(trimmed) {
t.Errorf("[ERROR] [agent] [main] emoji in non-exempt log at line %d", i+1)
}
}
}

43
agent/go.mod Normal file
View file

@ -0,0 +1,43 @@
module github.com/Fimeg/RedFlag/agent
go 1.26.6
require (
github.com/Microsoft/go-winio v0.4.21
github.com/cilium/ebpf v0.22.0
github.com/denisbrodbeck/machineid v1.0.1
github.com/docker/docker v27.4.1+incompatible
github.com/go-ole/go-ole v1.3.0
github.com/gofrs/uuid/v5 v5.4.0
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f
golang.org/x/sys v0.47.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/time v0.5.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
)

154
agent/go.sum Normal file
View file

@ -0,0 +1,154 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
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/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
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-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
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/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
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/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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f h1:v+bqkkvZj6Oasqi58jzJk03XO0vaXvdb6SS9U1Rbqpw=
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f/go.mod h1:Zt2M6t3i/fnWviIZkuw9wGn2E185P/rWZTqJkIrViGY=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
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/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

245
agent/install.sh Executable file
View file

@ -0,0 +1,245 @@
#!/bin/bash
set -e
# RedFlag Agent Installation Script
# This script installs the RedFlag agent as a systemd service with proper permissions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AGENT_USER="redflag-agent"
AGENT_HOME="/var/lib/redflag-agent"
AGENT_BINARY="/usr/local/bin/redflag-agent"
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
SERVICE_FILE="/etc/systemd/system/redflag-agent.service"
echo "=== RedFlag Agent Installation ==="
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "ERROR: This script must be run as root (use sudo)"
exit 1
fi
# Function to create user if doesn't exist
create_user() {
if id "$AGENT_USER" &>/dev/null; then
echo "✓ User $AGENT_USER already exists"
else
echo "Creating system user $AGENT_USER..."
useradd -r -s /bin/false -d "$AGENT_HOME" -m "$AGENT_USER"
echo "✓ User $AGENT_USER created"
fi
# Add user to docker group for Docker update scanning
if getent group docker &>/dev/null; then
echo "Adding $AGENT_USER to docker group..."
usermod -aG docker "$AGENT_USER"
echo "✓ User $AGENT_USER added to docker group"
else
echo "⚠ Docker group not found - Docker updates will not be available"
echo " (Install Docker first, then reinstall the agent to enable Docker support)"
fi
}
# Function to build agent binary
build_agent() {
echo "Building agent binary..."
cd "$SCRIPT_DIR"
go build -o redflag-agent ./cmd/agent
echo "✓ Agent binary built"
}
# Function to install agent binary
install_binary() {
echo "Installing agent binary to $AGENT_BINARY..."
cp "$SCRIPT_DIR/redflag-agent" "$AGENT_BINARY"
chmod 755 "$AGENT_BINARY"
chown root:root "$AGENT_BINARY"
echo "✓ Agent binary installed"
# Set SELinux context for binary if SELinux is enabled
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "SELinux detected, setting file context for binary..."
restorecon -v "$AGENT_BINARY" || echo "[WARNING] [installer] [selinux] restorecon_failed path=$AGENT_BINARY — continuing"
echo "✓ SELinux context set for binary"
fi
}
# Function to install sudoers configuration
install_sudoers() {
echo "Installing sudoers configuration..."
cat > "$SUDOERS_FILE" <<'EOF'
# RedFlag Agent minimal sudo permissions
# This file is generated automatically during RedFlag agent installation
# APT package management commands
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get update
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
# DNF package management commands
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf makecache
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install --assumeno --downloadonly *
# Docker operations
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
EOF
chmod 440 "$SUDOERS_FILE"
# Validate sudoers file
if visudo -c -f "$SUDOERS_FILE"; then
echo "✓ Sudoers configuration installed and validated"
else
echo "ERROR: Sudoers configuration is invalid"
rm -f "$SUDOERS_FILE"
exit 1
fi
}
# Function to install systemd service
install_service() {
echo "Installing systemd service..."
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=RedFlag Update Agent
After=network.target
[Service]
Type=simple
User=$AGENT_USER
Group=$AGENT_USER
WorkingDirectory=$AGENT_HOME
ExecStart=$AGENT_BINARY
Restart=always
RestartSec=30
# Security hardening
# NoNewPrivileges=true - DISABLED: Prevents sudo from working
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$AGENT_HOME /var/log /etc/aggregator
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
chmod 644 "$SERVICE_FILE"
echo "✓ Systemd service installed"
}
# Function to start and enable service
start_service() {
echo "Reloading systemd daemon..."
systemctl daemon-reload
# Stop service if running
if systemctl is-active --quiet redflag-agent; then
echo "Stopping existing service..."
systemctl stop redflag-agent
fi
echo "Enabling and starting redflag-agent service..."
systemctl enable redflag-agent
systemctl start redflag-agent
# Wait a moment for service to start
sleep 2
echo "✓ Service started"
}
# Function to show status
show_status() {
echo ""
echo "=== Service Status ==="
systemctl status redflag-agent --no-pager -l
echo ""
echo "=== Recent Logs ==="
journalctl -u redflag-agent -n 20 --no-pager
}
# Function to register agent
register_agent() {
local server_url="${1:-http://localhost:8080}"
echo "Registering agent with server at $server_url..."
# Create config directory
mkdir -p /etc/aggregator
# Set SELinux context for config directory if SELinux is enabled
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "Setting SELinux context for config directory..."
restorecon -Rv /etc/aggregator || echo "[WARNING] [installer] [selinux] restorecon_failed path=/etc/aggregator — continuing"
echo "✓ SELinux context set for config directory"
fi
# Register agent (run as regular binary, not as service)
if "$AGENT_BINARY" -register -server "$server_url"; then
echo "✓ Agent registered successfully"
else
echo "ERROR: Agent registration failed"
echo "Please ensure the RedFlag server is running at $server_url"
exit 1
fi
}
# Main installation flow
SERVER_URL="${1:-http://localhost:8080}"
echo "Step 1: Creating system user..."
create_user
echo ""
echo "Step 2: Building agent binary..."
build_agent
echo ""
echo "Step 3: Installing agent binary..."
install_binary
echo ""
echo "Step 4: Registering agent with server..."
register_agent "$SERVER_URL"
echo ""
echo "Step 5: Setting config file permissions..."
chown redflag-agent:redflag-agent /etc/redflag/agent/config.json
chmod 600 /etc/redflag/agent/config.json
echo ""
echo "Step 6: Installing sudoers configuration..."
install_sudoers
echo ""
echo "Step 7: Installing systemd service..."
install_service
echo ""
echo "Step 8: Starting service..."
start_service
echo ""
echo "=== Installation Complete ==="
echo ""
echo "The RedFlag agent is now installed and running as a systemd service."
echo "Server URL: $SERVER_URL"
echo ""
echo "Useful commands:"
echo " - Check status: sudo systemctl status redflag-agent"
echo " - View logs: sudo journalctl -u redflag-agent -f"
echo " - Restart: sudo systemctl restart redflag-agent"
echo " - Stop: sudo systemctl stop redflag-agent"
echo " - Disable: sudo systemctl disable redflag-agent"
echo ""
echo "Note: To re-register with a different server, edit /etc/aggregator/config.json"
echo ""
show_status

View file

@ -0,0 +1,202 @@
package acknowledgment
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// PendingResult represents a command result awaiting acknowledgment
type PendingResult struct {
CommandID string `json:"command_id"`
SentAt time.Time `json:"sent_at"`
RetryCount int `json:"retry_count"`
}
// Tracker manages pending acknowledgments for command results
type Tracker struct {
pending map[string]*PendingResult
mu sync.RWMutex
filePath string
maxAge time.Duration // Max time to keep pending (default 24h)
maxRetries int // Max retries before giving up (default 10)
}
// NewTracker creates a new acknowledgment tracker
func NewTracker(statePath string) *Tracker {
return &Tracker{
pending: make(map[string]*PendingResult),
filePath: filepath.Join(statePath, "pending_acks.json"),
maxAge: 24 * time.Hour,
maxRetries: 10,
}
}
// Load restores pending acknowledgments from disk
func (t *Tracker) Load() error {
t.mu.Lock()
defer t.mu.Unlock()
// If file doesn't exist, that's fine (fresh start)
if _, err := os.Stat(t.filePath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(t.filePath)
if err != nil {
return fmt.Errorf("failed to read pending acks: %w", err)
}
if len(data) == 0 {
return nil // Empty file
}
var pending map[string]*PendingResult
if err := json.Unmarshal(data, &pending); err != nil {
return fmt.Errorf("failed to parse pending acks: %w", err)
}
t.pending = pending
return nil
}
// Save persists pending acknowledgments to disk
func (t *Tracker) Save() error {
t.mu.RLock()
defer t.mu.RUnlock()
// Ensure directory exists
dir := filepath.Dir(t.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create ack directory: %w", err)
}
data, err := json.MarshalIndent(t.pending, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal pending acks: %w", err)
}
if err := os.WriteFile(t.filePath, data, 0600); err != nil {
return fmt.Errorf("failed to write pending acks: %w", err)
}
return nil
}
// Add marks a command result as pending acknowledgment
func (t *Tracker) Add(commandID string) {
t.mu.Lock()
defer t.mu.Unlock()
t.pending[commandID] = &PendingResult{
CommandID: commandID,
SentAt: time.Now().UTC(),
RetryCount: 0,
}
}
// Acknowledge marks command results as acknowledged and removes them
func (t *Tracker) Acknowledge(commandIDs []string) {
t.mu.Lock()
defer t.mu.Unlock()
for _, id := range commandIDs {
delete(t.pending, id)
}
}
// GetPending returns list of command IDs awaiting acknowledgment
func (t *Tracker) GetPending() []string {
t.mu.RLock()
defer t.mu.RUnlock()
ids := make([]string, 0, len(t.pending))
for id := range t.pending {
ids = append(ids, id)
}
return ids
}
// IncrementRetry increments retry count for a command
func (t *Tracker) IncrementRetry(commandID string) {
t.mu.Lock()
defer t.mu.Unlock()
if result, exists := t.pending[commandID]; exists {
result.RetryCount++
}
}
// DroppedResult describes a pending result-ack that Cleanup abandoned. The agent
// only ever redelivers the command ID (not the result payload), so a result the
// server never recorded can sit here unrecoverable until it ages out — and a drop
// is the silent loss of an auditable event. Cleanup returns these so the caller
// journals each one inward (ETHOS #1) rather than discarding it to /dev/null.
type DroppedResult struct {
CommandID string
Reason string // "max_age" | "max_retries"
RetryCount int
AgeSeconds int
}
// Cleanup removes old or over-retried pending results and returns what it dropped
// so the loss can be recorded as history. Returns an empty slice when nothing aged out.
func (t *Tracker) Cleanup() []DroppedResult {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now().UTC()
var dropped []DroppedResult
for id, result := range t.pending {
age := now.Sub(result.SentAt)
switch {
case age > t.maxAge:
dropped = append(dropped, DroppedResult{CommandID: id, Reason: "max_age", RetryCount: result.RetryCount, AgeSeconds: int(age.Seconds())})
delete(t.pending, id)
case result.RetryCount >= t.maxRetries:
dropped = append(dropped, DroppedResult{CommandID: id, Reason: "max_retries", RetryCount: result.RetryCount, AgeSeconds: int(age.Seconds())})
delete(t.pending, id)
}
}
return dropped
}
// Stats returns statistics about pending acknowledgments
func (t *Tracker) Stats() Stats {
t.mu.RLock()
defer t.mu.RUnlock()
stats := Stats{
Total: len(t.pending),
}
now := time.Now().UTC()
for _, result := range t.pending {
age := now.Sub(result.SentAt)
if age > 1*time.Hour {
stats.OlderThan1Hour++
}
if result.RetryCount > 0 {
stats.WithRetries++
}
if result.RetryCount >= 5 {
stats.HighRetries++
}
}
return stats
}
// Stats holds statistics about pending acknowledgments
type Stats struct {
Total int
OlderThan1Hour int
WithRetries int
HighRetries int
}

View file

@ -0,0 +1,54 @@
package agent
import (
"errors"
"fmt"
"testing"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
)
// TestClassifyFailure locks in the BUG-014 policy: dead credentials and
// machine-binding mismatches are terminal; everything else is transient.
func TestClassifyFailure(t *testing.T) {
cases := []struct {
name string
err error
want failureClass
}{
{"machine mismatch", client.ErrMachineMismatch, failureTerminal},
{"refresh token invalid", client.ErrRefreshTokenInvalid, failureTerminal},
{"wrapped machine mismatch", fmt.Errorf("get commands: %w", client.ErrMachineMismatch), failureTerminal},
{"wrapped refresh invalid", fmt.Errorf("renew: %w", client.ErrRefreshTokenInvalid), failureTerminal},
{"unauthorized alone is not terminal (renewal may fix it)", client.ErrUnauthorized, failureTransient},
{"plain network error", errors.New("dial tcp: connection refused"), failureTransient},
{"nil-adjacent generic error", errors.New("502 bad gateway"), failureTransient},
}
for _, tc := range cases {
if got := classifyFailure(tc.err); got != tc.want {
t.Errorf("%s: classifyFailure() = %v, want %v", tc.name, got, tc.want)
}
}
}
// TestDelayForFailure verifies the policy curves: terminal is a long flat
// delay independent of attempt count; transient follows the jittered
// exponential bounded by base and max.
func TestDelayForFailure(t *testing.T) {
base := 5 * time.Second
max := 5 * time.Minute
for _, attempt := range []int{1, 3, 50} {
if got := delayForFailure(failureTerminal, attempt, base, max); got != terminalRetryDelay {
t.Errorf("terminal attempt %d: delay = %s, want flat %s", attempt, got, terminalRetryDelay)
}
}
for attempt := 1; attempt <= 30; attempt++ {
got := delayForFailure(failureTransient, attempt, base, max)
if got < base || got > max {
t.Errorf("transient attempt %d: delay %s outside [%s, %s]", attempt, got, base, max)
}
}
}

1232
agent/internal/agent/loop.go Normal file

File diff suppressed because it is too large Load diff

4
agent/internal/cache/cache.go vendored Normal file
View file

@ -0,0 +1,4 @@
package cache
// Init initializes the cache module
func Init() {}

172
agent/internal/cache/hash_cache.go vendored Normal file
View file

@ -0,0 +1,172 @@
package cache
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// HashCache provides LRU caching for expected package hashes
type HashCache struct {
mu sync.RWMutex
hashes map[string]string
maxSize int
evicted map[string]time.Time
}
// NewHashCache creates a new hash cache
func NewHashCache(maxSize int) *HashCache {
return &HashCache{
hashes: make(map[string]string),
maxSize: maxSize,
evicted: make(map[string]time.Time),
}
}
// key creates a cache key from package type and name
func (c *HashCache) key(packageType, packageName, version string) string {
return fmt.Sprintf("%s:%s:%s", packageType, packageName, version)
}
// Get retrieves a cached hash
func (c *HashCache) Get(packageType, packageName, version string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.key(packageType, packageName, version)
hash, ok := c.hashes[key]
return hash, ok
}
// Set stores a hash in the cache
func (c *HashCache) Set(packageType, packageName, version, hash string) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.key(packageType, packageName, version)
// Remove old entry if exists
if _, ok := c.hashes[key]; ok {
c.evict(key)
}
c.hashes[key] = hash
c.touch(key)
// Enforce size limit
for len(c.hashes) > c.maxSize {
c.evictOldest()
}
}
// Delete removes a hash from the cache
func (c *HashCache) Delete(packageType, packageName, version string) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.key(packageType, packageName, version)
delete(c.hashes, key)
}
// evict removes a key from the cache
func (c *HashCache) evict(key string) {
delete(c.hashes, key)
delete(c.evicted, key)
}
// touch updates the access time for a key
func (c *HashCache) touch(key string) {
c.evicted[key] = time.Now()
}
// evictOldest removes the oldest accessed key when cache is full
func (c *HashCache) evictOldest() {
var oldest string
var oldestTime time.Time
c.mu.RLock()
for key, t := range c.evicted {
if oldest == "" || t.Before(oldestTime) {
oldest = key
oldestTime = t
}
}
c.mu.RUnlock()
if oldest != "" {
c.evict(oldest)
}
}
// VerifyHashFromCache downloads a package only if not cached, verifies hash, and caches result
func (c *HashCache) VerifyHashFromCache(packageType, packageName, version, expectedSHA256 string) error {
if expectedSHA256 == "" {
return nil
}
// Check cache first
if cachedHash, cached := c.Get(packageType, packageName, version); cached {
if cachedHash == expectedSHA256 {
return nil // Hash verified from cache
}
// Cache has different hash - re-download to update cache
}
// Download and compute hash
resp, err := http.Get(fmt.Sprintf("%s/api/v1/downloads/artifact?ecosystem=%s&package_name=%s&version=%s",
getDownloaderURL(), packageType, packageName, version))
if err != nil {
return fmt.Errorf("failed to download package: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
// Compute hash while streaming
h := sha256.New()
_, err = io.Copy(h, resp.Body)
if err != nil {
return fmt.Errorf("failed to read package: %w", err)
}
computedSHA256 := hex.EncodeToString(h.Sum(nil))
// Verify against expected
if computedSHA256 != expectedSHA256 {
return fmt.Errorf("package hash mismatch: expected %s, got %s", expectedSHA256, computedSHA256)
}
// Cache the verified hash
c.Set(packageType, packageName, version, computedSHA256)
return nil
}
// Clear evicts all cached hashes
func (c *HashCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.hashes = make(map[string]string)
c.evicted = make(map[string]time.Time)
}
// Size returns current cache size
func (c *HashCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.hashes)
}
// getDownloaderURL returns the URL of the download handler
// This is a simplified version - in production, use the download handler's getServerURL method
func getDownloaderURL() string {
// Default to localhost for local testing
// In production, this would come from config
return "http://localhost:8080"
}

314
agent/internal/cache/local.go vendored Normal file
View file

@ -0,0 +1,314 @@
package cache
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/gofrs/uuid/v5"
)
// LocalCache stores scan results locally for offline viewing
type LocalCache struct {
LastScanTime time.Time `json:"last_scan_time"`
LastCheckIn time.Time `json:"last_check_in"`
AgentID uuid.UUID `json:"agent_id"`
ServerURL string `json:"server_url"`
UpdateCount int `json:"update_count"`
Updates []client.UpdateReportItem `json:"updates"`
AgentStatus string `json:"agent_status"`
Summary UpdateSummary `json:"summary"`
Scanners map[string]ScannerState `json:"scanners,omitempty"`
Capabilities CapabilityTokenState `json:"capabilities"`
LastUpdated time.Time `json:"last_updated"`
}
// UpdateSummary is the local rollup consumed by status surfaces.
type UpdateSummary struct {
Total int `json:"total"`
ByEcosystem map[string]int `json:"by_ecosystem,omitempty"`
BySeverity map[string]int `json:"by_severity,omitempty"`
}
// ScannerState records the latest local scan status for one scanner.
type ScannerState struct {
Name string `json:"name"`
Status string `json:"status"`
LastScanTime time.Time `json:"last_scan_time,omitempty"`
LastDurationMS int64 `json:"last_duration_ms,omitempty"`
LastError string `json:"last_error,omitempty"`
UpdateCount int `json:"update_count"`
}
// CapabilityTokenState is count-only local state for the supply-chain token path.
// It intentionally excludes token IDs, token payloads, signatures, and artifacts.
type CapabilityTokenState struct {
LastFetchTime time.Time `json:"last_fetch_time,omitempty"`
LastProcessTime time.Time `json:"last_process_time,omitempty"`
PendingCount int `json:"pending_count"`
LastFetchedCount int `json:"last_fetched_count"`
LastProcessedCount int `json:"last_processed_count"`
LastFailedCount int `json:"last_failed_count"`
LastError string `json:"last_error,omitempty"`
}
// cacheFile is the file where scan results are cached
const cacheFile = "last_scan.json"
// GetCachePath returns the full path to the cache file
func GetCachePath() string {
return filepath.Join(constants.GetAgentCacheDir(), cacheFile)
}
// Load reads the local cache from disk
func Load() (*LocalCache, error) {
return LoadFromPath(GetCachePath())
}
// LoadFromPath reads a local cache file from disk.
func LoadFromPath(cachePath string) (*LocalCache, error) {
// Check if cache file exists
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
// Return empty cache if file doesn't exist
return &LocalCache{}, nil
}
// Read cache file
data, err := os.ReadFile(cachePath)
if err != nil {
return nil, fmt.Errorf("failed to read cache file: %w", err)
}
var cache LocalCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, fmt.Errorf("failed to parse cache file: %w", err)
}
return &cache, nil
}
// Save writes the local cache to disk
func (c *LocalCache) Save() error {
return c.SaveToPath(GetCachePath())
}
// SaveToPath writes a local cache file to disk.
func (c *LocalCache) SaveToPath(cachePath string) error {
// Ensure cache directory exists
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
c.refreshSummary()
if c.LastUpdated.IsZero() {
c.LastUpdated = time.Now().UTC()
}
// Marshal cache to JSON with indentation
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal cache: %w", err)
}
// Write cache file with restricted permissions
if err := os.WriteFile(cachePath, data, 0600); err != nil {
return fmt.Errorf("failed to write cache file: %w", err)
}
return nil
}
// UpdateScanResults updates the cache with new scan results
func (c *LocalCache) UpdateScanResults(updates []client.UpdateReportItem) {
now := time.Now().UTC()
c.LastScanTime = now
c.LastUpdated = now
c.Updates = updates
c.UpdateCount = len(updates)
c.refreshSummary()
}
// UpdateCheckIn updates the last check-in time
func (c *LocalCache) UpdateCheckIn() {
now := time.Now().UTC()
c.LastCheckIn = now
c.LastUpdated = now
}
// SetAgentInfo sets agent identification information
func (c *LocalCache) SetAgentInfo(agentID uuid.UUID, serverURL string) {
c.AgentID = agentID
c.ServerURL = serverURL
c.LastUpdated = time.Now().UTC()
}
// SetAgentStatus sets the current agent status
func (c *LocalCache) SetAgentStatus(status string) {
c.AgentStatus = status
c.LastUpdated = time.Now().UTC()
}
// RecordScannerResult updates the local read model with one scanner execution.
// Successful package-update scans replace only their ecosystem slice, so a
// scan_apt command does not erase the latest DNF/Winget/Docker observations.
func (c *LocalCache) RecordScannerResult(scannerName, status string, updates []client.UpdateReportItem, scanErr error, duration time.Duration, affectsUpdateList bool) {
name := normalizeScannerName(scannerName)
if status == "" {
status = "unknown"
}
if c.Scanners == nil {
c.Scanners = make(map[string]ScannerState)
}
now := time.Now().UTC()
lastError := ""
if scanErr != nil {
lastError = scanErr.Error()
}
c.Scanners[name] = ScannerState{
Name: name,
Status: status,
LastScanTime: now,
LastDurationMS: duration.Milliseconds(),
LastError: lastError,
UpdateCount: len(updates),
}
c.LastUpdated = now
if affectsUpdateList && status == "success" {
c.replaceScannerUpdates(name, updates)
c.LastScanTime = now
c.UpdateCount = len(c.Updates)
c.refreshSummary()
}
}
// RecordCapabilityTokenFetch records the count of tokens fetched on the latest poll.
func (c *LocalCache) RecordCapabilityTokenFetch(fetched int, fetchErr error) {
now := time.Now().UTC()
c.Capabilities.LastFetchTime = now
c.Capabilities.LastFetchedCount = fetched
c.Capabilities.PendingCount = fetched
c.Capabilities.LastError = ""
if fetchErr != nil {
c.Capabilities.LastError = fetchErr.Error()
}
c.LastUpdated = now
}
// RecordCapabilityTokenProcess records count-only token processing results.
func (c *LocalCache) RecordCapabilityTokenProcess(processed, failed int) {
now := time.Now().UTC()
c.Capabilities.LastProcessTime = now
c.Capabilities.LastProcessedCount = processed
c.Capabilities.LastFailedCount = failed
remaining := c.Capabilities.LastFetchedCount - processed - failed
if remaining < 0 {
remaining = 0
}
c.Capabilities.PendingCount = remaining
c.LastUpdated = now
}
// IsExpired checks if the cache is older than the specified duration
func (c *LocalCache) IsExpired(maxAge time.Duration) bool {
return time.Since(c.LastScanTime) > maxAge
}
// GetUpdatesByType returns updates filtered by package type
func (c *LocalCache) GetUpdatesByType(packageType string) []client.UpdateReportItem {
var filtered []client.UpdateReportItem
for _, update := range c.Updates {
if update.PackageType == packageType {
filtered = append(filtered, update)
}
}
return filtered
}
// Clear clears the cache
func (c *LocalCache) Clear() {
c.LastScanTime = time.Time{}
c.LastCheckIn = time.Time{}
c.UpdateCount = 0
c.Updates = []client.UpdateReportItem{}
c.AgentStatus = ""
c.Summary = UpdateSummary{}
c.Scanners = nil
c.Capabilities = CapabilityTokenState{}
c.LastUpdated = time.Now().UTC()
}
func (c *LocalCache) replaceScannerUpdates(scannerName string, updates []client.UpdateReportItem) {
packageTypes := packageTypesForScanner(scannerName, updates)
if len(packageTypes) == 0 {
return
}
filtered := make([]client.UpdateReportItem, 0, len(c.Updates)+len(updates))
for _, update := range c.Updates {
if _, replace := packageTypes[normalizeScannerName(update.PackageType)]; replace {
continue
}
filtered = append(filtered, update)
}
filtered = append(filtered, updates...)
c.Updates = filtered
}
func (c *LocalCache) refreshSummary() {
summary := UpdateSummary{
Total: len(c.Updates),
ByEcosystem: make(map[string]int),
BySeverity: make(map[string]int),
}
for _, update := range c.Updates {
ecosystem := normalizeScannerName(update.PackageType)
if ecosystem == "" {
ecosystem = "unknown"
}
severity := strings.ToLower(strings.TrimSpace(update.Severity))
if severity == "" {
severity = "unknown"
}
summary.ByEcosystem[ecosystem]++
summary.BySeverity[severity]++
}
if len(summary.ByEcosystem) == 0 {
summary.ByEcosystem = nil
}
if len(summary.BySeverity) == 0 {
summary.BySeverity = nil
}
c.UpdateCount = summary.Total
c.Summary = summary
}
func packageTypesForScanner(scannerName string, updates []client.UpdateReportItem) map[string]struct{} {
packageTypes := make(map[string]struct{})
switch normalizeScannerName(scannerName) {
case "apt", "dnf", "pacman", "docker", "winget":
packageTypes[normalizeScannerName(scannerName)] = struct{}{}
case "windows":
packageTypes["windows_update"] = struct{}{}
packageTypes["windows_update_history"] = struct{}{}
}
for _, update := range updates {
if packageType := normalizeScannerName(update.PackageType); packageType != "" {
packageTypes[packageType] = struct{}{}
}
}
return packageTypes
}
func normalizeScannerName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

139
agent/internal/cache/local_test.go vendored Normal file
View file

@ -0,0 +1,139 @@
package cache
import (
"errors"
"path/filepath"
"testing"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
)
func TestSaveLoadFromPathRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "last_scan.json")
localCache := &LocalCache{
AgentStatus: "online",
Updates: []client.UpdateReportItem{
{PackageType: "apt", PackageName: "openssl", Severity: "important"},
},
}
localCache.UpdateScanResults(localCache.Updates)
if err := localCache.SaveToPath(path); err != nil {
t.Fatalf("SaveToPath() error = %v", err)
}
loaded, err := LoadFromPath(path)
if err != nil {
t.Fatalf("LoadFromPath() error = %v", err)
}
if loaded.AgentStatus != "online" {
t.Fatalf("AgentStatus = %q, want online", loaded.AgentStatus)
}
if loaded.Summary.Total != 1 {
t.Fatalf("Summary.Total = %d, want 1", loaded.Summary.Total)
}
if loaded.Summary.ByEcosystem["apt"] != 1 {
t.Fatalf("Summary.ByEcosystem[apt] = %d, want 1", loaded.Summary.ByEcosystem["apt"])
}
if loaded.Summary.BySeverity["important"] != 1 {
t.Fatalf("Summary.BySeverity[important] = %d, want 1", loaded.Summary.BySeverity["important"])
}
}
func TestRecordScannerResultReplacesOnlyThatScanner(t *testing.T) {
localCache := &LocalCache{}
localCache.UpdateScanResults([]client.UpdateReportItem{
{PackageType: "apt", PackageName: "openssl", Severity: "important"},
{PackageType: "dnf", PackageName: "kernel", Severity: "critical"},
})
localCache.RecordScannerResult("apt", "success", []client.UpdateReportItem{
{PackageType: "apt", PackageName: "curl", Severity: "moderate"},
}, nil, 250*time.Millisecond, true)
if len(localCache.Updates) != 2 {
t.Fatalf("len(Updates) = %d, want 2", len(localCache.Updates))
}
if got := packageNames(localCache.Updates); got["openssl"] {
t.Fatalf("stale apt package remained in updates: %#v", localCache.Updates)
}
if got := packageNames(localCache.Updates); !got["curl"] || !got["kernel"] {
t.Fatalf("updates = %#v, want curl and kernel", localCache.Updates)
}
if localCache.Summary.ByEcosystem["apt"] != 1 || localCache.Summary.ByEcosystem["dnf"] != 1 {
t.Fatalf("summary by ecosystem = %#v, want apt=1 dnf=1", localCache.Summary.ByEcosystem)
}
if localCache.Scanners["apt"].Status != "success" {
t.Fatalf("scanner status = %q, want success", localCache.Scanners["apt"].Status)
}
}
func TestRecordScannerResultSuccessfulEmptyScanClearsScannerUpdates(t *testing.T) {
localCache := &LocalCache{}
localCache.UpdateScanResults([]client.UpdateReportItem{
{PackageType: "windows_update", PackageName: "KB123", Severity: "important"},
{PackageType: "winget", PackageName: "Git.Git", Severity: "moderate"},
})
localCache.RecordScannerResult("windows", "success", nil, nil, 100*time.Millisecond, true)
if len(localCache.Updates) != 1 {
t.Fatalf("len(Updates) = %d, want 1", len(localCache.Updates))
}
if localCache.Updates[0].PackageType != "winget" {
t.Fatalf("remaining PackageType = %q, want winget", localCache.Updates[0].PackageType)
}
if localCache.Summary.ByEcosystem["windows_update"] != 0 {
t.Fatalf("windows_update summary = %d, want 0", localCache.Summary.ByEcosystem["windows_update"])
}
}
func TestRecordScannerResultFailureDoesNotClearStaleUpdates(t *testing.T) {
localCache := &LocalCache{}
localCache.UpdateScanResults([]client.UpdateReportItem{
{PackageType: "apt", PackageName: "openssl", Severity: "important"},
})
localCache.RecordScannerResult("apt", "failed", nil, errors.New("scanner failed"), time.Second, true)
if len(localCache.Updates) != 1 {
t.Fatalf("len(Updates) = %d, want stale update retained", len(localCache.Updates))
}
if localCache.Scanners["apt"].LastError != "scanner failed" {
t.Fatalf("LastError = %q, want scanner failed", localCache.Scanners["apt"].LastError)
}
}
func TestRecordCapabilityTokenCounts(t *testing.T) {
localCache := &LocalCache{}
localCache.RecordCapabilityTokenFetch(3, nil)
localCache.RecordCapabilityTokenProcess(2, 1)
if localCache.Capabilities.LastFetchedCount != 3 {
t.Fatalf("LastFetchedCount = %d, want 3", localCache.Capabilities.LastFetchedCount)
}
if localCache.Capabilities.LastProcessedCount != 2 {
t.Fatalf("LastProcessedCount = %d, want 2", localCache.Capabilities.LastProcessedCount)
}
if localCache.Capabilities.LastFailedCount != 1 {
t.Fatalf("LastFailedCount = %d, want 1", localCache.Capabilities.LastFailedCount)
}
if localCache.Capabilities.PendingCount != 0 {
t.Fatalf("PendingCount = %d, want 0", localCache.Capabilities.PendingCount)
}
localCache.RecordCapabilityTokenFetch(0, errors.New("server unavailable"))
if localCache.Capabilities.LastError != "server unavailable" {
t.Fatalf("LastError = %q, want server unavailable", localCache.Capabilities.LastError)
}
}
func packageNames(updates []client.UpdateReportItem) map[string]bool {
names := make(map[string]bool, len(updates))
for _, update := range updates {
names[update.PackageName] = true
}
return names
}

View file

@ -0,0 +1,15 @@
package capability
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
)
// KeyIDFor returns the authority key fingerprint: hex(sha256(pubkey)[:16]).
// Matches SigningService.GetPublicKeyFingerprint and the executor's key_id_for.
// Split out of token.go so retiring Token does not strand the mutation protocol.
func KeyIDFor(pub ed25519.PublicKey) string {
hash := sha256.Sum256(pub)
return hex.EncodeToString(hash[:16])
}

View file

@ -0,0 +1,423 @@
package capability
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
)
const (
// MutationProtocolVersion belongs to the manifest namespace, independently
// of the current closure-based Token format. Backends opt into the envelope
// path explicitly; pacman begins at the helper boundary.
MutationProtocolVersion = 1
// MaxAuthorizationLifetimeSeconds is the ceiling on expires_at - not_before.
// Derived, not chosen: it is DefaultTokenTTL, the fleet minter's window
// (server/internal/services/capability_minter.go). Standalone mint is
// tighter still at 600s. Doctrine, not a knob — an authority that wants a
// standing capability has to say so by minting again.
MaxAuthorizationLifetimeSeconds = 3600
manifestDomain = "redflag.mutation-manifest"
actionDomain = "redflag.resolved-action"
evidenceDomain = "redflag.evidence"
authorizationDomain = "redflag.mutation-authorization"
receiptDomain = "redflag.mutation-receipt"
)
// ResolvedAction carries exact backend-owned UTF-8 JSON bytes. The common
// protocol signs those bytes but does not reinterpret pacman, WUA, Winget,
// Docker, or self-update semantics into a fictional universal artifact.
type ResolvedAction struct {
Kind string `json:"kind"`
Identity string `json:"identity"`
Payload string `json:"payload"`
}
// Evidence identifies provenance or policy evidence by digest. Execution
// location belongs in the resolved action payload, never in this trust class.
type Evidence struct {
Kind string `json:"kind"`
Digest string `json:"digest"`
}
// MutationManifest is the immutable description an authority approves and
// an executor later receives unchanged.
//
// TargetID MUST be the locally provisioned RedFlag agent identity. The generic
// name is deliberate: a later protocol may define another target namespace.
type MutationManifest struct {
ProtocolVersion int `json:"protocol_version"`
OperationID string `json:"operation_id"`
TargetID string `json:"target_id"`
Backend string `json:"backend"`
Operation string `json:"operation"`
ResolvedActions []ResolvedAction `json:"resolved_actions"`
Evidence []Evidence `json:"evidence"`
}
// MutationAuthorization binds an authority decision to one manifest and body.
// Every field except Signature is inside CanonicalMessage, including KeyID.
type MutationAuthorization struct {
ProtocolVersion int `json:"protocol_version"`
AuthorizationID string `json:"authorization_id"`
ManifestHash string `json:"manifest_hash"`
AuthorityKind string `json:"authority_kind"`
AuthorityID string `json:"authority_id"`
TargetID string `json:"target_id"`
IssuedAt int64 `json:"issued_at"`
NotBefore int64 `json:"not_before"`
ExpiresAt int64 `json:"expires_at"`
Decision string `json:"decision"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
// MutationEnvelope is the indivisible object handed across an authority or
// executor boundary. Verification always recomputes the manifest hash from the
// manifest carried beside its authorization.
type MutationEnvelope struct {
Manifest MutationManifest `json:"manifest"`
Authorization MutationAuthorization `json:"authorization"`
}
func writeLP(buf *bytes.Buffer, value []byte) {
buf.WriteString(strconv.Itoa(len(value)))
buf.WriteByte(':')
buf.Write(value)
}
func canonicalRecord(domain string, values ...string) []byte {
var buf bytes.Buffer
writeLP(&buf, []byte(domain))
for _, value := range values {
writeLP(&buf, []byte(value))
}
return buf.Bytes()
}
func (a ResolvedAction) canonicalBytes() []byte {
return canonicalRecord(actionDomain, a.Kind, a.Identity, a.Payload)
}
func (e Evidence) canonicalBytes() []byte {
return canonicalRecord(evidenceDomain, e.Kind, e.Digest)
}
func sortedRecords[T any](values []T, encode func(T) []byte) [][]byte {
records := make([][]byte, 0, len(values))
for _, value := range values {
records = append(records, encode(value))
}
sort.Slice(records, func(i, j int) bool { return bytes.Compare(records[i], records[j]) < 0 })
return records
}
// CanonicalBytes is domain-separated and length-prefixed. Action and evidence
// ordering is irrelevant, while exact duplicates remain present and therefore
// change the hash. No set conversion is permitted here.
func (m MutationManifest) CanonicalBytes() []byte {
var buf bytes.Buffer
writeLP(&buf, []byte(manifestDomain))
for _, value := range []string{
strconv.Itoa(m.ProtocolVersion),
m.OperationID,
m.TargetID,
m.Backend,
m.Operation,
} {
writeLP(&buf, []byte(value))
}
actions := sortedRecords(m.ResolvedActions, func(a ResolvedAction) []byte { return a.canonicalBytes() })
writeLP(&buf, []byte(strconv.Itoa(len(actions))))
for _, action := range actions {
writeLP(&buf, action)
}
evidence := sortedRecords(m.Evidence, func(e Evidence) []byte { return e.canonicalBytes() })
writeLP(&buf, []byte(strconv.Itoa(len(evidence))))
for _, item := range evidence {
writeLP(&buf, item)
}
return buf.Bytes()
}
func (m MutationManifest) Hash() string {
digest := sha256.Sum256(m.CanonicalBytes())
return hex.EncodeToString(digest[:])
}
func (m MutationManifest) Validate() error {
if m.ProtocolVersion != MutationProtocolVersion {
return fmt.Errorf("mutation protocol: unsupported manifest version %d", m.ProtocolVersion)
}
for _, field := range [][2]string{
{"operation_id", m.OperationID},
{"target_id", m.TargetID},
{"backend", m.Backend},
{"operation", m.Operation},
} {
name, value := field[0], field[1]
if value == "" {
return fmt.Errorf("mutation protocol: manifest %s is empty", name)
}
}
if len(m.ResolvedActions) == 0 {
return fmt.Errorf("mutation protocol: manifest has no resolved actions")
}
for i, action := range m.ResolvedActions {
if action.Kind == "" || action.Identity == "" || action.Payload == "" {
return fmt.Errorf("mutation protocol: resolved action %d is incomplete", i)
}
if !json.Valid([]byte(action.Payload)) {
return fmt.Errorf("mutation protocol: resolved action %d payload is not JSON", i)
}
}
for i, evidence := range m.Evidence {
if evidence.Kind == "" {
return fmt.Errorf("mutation protocol: evidence %d kind is empty", i)
}
decoded, err := hex.DecodeString(evidence.Digest)
if err != nil || len(decoded) != sha256.Size {
return fmt.Errorf("mutation protocol: evidence %d digest is not SHA-256 hex", i)
}
}
return nil
}
func (a MutationAuthorization) CanonicalMessage() []byte {
return canonicalRecord(
authorizationDomain,
strconv.Itoa(a.ProtocolVersion),
a.AuthorizationID,
a.ManifestHash,
a.AuthorityKind,
a.AuthorityID,
a.TargetID,
strconv.FormatInt(a.IssuedAt, 10),
strconv.FormatInt(a.NotBefore, 10),
strconv.FormatInt(a.ExpiresAt, 10),
a.Decision,
a.KeyID,
)
}
// IsCanonicalUUIDv4 reports whether s is 8-4-4-4-12 lowercase hex with the
// version (4) and variant (8/9/a/b) nibbles set. Same discipline the standalone
// mint already applies to request_id, applied here before authorization_id can
// become an executor replay key: a newline-delimited replay ledger matched by
// exact line has no defence against an identifier that contains a newline.
func IsCanonicalUUIDv4(s string) bool {
if len(s) != 36 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if i == 8 || i == 13 || i == 18 || i == 23 {
if c != '-' {
return false
}
continue
}
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
if !isHex {
return false
}
}
if s[14] != '4' {
return false
}
switch s[19] {
case '8', '9', 'a', 'b':
return true
}
return false
}
func (a MutationAuthorization) validateShape() error {
if a.ProtocolVersion != MutationProtocolVersion {
return fmt.Errorf("mutation protocol: unsupported authorization version %d", a.ProtocolVersion)
}
for _, field := range [][2]string{
{"authorization_id", a.AuthorizationID},
{"authority_kind", a.AuthorityKind},
{"authority_id", a.AuthorityID},
{"target_id", a.TargetID},
{"decision", a.Decision},
} {
name, value := field[0], field[1]
if value == "" {
return fmt.Errorf("mutation protocol: authorization %s is empty", name)
}
}
if !IsCanonicalUUIDv4(a.AuthorizationID) {
return fmt.Errorf("mutation protocol: authorization_id is not a canonical UUID v4")
}
if a.IssuedAt <= 0 || a.NotBefore < a.IssuedAt || a.ExpiresAt <= a.NotBefore {
return fmt.Errorf("mutation protocol: invalid authorization time window")
}
if a.ExpiresAt-a.NotBefore > MaxAuthorizationLifetimeSeconds {
return fmt.Errorf("mutation protocol: authorization lifetime %ds exceeds the %ds ceiling",
a.ExpiresAt-a.NotBefore, MaxAuthorizationLifetimeSeconds)
}
return nil
}
func (a *MutationAuthorization) Sign(priv ed25519.PrivateKey, manifest MutationManifest) error {
if len(priv) != ed25519.PrivateKeySize {
return fmt.Errorf("mutation protocol: invalid private key size %d", len(priv))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
a.ManifestHash = manifest.Hash()
a.KeyID = KeyIDFor(priv.Public().(ed25519.PublicKey))
a.Signature = hex.EncodeToString(ed25519.Sign(priv, a.CanonicalMessage()))
return nil
}
func (a MutationAuthorization) Verify(pub ed25519.PublicKey, manifest MutationManifest) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("mutation protocol: invalid public key size %d", len(pub))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
if a.ManifestHash != manifest.Hash() {
return fmt.Errorf("mutation protocol: authorization manifest hash mismatch")
}
if a.KeyID != KeyIDFor(pub) {
return fmt.Errorf("mutation protocol: authorization key id mismatch")
}
signature, err := hex.DecodeString(a.Signature)
if err != nil || len(signature) != ed25519.SignatureSize {
return fmt.Errorf("mutation protocol: malformed signature")
}
if !ed25519.Verify(pub, a.CanonicalMessage(), signature) {
return fmt.Errorf("mutation protocol: signature verification failed")
}
return nil
}
// VerifyForExecutionAt adds the executor's decision and time checks to the
// cryptographic envelope verification.
func (a MutationAuthorization) VerifyForExecutionAt(pub ed25519.PublicKey, manifest MutationManifest, now int64) error {
if err := a.Verify(pub, manifest); err != nil {
return err
}
if a.Decision != "allow" {
return fmt.Errorf("mutation protocol: authorization decision is %q", a.Decision)
}
if now < a.NotBefore || now > a.ExpiresAt {
return fmt.Errorf("mutation protocol: authorization is outside its time window")
}
return nil
}
func (e MutationEnvelope) VerifyForExecutionAt(pub ed25519.PublicKey, now int64) error {
return e.Authorization.VerifyForExecutionAt(pub, e.Manifest, now)
}
// MutationReceipt is the response half of the contract: what the privileged
// executor did with one envelope. It carries the audit join ARCH-002 names —
// operation ID, manifest hash, authorization ID — so a local receipt and a
// server history row can be joined without either guessing.
//
// It is not signed. The executor is not a second authority; this is a record
// produced inside the trust boundary that already ran the operation. Decision
// and Reason keep the PolicyResult taxonomy rather than inventing a new one.
type MutationReceipt struct {
ProtocolVersion int `json:"protocol_version"`
OperationID string `json:"operation_id"`
ManifestHash string `json:"manifest_hash"`
AuthorizationID string `json:"authorization_id"`
TargetID string `json:"target_id"`
Backend string `json:"backend"`
Operation string `json:"operation"`
Decision string `json:"decision"` // executed | denied | failed
Reason string `json:"reason"`
Executed bool `json:"executed"`
VerifiedActions int `json:"verified_actions"`
ExitCode int `json:"exit_code"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// CanonicalBytes pins the receipt the same way the manifest is pinned, so a
// ledger can digest one without re-deriving field order from JSON. Every field
// is present even when empty — a refusal before parse still produces a receipt,
// and its emptiness is part of the record.
func (r MutationReceipt) CanonicalBytes() []byte {
return canonicalRecord(
receiptDomain,
strconv.Itoa(r.ProtocolVersion),
r.OperationID,
r.ManifestHash,
r.AuthorizationID,
r.TargetID,
r.Backend,
r.Operation,
r.Decision,
r.Reason,
strconv.FormatBool(r.Executed),
strconv.Itoa(r.VerifiedActions),
strconv.Itoa(r.ExitCode),
r.Error,
strconv.FormatInt(r.Timestamp, 10),
)
}
func (r MutationReceipt) Digest() string {
digest := sha256.Sum256(r.CanonicalBytes())
return hex.EncodeToString(digest[:])
}
// MutationOutcome is what the executor did, separate from which envelope it
// did it to.
type MutationOutcome struct {
Decision string
Reason string
Executed bool
VerifiedActions int
ExitCode int
Detail string
}
// ReceiptFor copies the audit join out of signed bytes rather than retyping it.
func (e MutationEnvelope) ReceiptFor(outcome MutationOutcome, now int64) MutationReceipt {
return MutationReceipt{
ProtocolVersion: MutationProtocolVersion,
OperationID: e.Manifest.OperationID,
ManifestHash: e.Manifest.Hash(),
AuthorizationID: e.Authorization.AuthorizationID,
TargetID: e.Manifest.TargetID,
Backend: e.Manifest.Backend,
Operation: e.Manifest.Operation,
Decision: outcome.Decision,
Reason: outcome.Reason,
Executed: outcome.Executed,
VerifiedActions: outcome.VerifiedActions,
ExitCode: outcome.ExitCode,
Error: outcome.Detail,
Timestamp: now,
}
}

View file

@ -0,0 +1,302 @@
package capability
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
)
type mutationProtocolFixture struct {
Manifest MutationManifest `json:"manifest"`
Authorization MutationAuthorization `json:"authorization"`
Receipt MutationReceipt `json:"receipt"`
TestSeed string `json:"test_seed"`
ExpectedManifestCanonicalHex string `json:"expected_manifest_canonical_hex"`
ExpectedManifestHash string `json:"expected_manifest_hash"`
ExpectedAuthorizationCanonical string `json:"expected_authorization_canonical_hex"`
ExpectedKeyID string `json:"expected_key_id"`
ExpectedSignature string `json:"expected_signature"`
ExpectedReceiptCanonicalHex string `json:"expected_receipt_canonical_hex"`
ExpectedReceiptDigest string `json:"expected_receipt_digest"`
}
func loadMutationProtocolFixture(t *testing.T) mutationProtocolFixture {
t.Helper()
raw, err := os.ReadFile("../../../protocol/testdata/mutation-golden.json")
if err != nil {
t.Fatal(err)
}
var fixture mutationProtocolFixture
if err := json.Unmarshal(raw, &fixture); err != nil {
t.Fatal(err)
}
return fixture
}
func signedMutationProtocolFixture(t *testing.T) (MutationManifest, MutationAuthorization, ed25519.PublicKey) {
t.Helper()
fixture := loadMutationProtocolFixture(t)
seed, err := hex.DecodeString(fixture.TestSeed)
if err != nil {
t.Fatal(err)
}
privateKey := ed25519.NewKeyFromSeed(seed)
authorization := fixture.Authorization
if err := authorization.Sign(privateKey, fixture.Manifest); err != nil {
t.Fatal(err)
}
return fixture.Manifest, authorization, privateKey.Public().(ed25519.PublicKey)
}
func cloneMutationManifest(t *testing.T, manifest MutationManifest) MutationManifest {
t.Helper()
raw, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
var clone MutationManifest
if err := json.Unmarshal(raw, &clone); err != nil {
t.Fatal(err)
}
return clone
}
func TestMutationProtocolGoldenVector(t *testing.T) {
fixture := loadMutationProtocolFixture(t)
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
if fixture.ExpectedManifestHash == "" {
t.Fatalf(
"fill fixture: manifest_canonical=%s\nmanifest_hash=%s\nauthorization_canonical=%s\nkey_id=%s\nsignature=%s\nreceipt_canonical=%s\nreceipt_digest=%s",
hex.EncodeToString(manifest.CanonicalBytes()),
manifest.Hash(),
hex.EncodeToString(authorization.CanonicalMessage()),
authorization.KeyID,
authorization.Signature,
hex.EncodeToString(fixture.Receipt.CanonicalBytes()),
fixture.Receipt.Digest(),
)
}
if got := hex.EncodeToString(manifest.CanonicalBytes()); got != fixture.ExpectedManifestCanonicalHex {
t.Fatalf("manifest canonical bytes = %q, want %q", got, fixture.ExpectedManifestCanonicalHex)
}
if got := manifest.Hash(); got != fixture.ExpectedManifestHash {
t.Fatalf("manifest hash = %q, want %q", got, fixture.ExpectedManifestHash)
}
if got := hex.EncodeToString(authorization.CanonicalMessage()); got != fixture.ExpectedAuthorizationCanonical {
t.Fatalf("authorization canonical bytes = %q, want %q", got, fixture.ExpectedAuthorizationCanonical)
}
if authorization.KeyID != fixture.ExpectedKeyID {
t.Fatalf("key id = %q, want %q", authorization.KeyID, fixture.ExpectedKeyID)
}
if authorization.Signature != fixture.ExpectedSignature {
t.Fatalf("signature = %q, want %q", authorization.Signature, fixture.ExpectedSignature)
}
if got := hex.EncodeToString(fixture.Receipt.CanonicalBytes()); got != fixture.ExpectedReceiptCanonicalHex {
t.Fatalf("receipt canonical bytes = %q, want %q", got, fixture.ExpectedReceiptCanonicalHex)
}
if got := fixture.Receipt.Digest(); got != fixture.ExpectedReceiptDigest {
t.Fatalf("receipt digest = %q, want %q", got, fixture.ExpectedReceiptDigest)
}
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
if err := envelope.VerifyForExecutionAt(publicKey, 1_700_000_100); err != nil {
t.Fatalf("golden authorization did not verify: %v", err)
}
}
func TestMutationProtocolOrderingAndDuplicateSemantics(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
reordered := cloneMutationManifest(t, manifest)
reordered.ResolvedActions[0], reordered.ResolvedActions[1] = reordered.ResolvedActions[1], reordered.ResolvedActions[0]
reordered.Evidence[0], reordered.Evidence[1] = reordered.Evidence[1], reordered.Evidence[0]
if reordered.Hash() != manifest.Hash() {
t.Fatal("manifest hash changed when action/evidence order changed")
}
if err := authorization.Verify(publicKey, reordered); err != nil {
t.Fatalf("authorization rejected reordered manifest: %v", err)
}
duplicate := cloneMutationManifest(t, manifest)
duplicate.ResolvedActions = append(duplicate.ResolvedActions, duplicate.ResolvedActions[0])
if duplicate.Hash() == manifest.Hash() {
t.Fatal("exact duplicate action was silently de-duplicated")
}
if err := authorization.Verify(publicKey, duplicate); err == nil {
t.Fatal("authorization accepted a duplicate resolved action")
}
}
func TestMutationProtocolExecutorAffectingTamperFails(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
tests := map[string]func(*MutationManifest){
"provenance": func(m *MutationManifest) { m.Evidence[0].Digest = strings.Repeat("c", 64) },
"execution location": func(m *MutationManifest) {
m.ResolvedActions[0].Payload = strings.Replace(m.ResolvedActions[0].Payload, "/var/cache/redflag", "/tmp", 1)
},
"target": func(m *MutationManifest) { m.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211" },
"backend": func(m *MutationManifest) { m.Backend = "wua" },
"resolved action": func(m *MutationManifest) { m.ResolvedActions[0].Identity = "zsh@6.0-1" },
}
for name, tamper := range tests {
t.Run(name, func(t *testing.T) {
changed := cloneMutationManifest(t, manifest)
tamper(&changed)
if err := authorization.Verify(publicKey, changed); err == nil {
t.Fatal("authorization accepted tampered manifest")
}
})
}
changedAuthorization := authorization
changedAuthorization.IssuedAt++
if err := changedAuthorization.Verify(publicKey, manifest); err == nil {
t.Fatal("authorization accepted tampered authorization metadata")
}
}
func TestMutationProtocolUnknownVersionsFailClosed(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
manifest.ProtocolVersion++
if err := manifest.Validate(); err == nil {
t.Fatal("unknown manifest version passed validation")
}
manifest.ProtocolVersion = MutationProtocolVersion
authorization.ProtocolVersion++
if err := authorization.Verify(publicKey, manifest); err == nil {
t.Fatal("unknown authorization version passed verification")
}
}
// The target fields carry the RedFlag agent identity. Both are signed and the
// verifier requires them equal, so an executor that binds either one to the
// host it read for itself has bound the whole envelope.
func TestMutationProtocolTargetBindsManifestAndAuthorization(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
if manifest.TargetID != authorization.TargetID {
t.Fatal("golden fixture disagrees with itself about the target")
}
split := authorization
split.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211"
if err := split.Verify(publicKey, manifest); err == nil {
t.Fatal("authorization for another target verified against this manifest")
}
}
func TestMutationAuthorizationIDIsCanonicalUUIDv4(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
// A replay ledger matched line-by-line has no defence against an embedded
// newline; the shape check is what makes the identifier safe to record.
for _, bad := range []string{
"",
"not-a-uuid",
"550e8400-e29b-41d4-a716-44665544001",
"550e8400-e29b-11d4-a716-446655440011",
"550e8400-e29b-41d4-c716-446655440011",
"550E8400-E29B-41D4-A716-446655440011",
"550e8400-e29b-41d4-a716-4466554400\n1",
} {
if IsCanonicalUUIDv4(bad) {
t.Fatalf("accepted %q as a canonical UUID v4", bad)
}
changed := authorization
changed.AuthorizationID = bad
if err := changed.Verify(publicKey, manifest); err == nil {
t.Fatalf("authorization with id %q verified", bad)
}
}
if !IsCanonicalUUIDv4(authorization.AuthorizationID) {
t.Fatalf("golden authorization_id %q is not a canonical UUID v4", authorization.AuthorizationID)
}
}
func TestMutationAuthorizationLifetimeCeiling(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
seed, err := hex.DecodeString(loadMutationProtocolFixture(t).TestSeed)
if err != nil {
t.Fatal(err)
}
privateKey := ed25519.NewKeyFromSeed(seed)
atCeiling := authorization
atCeiling.ExpiresAt = atCeiling.NotBefore + MaxAuthorizationLifetimeSeconds
if err := atCeiling.Sign(privateKey, manifest); err != nil {
t.Fatalf("an authorization exactly at the ceiling must sign: %v", err)
}
if err := atCeiling.Verify(publicKey, manifest); err != nil {
t.Fatalf("an authorization exactly at the ceiling must verify: %v", err)
}
overCeiling := authorization
overCeiling.ExpiresAt = overCeiling.NotBefore + MaxAuthorizationLifetimeSeconds + 1
if err := overCeiling.Sign(privateKey, manifest); err == nil {
t.Fatal("minted an authorization past the lifetime ceiling")
}
if err := overCeiling.Verify(publicKey, manifest); err == nil {
t.Fatal("verified an authorization past the lifetime ceiling")
}
}
// Evidence carries digests. Operator reason prose and policy text stay in the
// authority's journal, and the shape check is what keeps them out.
func TestMutationEvidenceCarriesDigestsNotProse(t *testing.T) {
manifest, _, _ := signedMutationProtocolFixture(t)
for _, bad := range []string{"", "operator accepted the CVE risk", strings.Repeat("a", 63), strings.Repeat("z", 64)} {
changed := cloneMutationManifest(t, manifest)
changed.Evidence[0].Digest = bad
if err := changed.Validate(); err == nil {
t.Fatalf("manifest validated with evidence digest %q", bad)
}
}
}
func TestMutationReceiptCarriesAuditJoin(t *testing.T) {
manifest, authorization, _ := signedMutationProtocolFixture(t)
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
receipt := envelope.ReceiptFor(MutationOutcome{
Decision: "denied",
Reason: "backend_not_migrated",
ExitCode: 18,
Detail: "backend=pacman",
}, 1_700_000_100)
if receipt.OperationID != manifest.OperationID ||
receipt.ManifestHash != manifest.Hash() ||
receipt.AuthorizationID != authorization.AuthorizationID ||
receipt.TargetID != manifest.TargetID {
t.Fatal("receipt lost the operation/manifest/authorization/target join")
}
if receipt.Backend != manifest.Backend || receipt.Operation != manifest.Operation {
t.Fatal("receipt lost the backend/operation it answers")
}
// Every recorded field is in the canonical bytes, including the ones a
// lossy report would drop first.
for name, mutate := range map[string]func(*MutationReceipt){
"decision": func(r *MutationReceipt) { r.Decision = "executed" },
"reason": func(r *MutationReceipt) { r.Reason = "operation_completed" },
"executed": func(r *MutationReceipt) { r.Executed = true },
"verified actions": func(r *MutationReceipt) { r.VerifiedActions = 1 },
"exit code": func(r *MutationReceipt) { r.ExitCode = 0 },
"error": func(r *MutationReceipt) { r.Error = "" },
"timestamp": func(r *MutationReceipt) { r.Timestamp++ },
} {
t.Run(name, func(t *testing.T) {
changed := receipt
mutate(&changed)
if changed.Digest() == receipt.Digest() {
t.Fatal("receipt digest ignored a recorded field")
}
})
}
}

View file

@ -0,0 +1,110 @@
// Package capability defines the supply-chain capability token: an Ed25519-signed
// authorization for exactly one package operation over a fully-resolved dependency
// closure. The server (authority) mints and signs tokens; the agent passes them to
// the privileged Rust executor (helper/) which independently verifies them.
//
// The canonical signed message and closure hash MUST stay byte-identical across
// this package, the server's mirror of it, and helper/src/main.rs. See
// RAF/security/05-supply-chain-gate.md for the contract.
package capability
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
)
// Version is the only token format this code understands. Forward-only doctrine:
// new versions add fields, never reinterpret existing ones.
const Version = 1
// ClosureEntry is one resolved artifact in the dependency closure.
type ClosureEntry struct {
Name string `json:"name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
Source string `json:"source"` // "mirror" | "registry"
ArtifactPath string `json:"artifact_path,omitempty"` // local path or url, optional
}
// Token is the full capability token exchanged between server, agent, and executor.
type Token struct {
Version int `json:"version"`
TokenID string `json:"token_id"`
AgentID string `json:"agent_id"`
KeyID string `json:"key_id"`
PackageType string `json:"package_type"` // apt|dnf|npm|bun|pip|docker|winget
Operation string `json:"operation"` // install|upgrade (forward-only)
Closure []ClosureEntry `json:"closure"`
IssuedAt int64 `json:"issued_at"`
NotBefore int64 `json:"not_before"`
ExpiresAt int64 `json:"expires_at"`
Signature string `json:"signature"` // hex ed25519
}
// ClosureHash computes hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) )).
// Lines are sorted and de-duplicated so neither array order nor exact duplicates
// can change the digest. This mirrors the Rust BTreeSet construction exactly.
func (t *Token) ClosureHash() string {
set := make(map[string]struct{}, len(t.Closure))
for _, e := range t.Closure {
set[fmt.Sprintf("%s@%s#%s", e.Name, e.Version, e.SHA256)] = struct{}{}
}
lines := make([]string, 0, len(set))
for line := range set {
lines = append(lines, line)
}
sort.Strings(lines)
h := sha256.New()
for i, line := range lines {
if i > 0 {
h.Write([]byte("\n"))
}
h.Write([]byte(line))
}
return hex.EncodeToString(h.Sum(nil))
}
// CanonicalMessage builds the deterministic message that is signed/verified:
// "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}".
func (t *Token) CanonicalMessage() string {
return fmt.Sprintf("%s:%s:%s:%s:%s:%d",
t.AgentID, t.TokenID, t.Operation, t.PackageType, t.ClosureHash(), t.ExpiresAt)
}
// Sign signs the canonical message with the authority private key, sets the
// token's KeyID and Signature, and returns the hex signature.
func (t *Token) Sign(priv ed25519.PrivateKey) (string, error) {
if len(priv) != ed25519.PrivateKeySize {
return "", fmt.Errorf("capability: invalid private key size %d", len(priv))
}
pub := priv.Public().(ed25519.PublicKey)
t.KeyID = KeyIDFor(pub)
sig := ed25519.Sign(priv, []byte(t.CanonicalMessage()))
t.Signature = hex.EncodeToString(sig)
return t.Signature, nil
}
// Verify checks the token's signature against the given public key. It does not
// check the validity window, agent binding, or artifact hashes — those are the
// executor's responsibility (and the agent's bind-check). It verifies only that
// this key signed this canonical message.
func (t *Token) Verify(pub ed25519.PublicKey) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("capability: invalid public key size %d", len(pub))
}
sig, err := hex.DecodeString(t.Signature)
if err != nil {
return fmt.Errorf("capability: signature not hex: %w", err)
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("capability: invalid signature size %d", len(sig))
}
if !ed25519.Verify(pub, []byte(t.CanonicalMessage()), sig) {
return fmt.Errorf("capability: signature verification failed")
}
return nil
}

View file

@ -0,0 +1,63 @@
package capability
import (
"crypto/ed25519"
"testing"
)
// Cross-language contract vector. The Rust executor (helper/src/main.rs) asserts
// these same strings for the same input. If either side drifts, both break.
func TestCanonicalVector(t *testing.T) {
tok := &Token{
Version: 1, TokenID: "tok-1", AgentID: "agent-123",
PackageType: "npm", Operation: "install",
Closure: []ClosureEntry{
{Name: "left-pad", Version: "1.3.0", SHA256: "aaaa"},
{Name: "is-odd", Version: "2.0.0", SHA256: "bbbb"},
},
ExpiresAt: 1700000000,
}
const wantHash = "49a181cd7b6df83a6dc83c7f647f2d224effe90907dd5dcdfdaf4c8697af595f"
const wantMsg = "agent-123:tok-1:install:npm:" + wantHash + ":1700000000"
if got := tok.ClosureHash(); got != wantHash {
t.Fatalf("ClosureHash() = %q, want %q", got, wantHash)
}
if got := tok.CanonicalMessage(); got != wantMsg {
t.Fatalf("CanonicalMessage() = %q, want %q", got, wantMsg)
}
}
func TestClosureHashOrderIndependent(t *testing.T) {
a := &Token{Closure: []ClosureEntry{{Name: "a", Version: "1", SHA256: "x"}, {Name: "b", Version: "2", SHA256: "y"}}}
b := &Token{Closure: []ClosureEntry{{Name: "b", Version: "2", SHA256: "y"}, {Name: "a", Version: "1", SHA256: "x"}}}
if a.ClosureHash() != b.ClosureHash() {
t.Fatalf("closure hash depends on order: %q != %q", a.ClosureHash(), b.ClosureHash())
}
}
func TestSignVerifyRoundtrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
tok := &Token{
Version: 1, TokenID: "tok-2", AgentID: "agent-9",
PackageType: "dnf", Operation: "upgrade",
Closure: []ClosureEntry{{Name: "openssl", Version: "3.2.1", SHA256: "deadbeef"}},
ExpiresAt: 1700000000,
}
if _, err := tok.Sign(priv); err != nil {
t.Fatal(err)
}
if tok.KeyID != KeyIDFor(pub) {
t.Fatalf("KeyID = %q, want %q", tok.KeyID, KeyIDFor(pub))
}
if err := tok.Verify(pub); err != nil {
t.Fatalf("Verify after Sign failed: %v", err)
}
// Tamper detection: any change to the closure breaks verification.
tok.Closure[0].Version = "3.2.2"
if err := tok.Verify(pub); err == nil {
t.Fatal("Verify accepted a tampered closure")
}
}

View file

@ -0,0 +1,233 @@
package circuitbreaker
import (
"fmt"
"sync"
"time"
)
// State represents the circuit breaker state
type State int
const (
StateClosed State = iota // Normal operation
StateOpen // Circuit is open, failing fast
StateHalfOpen // Testing if service recovered
)
func (s State) String() string {
switch s {
case StateClosed:
return "closed"
case StateOpen:
return "open"
case StateHalfOpen:
return "half-open"
default:
return "unknown"
}
}
// Config holds circuit breaker configuration
type Config struct {
FailureThreshold int // Number of failures before opening
FailureWindow time.Duration // Time window to track failures
OpenDuration time.Duration // How long circuit stays open
HalfOpenAttempts int // Successful attempts needed to close from half-open
}
// CircuitBreaker implements the circuit breaker pattern for subsystems
type CircuitBreaker struct {
name string
config Config
mu sync.RWMutex
state State
failures []time.Time // Timestamps of recent failures
consecutiveSuccess int // Consecutive successes in half-open state
openedAt time.Time // When circuit was opened
}
// New creates a new circuit breaker
func New(name string, config Config) *CircuitBreaker {
return &CircuitBreaker{
name: name,
config: config,
state: StateClosed,
failures: make([]time.Time, 0),
}
}
// Call executes the given function with circuit breaker protection
func (cb *CircuitBreaker) Call(fn func() error) error {
// Check if we can execute
if err := cb.beforeCall(); err != nil {
return err
}
// Execute the function
err := fn()
// Record the result
cb.afterCall(err)
return err
}
// beforeCall checks if the call should be allowed
func (cb *CircuitBreaker) beforeCall() error {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case StateClosed:
// Normal operation, allow call
return nil
case StateOpen:
// Check if enough time has passed to try half-open
if time.Since(cb.openedAt) >= cb.config.OpenDuration {
cb.state = StateHalfOpen
cb.consecutiveSuccess = 0
return nil
}
// Circuit is still open, fail fast
return fmt.Errorf("circuit breaker [%s] is OPEN (will retry at %s)",
cb.name, cb.openedAt.Add(cb.config.OpenDuration).Format("15:04:05"))
case StateHalfOpen:
// In half-open state, allow limited attempts
return nil
default:
return fmt.Errorf("unknown circuit breaker state")
}
}
// afterCall records the result and updates state
func (cb *CircuitBreaker) afterCall(err error) {
cb.mu.Lock()
defer cb.mu.Unlock()
now := time.Now()
if err != nil {
// Record failure
cb.recordFailure(now)
// If in half-open, go back to open on any failure
if cb.state == StateHalfOpen {
cb.state = StateOpen
cb.openedAt = now
cb.consecutiveSuccess = 0
return
}
// Check if we should open the circuit
if cb.shouldOpen(now) {
cb.state = StateOpen
cb.openedAt = now
cb.consecutiveSuccess = 0
}
} else {
// Success
switch cb.state {
case StateHalfOpen:
// Count consecutive successes
cb.consecutiveSuccess++
if cb.consecutiveSuccess >= cb.config.HalfOpenAttempts {
// Enough successes, close the circuit
cb.state = StateClosed
cb.failures = make([]time.Time, 0)
cb.consecutiveSuccess = 0
}
case StateClosed:
// Clean up old failures on success
cb.cleanupOldFailures(now)
}
}
}
// recordFailure adds a failure timestamp
func (cb *CircuitBreaker) recordFailure(now time.Time) {
cb.failures = append(cb.failures, now)
cb.cleanupOldFailures(now)
}
// cleanupOldFailures removes failures outside the window
func (cb *CircuitBreaker) cleanupOldFailures(now time.Time) {
cutoff := now.Add(-cb.config.FailureWindow)
validFailures := make([]time.Time, 0)
for _, failTime := range cb.failures {
if failTime.After(cutoff) {
validFailures = append(validFailures, failTime)
}
}
cb.failures = validFailures
}
// shouldOpen determines if circuit should open based on failures
func (cb *CircuitBreaker) shouldOpen(now time.Time) bool {
cb.cleanupOldFailures(now)
return len(cb.failures) >= cb.config.FailureThreshold
}
// State returns the current circuit breaker state (thread-safe)
func (cb *CircuitBreaker) State() State {
cb.mu.RLock()
defer cb.mu.RUnlock()
return cb.state
}
// GetStats returns current circuit breaker statistics
func (cb *CircuitBreaker) GetStats() Stats {
cb.mu.RLock()
defer cb.mu.RUnlock()
stats := Stats{
Name: cb.name,
State: cb.state.String(),
RecentFailures: len(cb.failures),
ConsecutiveSuccess: cb.consecutiveSuccess,
}
if cb.state == StateOpen && !cb.openedAt.IsZero() {
nextAttempt := cb.openedAt.Add(cb.config.OpenDuration)
stats.NextAttempt = &nextAttempt
}
return stats
}
// Reset manually resets the circuit breaker to closed state
func (cb *CircuitBreaker) Reset() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.state = StateClosed
cb.failures = make([]time.Time, 0)
cb.consecutiveSuccess = 0
cb.openedAt = time.Time{}
}
// Stats holds circuit breaker statistics
type Stats struct {
Name string
State string
RecentFailures int
ConsecutiveSuccess int
NextAttempt *time.Time
}
// String returns a human-readable representation of the stats
func (s Stats) String() string {
if s.NextAttempt != nil {
return fmt.Sprintf("[%s] state=%s, failures=%d, next_attempt=%s",
s.Name, s.State, s.RecentFailures, s.NextAttempt.Format("15:04:05"))
}
return fmt.Sprintf("[%s] state=%s, failures=%d, success=%d",
s.Name, s.State, s.RecentFailures, s.ConsecutiveSuccess)
}

View file

@ -0,0 +1,138 @@
package circuitbreaker
import (
"errors"
"testing"
"time"
)
func TestCircuitBreaker_NormalOperation(t *testing.T) {
cb := New("test", Config{
FailureThreshold: 3,
FailureWindow: 1 * time.Minute,
OpenDuration: 1 * time.Minute,
HalfOpenAttempts: 2,
})
// Should allow calls in closed state
err := cb.Call(func() error { return nil })
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if cb.State() != StateClosed {
t.Fatalf("expected state closed, got %v", cb.State())
}
}
func TestCircuitBreaker_OpensAfterFailures(t *testing.T) {
cb := New("test", Config{
FailureThreshold: 3,
FailureWindow: 1 * time.Minute,
OpenDuration: 100 * time.Millisecond,
HalfOpenAttempts: 2,
})
testErr := errors.New("test error")
// Record 3 failures
for i := 0; i < 3; i++ {
cb.Call(func() error { return testErr })
}
// Should now be open
if cb.State() != StateOpen {
t.Fatalf("expected state open after %d failures, got %v", 3, cb.State())
}
// Next call should fail fast
err := cb.Call(func() error { return nil })
if err == nil {
t.Fatal("expected circuit breaker to reject call, but it succeeded")
}
}
func TestCircuitBreaker_HalfOpenRecovery(t *testing.T) {
cb := New("test", Config{
FailureThreshold: 2,
FailureWindow: 1 * time.Minute,
OpenDuration: 50 * time.Millisecond,
HalfOpenAttempts: 2,
})
testErr := errors.New("test error")
// Open the circuit
cb.Call(func() error { return testErr })
cb.Call(func() error { return testErr })
if cb.State() != StateOpen {
t.Fatal("circuit should be open")
}
// Wait for open duration
time.Sleep(60 * time.Millisecond)
// Should transition to half-open and allow call
err := cb.Call(func() error { return nil })
if err != nil {
t.Fatalf("expected call to succeed in half-open state, got %v", err)
}
if cb.State() != StateHalfOpen {
t.Fatalf("expected half-open state, got %v", cb.State())
}
// One more success should close it
cb.Call(func() error { return nil })
if cb.State() != StateClosed {
t.Fatalf("expected closed state after %d successes, got %v", 2, cb.State())
}
}
func TestCircuitBreaker_HalfOpenFailure(t *testing.T) {
cb := New("test", Config{
FailureThreshold: 2,
FailureWindow: 1 * time.Minute,
OpenDuration: 50 * time.Millisecond,
HalfOpenAttempts: 2,
})
testErr := errors.New("test error")
// Open the circuit
cb.Call(func() error { return testErr })
cb.Call(func() error { return testErr })
// Wait and attempt in half-open
time.Sleep(60 * time.Millisecond)
cb.Call(func() error { return nil }) // Half-open
// Fail in half-open - should go back to open
cb.Call(func() error { return testErr })
if cb.State() != StateOpen {
t.Fatalf("expected open state after half-open failure, got %v", cb.State())
}
}
func TestCircuitBreaker_Stats(t *testing.T) {
cb := New("test-subsystem", Config{
FailureThreshold: 3,
FailureWindow: 1 * time.Minute,
OpenDuration: 1 * time.Minute,
HalfOpenAttempts: 2,
})
stats := cb.GetStats()
if stats.Name != "test-subsystem" {
t.Fatalf("expected name 'test-subsystem', got %s", stats.Name)
}
if stats.State != "closed" {
t.Fatalf("expected state 'closed', got %s", stats.State)
}
if stats.RecentFailures != 0 {
t.Fatalf("expected 0 failures, got %d", stats.RecentFailures)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
package client
import "time"
// InventoryItem represents a single inventory record -- something present on
// the system. Distinct from UpdateReportItem (which represents an available
// update) and MetricsReportItem (which represents a point-in-time measurement).
//
// Inspired by osquery's per-ecosystem table model (rpm_packages, deb_packages,
// programs, docker_images) where each item has typed, indexed fields rather
// than a freeform Metadata map.
type InventoryItem struct {
Ecosystem string `json:"inventory_ecosystem"` // "docker", "system", "apt", "dnf", etc.
ItemName string `json:"item_name"` // Primary identifier within ecosystem
ItemVersion string `json:"item_version"` // Installed version / digest
Description string `json:"description,omitempty"`
Arch string `json:"arch,omitempty"`
InstallTime string `json:"install_time,omitempty"` // RFC3339 or ecosystem-specific
SizeBytes int64 `json:"size_bytes,omitempty"`
Vendor string `json:"vendor,omitempty"` // Registry, repo, manufacturer
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// InventoryReport is sent by agents when reporting inventory data.
type InventoryReport struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Ecosystem string `json:"inventory_ecosystem"`
Items []InventoryItem `json:"items"`
ScanSucceeded bool `json:"scan_succeeded"`
}

View file

@ -0,0 +1,77 @@
package client
// machine_id_logging_test.go — Pre-fix tests for machine ID logging format.
//
// F-D1-5 LOW: client.go:39 uses fmt.Printf instead of log.Printf.
//
// Run: cd agent && go test ./internal/client/... -v -run TestClientMachineID
import (
"os"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Test 6.1 — Documents fmt.Printf usage (F-D1-5)
//
// Category: PASS-NOW (documents ETHOS violation)
// ---------------------------------------------------------------------------
func TestClientMachineIDErrorUsesFmtPrintf(t *testing.T) {
// POST-FIX (F-D1-5): fmt.Printf replaced with log.Printf.
content, err := os.ReadFile("client.go")
if err != nil {
t.Fatalf("failed to read client.go: %v", err)
}
src := string(content)
newClientIdx := strings.Index(src, "func NewClient(")
if newClientIdx == -1 {
t.Fatal("[ERROR] [agent] [client] NewClient function not found")
}
fnBody := src[newClientIdx:]
nextFn := strings.Index(fnBody[1:], "\nfunc ")
if nextFn > 0 {
fnBody = fnBody[:nextFn+1]
}
if strings.Contains(fnBody, "fmt.Printf") {
t.Error("[ERROR] [agent] [client] F-D1-5 NOT FIXED: fmt.Printf still in NewClient")
}
t.Log("[INFO] [agent] [client] F-D1-5 FIXED: structured logging in NewClient")
}
// ---------------------------------------------------------------------------
// Test 6.2 — Must use structured logging (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestClientMachineIDErrorUsesStructuredLogging(t *testing.T) {
content, err := os.ReadFile("client.go")
if err != nil {
t.Fatalf("failed to read client.go: %v", err)
}
src := string(content)
newClientIdx := strings.Index(src, "func NewClient(")
if newClientIdx == -1 {
t.Fatal("[ERROR] [agent] [client] NewClient function not found")
}
fnBody := src[newClientIdx:]
nextFn := strings.Index(fnBody[1:], "\nfunc ")
if nextFn > 0 {
fnBody = fnBody[:nextFn+1]
}
if strings.Contains(fnBody, "fmt.Printf") {
t.Errorf("[ERROR] [agent] [client] NewClient uses fmt.Printf for machine ID error.\n" +
"F-D1-5: use log.Printf with [WARNING] [agent] [client] format.")
}
}

View file

@ -0,0 +1,44 @@
package common
import (
"crypto/sha256"
"encoding/hex"
"os"
"time"
)
type AgentFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
ModifiedTime time.Time `json:"modified_time"`
Version string `json:"version,omitempty"`
Checksum string `json:"checksum"`
Required bool `json:"required"`
Migrate bool `json:"migrate"`
Description string `json:"description"`
}
// CalculateChecksum computes SHA256 checksum of a file
func CalculateChecksum(filePath string) (string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:]), nil
}
// IsRequiredFile determines if a file is required for agent operation
func IsRequiredFile(path string) bool {
requiredFiles := []string{
"/etc/redflag/agent/config.json", // Agent config in nested structure
"/usr/local/bin/redflag-agent",
"/etc/systemd/system/redflag-agent.service",
}
for _, rf := range requiredFiles {
if path == rf {
return true
}
}
return false
}

View file

@ -0,0 +1,941 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/Fimeg/RedFlag/agent/internal/version"
"github.com/gofrs/uuid/v5"
)
var teeLogger *event.TeeLogger
// InitLogger sets the package-level TeeLogger for dual-output logging.
func InitLogger(l *event.TeeLogger) {
teeLogger = l
}
// MigrationState tracks migration completion status (used by migration package)
type MigrationState struct {
LastCompleted map[string]time.Time `json:"last_completed"`
AgentVersion string `json:"agent_version"`
ConfigVersion string `json:"config_version"`
Timestamp time.Time `json:"timestamp"`
Success bool `json:"success"`
RollbackPath string `json:"rollback_path,omitempty"`
CompletedMigrations []string `json:"completed_migrations"`
}
// ProxyConfig holds proxy configuration
type ProxyConfig struct {
Enabled bool `json:"enabled"`
HTTP string `json:"http,omitempty"` // HTTP proxy URL
HTTPS string `json:"https,omitempty"` // HTTPS proxy URL
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy
Username string `json:"username,omitempty"` // Proxy username (optional)
Password string `json:"password,omitempty"` // Proxy password (optional)
}
// TLSConfig holds TLS/security configuration
type TLSConfig struct {
InsecureSkipVerify bool `json:"insecure_skip_verify"` // Skip TLS certificate verification
CertFile string `json:"cert_file,omitempty"` // Client certificate file
KeyFile string `json:"key_file,omitempty"` // Client key file
CAFile string `json:"ca_file,omitempty"` // CA certificate file
}
// NetworkConfig holds network-related configuration
type NetworkConfig struct {
Timeout time.Duration `json:"timeout"` // Request timeout
RetryCount int `json:"retry_count"` // Number of retries
RetryDelay time.Duration `json:"retry_delay"` // Delay between retries
MaxIdleConn int `json:"max_idle_conn"` // Maximum idle connections
}
// LoggingConfig holds logging configuration
type LoggingConfig struct {
Level string `json:"level"` // Log level (debug, info, warn, error)
File string `json:"file,omitempty"` // Log file path (optional)
MaxSize int `json:"max_size"` // Max log file size in MB
MaxBackups int `json:"max_backups"` // Max number of log file backups
MaxAge int `json:"max_age"` // Max age of log files in days
}
// SecurityLogConfig holds configuration for security logging
type SecurityLogConfig struct {
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_SECURITY_LOG_ENABLED" default:"true"`
Level string `json:"level" env:"REDFLAG_AGENT_SECURITY_LOG_LEVEL" default:"warning"` // none, error, warn, info, debug
LogSuccesses bool `json:"log_successes" env:"REDFLAG_AGENT_SECURITY_LOG_SUCCESSES" default:"false"`
FilePath string `json:"file_path" env:"REDFLAG_AGENT_SECURITY_LOG_PATH"` // Relative to agent data directory
MaxSizeMB int `json:"max_size_mb" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_SIZE" default:"50"`
MaxFiles int `json:"max_files" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_FILES" default:"5"`
BatchSize int `json:"batch_size" env:"REDFLAG_AGENT_SECURITY_LOG_BATCH_SIZE" default:"10"`
SendToServer bool `json:"send_to_server" env:"REDFLAG_AGENT_SECURITY_LOG_SEND" default:"true"`
}
// CommandSigningConfig holds configuration for command signature verification
type CommandSigningConfig struct {
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_COMMAND_SIGNING_ENABLED" default:"true"`
EnforcementMode string `json:"enforcement_mode" env:"REDFLAG_AGENT_COMMAND_ENFORCEMENT_MODE" default:"strict"` // strict, warning, disabled
// StaleKeyMaxAgeHours bounds how long the agent serves its cached server
// public key while the server is unreachable (SEC-028). 0 = built-in default.
// The agent clamps to a doctrinal ceiling regardless; this only tunes within
// it. Delivered fleet-wide via GET /api/v1/agents/:id/config.
StaleKeyMaxAgeHours int `json:"stale_key_max_age_hours,omitempty" env:"REDFLAG_AGENT_STALE_KEY_MAX_AGE_HOURS"`
}
// PollingConfig holds admin-adjustable polling resilience tuning. These shape
// the check-in jitter and the reconnect backoff curve. Zero values fall back to
// built-in defaults (see the resilience defaults in internal/agent/loop.go), so
// older config files without these fields keep working.
type PollingConfig struct {
JitterMaxSeconds int `json:"jitter_max_seconds,omitempty"` // cap on proportional check-in jitter (default 30)
BackoffBaseSeconds int `json:"backoff_base_seconds,omitempty"` // reconnect backoff floor (default 10)
BackoffMaxSeconds int `json:"backoff_max_seconds,omitempty"` // reconnect backoff ceiling (default 300)
}
// ProcessExplorerConfig holds limits for process detail data collection.
// Zero values use the built-in defaults.
type ProcessExplorerConfig struct {
MaxOpenFiles int `json:"max_open_files,omitempty"` // Cap on open files per process (default 2000)
MaxSockets int `json:"max_sockets,omitempty"` // Cap on open sockets per process (default 500)
MaxPipes int `json:"max_pipes,omitempty"` // Cap on open pipes per process (default 500)
MaxMemoryMap int `json:"max_memory_map,omitempty"` // Cap on memory map entries per process (default 2000)
MaxNamespaces int `json:"max_namespaces,omitempty"` // Cap on namespace entries per process (default 50)
MaxEnvKeys int `json:"max_env_keys,omitempty"` // Cap on environment variable keys per process (default 200)
MaxListeningPorts int `json:"max_listening_ports,omitempty"` // Cap on listening ports per process (default 100)
}
// Config holds agent configuration
type Config struct {
// Version Information
Version string `json:"version,omitempty"` // Config schema version
AgentVersion string `json:"agent_version,omitempty"` // Agent binary version
// Server Configuration
ServerURL string `json:"server_url"`
RegistrationToken string `json:"registration_token,omitempty"` // One-time registration token
// Agent Authentication
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"` // Short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
// Agent Behavior
CheckInInterval int `json:"check_in_interval"`
// Rapid polling mode for faster response during operations
RapidPollingEnabled bool `json:"rapid_polling_enabled"`
RapidPollingUntil time.Time `json:"rapid_polling_until"`
// Polling resilience tuning (admin-adjustable; server authority)
Polling PollingConfig `json:"polling,omitempty"`
// Degraded mode for operation after repeated failures
DegradedMode bool `json:"degraded_mode"`
// Network Configuration
Network NetworkConfig `json:"network,omitempty"`
// Proxy Configuration
Proxy ProxyConfig `json:"proxy,omitempty"`
// Security Configuration
TLS TLSConfig `json:"tls,omitempty"`
// Logging Configuration
Logging LoggingConfig `json:"logging,omitempty"`
// Security Logging Configuration
SecurityLogging SecurityLogConfig `json:"security_logging,omitempty"`
// Command Signing Configuration
CommandSigning CommandSigningConfig `json:"command_signing,omitempty"`
// Package Hash Verification Configuration
PackageHashes map[string]string `json:"package_hashes,omitempty"` // package_name:expected_sha256
// Agent Metadata
Tags []string `json:"tags,omitempty"` // User-defined tags
Metadata map[string]string `json:"metadata,omitempty"` // Custom metadata
DisplayName string `json:"display_name,omitempty"` // Human-readable name
Organization string `json:"organization,omitempty"` // Organization/group
// OS Type (linux, windows, darwin)
OSType string `json:"os_type,omitempty"`
// OS holds OS-specific information
OS OS `json:"os,omitempty"`
// Subsystem Configuration
Subsystems SubsystemsConfig `json:"subsystems,omitempty"` // Scanner subsystem configs
// Kernel Enforcement Configuration
KernelEnforcement KernelEnforcementConfig `json:"kernel_enforcement,omitempty"`
// Desktop App Configuration
Desktop DesktopConfig `json:"desktop,omitempty"`
// Process Explorer Configuration
ProcessExplorer ProcessExplorerConfig `json:"process_explorer,omitempty"`
// Migration State
MigrationState *MigrationState `json:"migration_state,omitempty"` // Migration completion tracking
}
// DesktopConfig controls the native Qt/QML local-machine operations console.
// The agent service spawns the desktop binary as a child process when a
// desktop session is detected. The binary connects back to the agent's
// local API socket.
type DesktopConfig struct {
Enabled bool `json:"enabled"` // Whether to auto-launch the desktop app
MaxRestarts int `json:"max_restarts"` // Max restarts before giving up (0 = unlimited)
RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts
}
// Load reads configuration from multiple sources with priority order:
// 1. CLI flags
// 2. Environment variables
// 3. Configuration file
// 4. Default values
func Load(configPath string, cliFlags *CLIFlags) (*Config, error) {
// Load existing config from file first
config, err := loadFromFile(configPath)
if err != nil {
// Only use defaults if file doesn't exist or can't be read
config = getDefaultConfig()
} else {
// Config loaded and merged with defaults. Persist any new keys that
// the upgrade brought in but were not in the on-disk file — the loaded
// struct has them via mergeConfigPreservingDefaults, but the file
// does not. This catches new fields (desktop, polling, etc.) that
// fresh installs get from the template but upgrades miss.
persistNewDefaults(configPath, config)
}
// Override with environment variables
mergeConfig(config, loadFromEnv())
// Override with CLI flags (highest priority)
if cliFlags != nil {
mergeConfig(config, loadFromFlags(cliFlags))
}
// Validate configuration
if err := validateConfig(config); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return config, nil
}
// CLIFlags holds command line flag values
type CLIFlags struct {
ServerURL string
RegistrationToken string
ProxyHTTP string
ProxyHTTPS string
ProxyNoProxy string
LogLevel string
ConfigFile string
Tags []string
Organization string
DisplayName string
InsecureTLS bool
}
// getConfigVersionForAgent extracts the config version from the agent version
// Agent version format: v0.1.23.6 where the fourth octet (.6) maps to config version
func getConfigVersionForAgent(agentVersion string) string {
// Strip 'v' prefix if present
cleanVersion := strings.TrimPrefix(agentVersion, "v")
// Split version parts
parts := strings.Split(cleanVersion, ".")
if len(parts) == 4 {
// Return the fourth octet as the config version
// v0.1.23.6 → "6"
return parts[3]
}
// TODO: Integrate with global error logging system when available
// For now, default to "6" to match current agent version
return "6"
}
// getDefaultConfig returns default configuration values
func getDefaultConfig() *Config {
// Use version package for single source of truth
configVersion := version.ConfigVersion
if configVersion == "dev" {
// Fallback to extracting from agent version if not injected
configVersion = version.ExtractConfigVersionFromAgent(version.Version)
}
return &Config{
Version: configVersion, // Config schema version from version package
AgentVersion: version.Version, // Agent version from version package
ServerURL: "http://localhost:8080",
CheckInInterval: 300, // 5 minutes
// Server Authentication
RegistrationToken: "", // One-time registration token (embedded by install script)
AgentID: uuid.Nil, // Will be set during registration
Token: "", // Will be set during registration
RefreshToken: "", // Will be set during registration
// Agent Behavior
RapidPollingEnabled: false,
RapidPollingUntil: time.Time{},
DegradedMode: false,
// Polling resilience tuning (defaults; operator/server may override)
Polling: PollingConfig{
JitterMaxSeconds: 30,
BackoffBaseSeconds: 10,
BackoffMaxSeconds: 300,
},
// Network Security
Proxy: ProxyConfig{},
TLS: TLSConfig{},
Network: NetworkConfig{
Timeout: 30 * time.Second,
RetryCount: 3,
RetryDelay: 5 * time.Second,
MaxIdleConn: 10,
},
Logging: LoggingConfig{
Level: "info",
MaxSize: 100, // 100MB
MaxBackups: 3,
MaxAge: 28, // 28 days
},
SecurityLogging: SecurityLogConfig{
Enabled: true,
Level: "warning",
LogSuccesses: false,
FilePath: "security.log",
MaxSizeMB: 50,
MaxFiles: 5,
BatchSize: 10,
SendToServer: true,
},
CommandSigning: CommandSigningConfig{
Enabled: true,
EnforcementMode: "strict",
},
Subsystems: GetDefaultSubsystemsConfig(),
ProcessExplorer: ProcessExplorerConfig{
MaxOpenFiles: 2000,
MaxSockets: 500,
MaxPipes: 500,
MaxMemoryMap: 2000,
MaxNamespaces: 50,
MaxEnvKeys: 200,
MaxListeningPorts: 100,
},
Desktop: DesktopConfig{
Enabled: true,
MaxRestarts: 3,
RestartDelaySec: 5,
},
Tags: []string{},
Metadata: make(map[string]string),
}
}
// loadFromFile reads configuration from file with backward compatibility migration
func loadFromFile(configPath string) (*Config, error) {
// Ensure directory exists
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
}
// Read config file
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("config file does not exist") // Return error so caller uses defaults
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
// Parse the existing config into a generic map to preserve all fields
var rawConfig map[string]interface{}
if err := json.Unmarshal(data, &rawConfig); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Create a new config with ALL defaults to fill missing fields
config := getDefaultConfig()
// Carefully merge the loaded config into our defaults
// This preserves existing values while filling missing ones with defaults
configJSON, err := json.Marshal(rawConfig)
if err != nil {
return nil, fmt.Errorf("failed to re-marshal config: %w", err)
}
// Create a temporary config to hold loaded values
tempConfig := &Config{}
if err := json.Unmarshal(configJSON, &tempConfig); err != nil {
return nil, fmt.Errorf("failed to unmarshal temp config: %w", err)
}
// Merge loaded config into defaults (only non-zero values)
mergeConfigPreservingDefaults(config, tempConfig)
// Handle specific migrations for known breaking changes
migrateConfig(config)
return config, nil
}
// migrateConfig handles specific known migrations between config versions
func migrateConfig(cfg *Config) {
// Save the registration token before migration
savedRegistrationToken := cfg.RegistrationToken
// Update config schema version to latest
targetVersion := version.ConfigVersion
if targetVersion == "dev" {
// Fallback to extracting from agent version
targetVersion = version.ExtractConfigVersionFromAgent(version.Version)
}
if cfg.Version != targetVersion {
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "migrating config schema", map[string]interface{}{
"from_version": cfg.Version,
"to_version": targetVersion,
})
}
cfg.Version = targetVersion
}
// Migration 1: Ensure minimum check-in interval (30 seconds)
if cfg.CheckInInterval < 30 {
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "migrating check_in_interval to minimum 30 seconds", map[string]interface{}{
"old_value": cfg.CheckInInterval,
})
}
cfg.CheckInInterval = 300 // Default to 5 minutes for better performance
}
// Migration 2: Add missing subsystem fields with defaults
// Check if subsystem is zero value (truly missing), not just has zero fields
if cfg.Subsystems.System == (SubsystemConfig{}) {
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "adding missing subsystem", map[string]interface{}{
"subsystem": "system",
})
}
cfg.Subsystems.System = GetDefaultSubsystemsConfig().System
}
if cfg.Subsystems.Updates == (SubsystemConfig{}) {
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "adding missing subsystem", map[string]interface{}{
"subsystem": "updates",
})
}
cfg.Subsystems.Updates = GetDefaultSubsystemsConfig().Updates
}
// CRITICAL: Restore the registration token after migration
// This ensures the token is never overwritten by migration logic
if savedRegistrationToken != "" {
cfg.RegistrationToken = savedRegistrationToken
}
}
// loadFromEnv loads configuration from environment variables
func loadFromEnv() *Config {
config := &Config{}
if serverURL := os.Getenv("REDFLAG_SERVER_URL"); serverURL != "" {
config.ServerURL = serverURL
}
if token := os.Getenv("REDFLAG_REGISTRATION_TOKEN"); token != "" {
config.RegistrationToken = token
}
if proxyHTTP := os.Getenv("REDFLAG_HTTP_PROXY"); proxyHTTP != "" {
config.Proxy.Enabled = true
config.Proxy.HTTP = proxyHTTP
}
if proxyHTTPS := os.Getenv("REDFLAG_HTTPS_PROXY"); proxyHTTPS != "" {
config.Proxy.Enabled = true
config.Proxy.HTTPS = proxyHTTPS
}
if noProxy := os.Getenv("REDFLAG_NO_PROXY"); noProxy != "" {
config.Proxy.NoProxy = noProxy
}
if logLevel := os.Getenv("REDFLAG_LOG_LEVEL"); logLevel != "" {
if config.Logging == (LoggingConfig{}) {
config.Logging = LoggingConfig{}
}
config.Logging.Level = logLevel
}
if org := os.Getenv("REDFLAG_ORGANIZATION"); org != "" {
config.Organization = org
}
if displayName := os.Getenv("REDFLAG_DISPLAY_NAME"); displayName != "" {
config.DisplayName = displayName
}
// Security logging environment variables
if secEnabled := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_ENABLED"); secEnabled != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.Enabled = secEnabled == "true"
}
if secLevel := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_LEVEL"); secLevel != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.Level = secLevel
}
if secLogSucc := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_SUCCESSES"); secLogSucc != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.LogSuccesses = secLogSucc == "true"
}
if secPath := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_PATH"); secPath != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.FilePath = secPath
}
return config
}
// loadFromFlags loads configuration from CLI flags
func loadFromFlags(flags *CLIFlags) *Config {
config := &Config{}
if flags.ServerURL != "" {
config.ServerURL = flags.ServerURL
}
if flags.RegistrationToken != "" {
config.RegistrationToken = flags.RegistrationToken
}
if flags.ProxyHTTP != "" || flags.ProxyHTTPS != "" {
config.Proxy = ProxyConfig{
Enabled: true,
HTTP: flags.ProxyHTTP,
HTTPS: flags.ProxyHTTPS,
NoProxy: flags.ProxyNoProxy,
}
}
if flags.LogLevel != "" {
config.Logging = LoggingConfig{
Level: flags.LogLevel,
}
}
if len(flags.Tags) > 0 {
config.Tags = flags.Tags
}
if flags.Organization != "" {
config.Organization = flags.Organization
}
if flags.DisplayName != "" {
config.DisplayName = flags.DisplayName
}
if flags.InsecureTLS {
config.TLS = TLSConfig{
InsecureSkipVerify: true,
}
}
return config
}
// mergeConfig merges source config into target config (non-zero values only)
func mergeConfig(target, source *Config) {
if source.ServerURL != "" {
target.ServerURL = source.ServerURL
}
if source.RegistrationToken != "" {
target.RegistrationToken = source.RegistrationToken
}
if source.CheckInInterval != 0 {
target.CheckInInterval = source.CheckInInterval
}
if source.Polling.JitterMaxSeconds != 0 {
target.Polling.JitterMaxSeconds = source.Polling.JitterMaxSeconds
}
if source.Polling.BackoffBaseSeconds != 0 {
target.Polling.BackoffBaseSeconds = source.Polling.BackoffBaseSeconds
}
if source.Polling.BackoffMaxSeconds != 0 {
target.Polling.BackoffMaxSeconds = source.Polling.BackoffMaxSeconds
}
if source.AgentID != uuid.Nil {
target.AgentID = source.AgentID
}
if source.Token != "" {
target.Token = source.Token
}
if source.RefreshToken != "" {
target.RefreshToken = source.RefreshToken
}
// Merge nested configs
if source.Network != (NetworkConfig{}) {
target.Network = source.Network
}
if source.Proxy != (ProxyConfig{}) {
target.Proxy = source.Proxy
}
if source.TLS != (TLSConfig{}) {
target.TLS = source.TLS
}
if source.Logging != (LoggingConfig{}) {
target.Logging = source.Logging
}
if source.SecurityLogging != (SecurityLogConfig{}) {
target.SecurityLogging = source.SecurityLogging
}
if source.CommandSigning != (CommandSigningConfig{}) {
target.CommandSigning = source.CommandSigning
}
// Merge metadata
if source.Tags != nil {
target.Tags = source.Tags
}
if source.Metadata != nil {
if target.Metadata == nil {
target.Metadata = make(map[string]string)
}
for k, v := range source.Metadata {
target.Metadata[k] = v
}
}
if source.DisplayName != "" {
target.DisplayName = source.DisplayName
}
if source.Organization != "" {
target.Organization = source.Organization
}
// Merge rapid polling settings
target.RapidPollingEnabled = source.RapidPollingEnabled
if !source.RapidPollingUntil.IsZero() {
target.RapidPollingUntil = source.RapidPollingUntil
}
// Merge subsystems config
if source.Subsystems != (SubsystemsConfig{}) {
target.Subsystems = source.Subsystems
}
}
// validateConfig validates configuration values
func validateConfig(config *Config) error {
if config.ServerURL == "" {
return fmt.Errorf("server_url is required")
}
if config.CheckInInterval < 30 {
return fmt.Errorf("check_in_interval must be at least 30 seconds")
}
if config.CheckInInterval > 3600 {
return fmt.Errorf("check_in_interval cannot exceed 3600 seconds (1 hour)")
}
if config.Network.Timeout <= 0 {
return fmt.Errorf("network timeout must be positive")
}
if config.Network.RetryCount < 0 || config.Network.RetryCount > 10 {
return fmt.Errorf("retry_count must be between 0 and 10")
}
// Validate log level
validLogLevels := map[string]bool{
"debug": true, "info": true, "warn": true, "error": true,
}
if config.Logging.Level != "" && !validLogLevels[config.Logging.Level] {
return fmt.Errorf("invalid log level: %s", config.Logging.Level)
}
return nil
}
// mergeMissingKeys recursively walks fresh and injects keys that are absent
// from raw into raw. Both maps are map[string]interface{} as parsed from JSON.
// Returns the total number of keys added (at any depth).
func mergeMissingKeys(raw, fresh map[string]interface{}, depth int) int {
if depth > 16 {
return 0 // safety limit — deeply nested config is pathological
}
added := 0
for k, v := range fresh {
rawVal, exists := raw[k]
if !exists {
raw[k] = v
added++
continue
}
// If both sides are maps, recurse to find missing sub-keys.
rawMap, rawOk := rawVal.(map[string]interface{})
freshMap, freshOk := v.(map[string]interface{})
if rawOk && freshOk {
added += mergeMissingKeys(rawMap, freshMap, depth+1)
}
}
return added
}
// persistNewDefaults injects new keys from the merged config into the on-disk
// file without removing unknown keys (e.g. machine_id) that the Config struct
// does not carry. This is how upgrades get new config fields (desktop, polling,
// etc.) persisted without the installer re-running.
func persistNewDefaults(configPath string, cfg *Config) {
existing, err := os.ReadFile(configPath)
if err != nil {
return
}
var raw map[string]interface{}
if err := json.Unmarshal(existing, &raw); err != nil {
return
}
// Marshal the merged struct to a map to discover new-key candidates.
cfgJSON, err := json.Marshal(cfg)
if err != nil {
return
}
var fresh map[string]interface{}
if err := json.Unmarshal(cfgJSON, &fresh); err != nil {
return
}
// Inject keys present in the struct map but absent from the raw file.
// Recurse into nested objects so new sub-fields (e.g. desktop.max_restarts
// added in a newer version) are written even when the parent key exists.
added := mergeMissingKeys(raw, fresh, 0)
if added == 0 {
return // already up to date
}
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "persisting new default keys to config.json", map[string]interface{}{
"added": added,
})
}
merged, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return
}
if err := os.WriteFile(configPath, merged, 0600); err != nil {
if teeLogger != nil {
teeLogger.Warning("agent", "config", "config", "failed to persist new defaults", map[string]interface{}{
"error": err.Error(),
})
}
}
}
// Save writes configuration to file
func (c *Config) Save(configPath string) error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Create parent directory if it doesn't exist
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
// SetDegradedMode sets the degraded mode flag and saves the config
func (c *Config) SetDegradedMode(enabled bool) error {
c.DegradedMode = enabled
return c.Save(constants.GetAgentConfigPath())
}
// IsRegistered checks if the agent is registered
func (c *Config) IsRegistered() bool {
return c.AgentID != uuid.Nil && c.Token != ""
}
// IsStandalone reports whether this config has a stable local identity and no
// fleet credential. A partial fleet enrollment is not standalone: refresh or
// registration material must never silently become local mutation authority.
func (c *Config) IsStandalone() bool {
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == ""
}
// InitializeStandalone gives a local-only Agent one durable UUID. It is
// idempotent, but refuses any fleet credential so provisioning cannot convert a
// fleet host into local authority by accident.
func (c *Config) InitializeStandalone() error {
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" {
return fmt.Errorf("standalone identity refused: fleet enrollment material is present")
}
if c.AgentID != uuid.Nil {
return nil
}
id, err := uuid.NewV4()
if err != nil {
return fmt.Errorf("generate standalone agent id: %w", err)
}
c.AgentID = id
return nil
}
// OSType represents the operating system type
type OSType string
const (
OSTypeLinux OSType = "linux"
OSTypeWindows OSType = "windows"
OSTypeDarwin OSType = "darwin"
)
// OS holds OS-specific information
type OS struct {
Type OSType `json:"type"`
Arch string `json:"arch,omitempty"`
Version string `json:"version,omitempty"`
}
// GetOSType returns the OS type from the config
func (c *Config) GetOSType() OSType {
if c.OS.Type != "" {
return c.OS.Type
}
// Default to linux if not set
return OSTypeLinux
}
// NeedsRegistration checks if the agent needs to register with a token
func (c *Config) NeedsRegistration() bool {
return c.RegistrationToken != "" && c.AgentID == uuid.Nil
}
// HasRegistrationToken checks if the agent has a registration token
func (c *Config) HasRegistrationToken() bool {
return c.RegistrationToken != ""
}
// mergeConfigPreservingDefaults merges source config into target config
// but only overwrites fields that are explicitly set (non-zero)
// This is different from mergeConfig which blindly copies non-zero values
func mergeConfigPreservingDefaults(target, source *Config) {
// Server Configuration
if source.ServerURL != "" && source.ServerURL != getDefaultConfig().ServerURL {
target.ServerURL = source.ServerURL
}
// IMPORTANT: Never overwrite registration token if target already has one
if source.RegistrationToken != "" && target.RegistrationToken == "" {
target.RegistrationToken = source.RegistrationToken
}
// Agent Configuration
if source.CheckInInterval != 0 {
target.CheckInInterval = source.CheckInInterval
}
if source.Polling.JitterMaxSeconds != 0 {
target.Polling.JitterMaxSeconds = source.Polling.JitterMaxSeconds
}
if source.Polling.BackoffBaseSeconds != 0 {
target.Polling.BackoffBaseSeconds = source.Polling.BackoffBaseSeconds
}
if source.Polling.BackoffMaxSeconds != 0 {
target.Polling.BackoffMaxSeconds = source.Polling.BackoffMaxSeconds
}
if source.AgentID != uuid.Nil {
target.AgentID = source.AgentID
}
if source.Token != "" {
target.Token = source.Token
}
if source.RefreshToken != "" {
target.RefreshToken = source.RefreshToken
}
// Merge nested configs only if they're not default values
if source.Network != (NetworkConfig{}) {
target.Network = source.Network
}
if source.Proxy != (ProxyConfig{}) {
target.Proxy = source.Proxy
}
if source.TLS != (TLSConfig{}) {
target.TLS = source.TLS
}
if source.Logging != (LoggingConfig{}) && source.Logging.Level != "" {
target.Logging = source.Logging
}
if source.SecurityLogging != (SecurityLogConfig{}) {
target.SecurityLogging = source.SecurityLogging
}
if source.CommandSigning != (CommandSigningConfig{}) {
target.CommandSigning = source.CommandSigning
}
// Merge metadata
if source.Tags != nil && len(source.Tags) > 0 {
target.Tags = source.Tags
}
if source.Metadata != nil {
if target.Metadata == nil {
target.Metadata = make(map[string]string)
}
for k, v := range source.Metadata {
target.Metadata[k] = v
}
}
if source.DisplayName != "" {
target.DisplayName = source.DisplayName
}
if source.Organization != "" {
target.Organization = source.Organization
}
// Merge rapid polling settings
target.RapidPollingEnabled = source.RapidPollingEnabled
if !source.RapidPollingUntil.IsZero() {
target.RapidPollingUntil = source.RapidPollingUntil
}
// Merge subsystems config
if source.Subsystems != (SubsystemsConfig{}) {
target.Subsystems = source.Subsystems
}
// Desktop app config. A zero struct means the key is absent from the file —
// keep the defaults. Any explicit setting (enabled, restart tuning) wins.
if source.Desktop != (DesktopConfig{}) {
target.Desktop = source.Desktop
}
// Version info
if source.Version != "" {
target.Version = source.Version
}
if source.AgentVersion != "" {
target.AgentVersion = source.AgentVersion
}
}

Some files were not shown because too many files have changed in this diff Show more