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
13
.gitea/allowed_signers
Normal 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
|
|
@ -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"
|
||||||
39
.gitea/workflows/desktop-windows.yml
Normal 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
|
||||||
61
.gitea/workflows/msi-custody-proof.yml
Normal 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
|
||||||
181
.gitea/workflows/nightly.yml
Normal 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)"
|
||||||
238
.gitea/workflows/promote-release.yml
Normal 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"
|
||||||
735
.gitea/workflows/release.yml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
5
.publication/commit-voice-allowlist.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"repository": "Fimeg/RedFlag",
|
||||||
|
"exceptions": []
|
||||||
|
}
|
||||||
156
.publication/commit_voice.py
Normal 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
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
535
.publication/surface_gate.py
Normal 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())
|
||||||
84
.publication/test_commit_voice.py
Normal 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()
|
||||||
169
.publication/test_surface_gate.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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 1–3 (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
|
|
@ -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
|
|
@ -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 (001–058)
|
||||||
|
│ │ └── 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 (001–058, 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
|
|
@ -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
|
|
@ -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
|
|
@ -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*
|
||||||
139
RAF/components/05-desktop.md
Normal 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
|
|
@ -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*
|
||||||
404
RAF/core/02-architecture-decisions.md
Normal 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).
|
||||||
285
RAF/flows/01-registration.md
Normal 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*
|
||||||
228
RAF/flows/02-command-execution.md
Normal 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
|
|
@ -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*
|
||||||
77
RAF/flows/05-capability-advertisement.md
Normal 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*
|
||||||
275
RAF/flows/06-update-lifecycle.md
Normal 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 203–205.**
|
||||||
|
|
||||||
|
### 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*
|
||||||
137
RAF/flows/07-process-scan.md
Normal 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*
|
||||||
120
RAF/flows/08-wazuh-event-emitter.md
Normal 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`
|
||||||
141
RAF/reference/01-file-mappings.md
Normal 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*
|
||||||
34
RAF/reference/02-glossary.md
Normal 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*
|
||||||
135
RAF/scanners/01-windows-updates.md
Normal 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*
|
||||||
111
RAF/scanners/02-docker-scanner.md
Normal 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*
|
||||||
133
RAF/scanners/03-apt-scanner.md
Normal 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*
|
||||||
134
RAF/scanners/04-dnf-scanner.md
Normal 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*
|
||||||
197
RAF/scanners/05-process-scanner.md
Normal 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*
|
||||||
98
RAF/scanners/06-pacman-scanner.md
Normal 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*
|
||||||
263
RAF/security/01-trust-boundaries.md
Normal 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*
|
||||||
205
RAF/security/02-authentication-stack.md
Normal 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*
|
||||||
70
RAF/security/03-refresh-tokens.md
Normal 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*
|
||||||
119
RAF/security/04-machine-binding.md
Normal 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*
|
||||||
499
RAF/security/05-supply-chain-gate.md
Normal 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 1–5 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*
|
||||||
188
RAF/security/06-standalone-authority.md
Normal 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*
|
||||||
41
RAF/testing/01-test-pyramid.md
Normal 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*
|
||||||
177
RAF/verification/01-signing-pipeline.md
Normal 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*
|
||||||
162
RAF/verification/02-agent-verification.md
Normal 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*
|
||||||
138
RAF/verification/03-key-rotation.md
Normal 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 (1–720h 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*
|
||||||
129
RAF/verification/04-replay-protection.md
Normal 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
|
|
@ -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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|  |  |  |
|
||||||
|
|---|---|---|
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>More screenshots</summary>
|
||||||
|
|
||||||
|
|  |  |  |
|
||||||
|
|---|---|---|
|
||||||
|
|
||||||
|
|  |  |  |
|
||||||
|
|---|---|---|
|
||||||
|
|
||||||
|
|  |  | |
|
||||||
|
|---|---|---|
|
||||||
|
|
||||||
|
</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
|
|
@ -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.
|
||||||
BIN
Screenshots/7Zip-Updates-RedFlag-Dependency.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
Screenshots/RedFlag Agent List.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
Screenshots/RedFlag Default Dashboard.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
Screenshots/RedFlag Docker Dashboard.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
Screenshots/RedFlag Heartbeat System.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
Screenshots/RedFlag History Dashboard.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
Screenshots/RedFlag Linux Agent Details.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
Screenshots/RedFlag Live Operations - Failed Dashboard.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
Screenshots/RedFlag Updates Dashboard.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
Screenshots/RedFlag Windows Agent Details.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
Screenshots/Upstream-Version-Tracking.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
45
THIRD_PARTY_LICENSES.md
Normal 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
|
|
@ -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
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
119
agent/cmd/agent/local_status.go
Normal 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
|
|
@ -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
|
||||||
|
}
|
||||||
82
agent/cmd/ethos_emoji_test.go
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
202
agent/internal/acknowledgment/tracker.go
Normal 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
|
||||||
|
}
|
||||||
54
agent/internal/agent/backoff_test.go
Normal 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
4
agent/internal/cache/cache.go
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
package cache
|
||||||
|
|
||||||
|
// Init initializes the cache module
|
||||||
|
func Init() {}
|
||||||
172
agent/internal/cache/hash_cache.go
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
||||||
|
}
|
||||||
15
agent/internal/capability/keyid.go
Normal 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])
|
||||||
|
}
|
||||||
423
agent/internal/capability/mutation_manifest.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
302
agent/internal/capability/mutation_manifest_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
110
agent/internal/capability/token.go
Normal 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
|
||||||
|
}
|
||||||
63
agent/internal/capability/token_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
233
agent/internal/circuitbreaker/circuitbreaker.go
Normal 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)
|
||||||
|
}
|
||||||
138
agent/internal/circuitbreaker/circuitbreaker_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
1381
agent/internal/client/client.go
Normal file
31
agent/internal/client/inventory.go
Normal 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"`
|
||||||
|
}
|
||||||
77
agent/internal/client/machine_id_logging_test.go
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
44
agent/internal/common/agentfile.go
Normal 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
|
||||||
|
}
|
||||||
941
agent/internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||