This review covers the v0 Linux agent, DNS-mediated selected platform profile, native nftables and NFLOG boundaries, root-owned runtime storage, hosted lockdown controls, bundled Action wrapper, release provenance workflow, and offline validation scripts as of 2026-07-15.
This document records focused review findings. The current trust assumptions,
attacker capabilities, abuse paths, and residual-risk priorities are defined in
the frozen threat-model.md; normative behavior and schemas
remain in v0.md.
Fence intentionally supports only GitHub-hosted ubuntu-24.04 x64 host jobs. Standard block mode reduces arbitrary outbound egress, disables measured passwordless sudo and container-control paths, and keeps resident controls active until ephemeral runner teardown. It does not claim sandboxing, kernel isolation, or elimination of every exfiltration channel. The selected GitHub workflow-bootstrap profile and its bounded DNS mediation remain disclosed channels that later workflow code may use. The exact github.com, api.github.com, release-assets.githubusercontent.com, the optional exact watchdog root, and up to eight single-label *.githubapp.com names are intentional compatibility exceptions by default. Workflows may set disable_broad_github_domains: true to remove those broad GitHub channels while keeping core Actions status and finalization endpoints available. GitHub results storage has five exact reviewed static compatibility roots: productionresultssa19.blob.core.windows.net, productionresultssa13.blob.core.windows.net, productionresultssa9.blob.core.windows.net, productionresultssa15.blob.core.windows.net, and productionresultssa17.blob.core.windows.net. Fence may additionally authorize at most four exact matching accounts requested by the pinned Runner.Worker process by default. Block-only allow_github_artifacts: true explicitly extends the same four-account budget to uniquely attributed, runner-UID-matching descendants of that pinned worker and records the reduced assurance. Every approved answer remains withheld until its TCP 443 rule is applied and verified.
Protected main is source-only and intentionally omits the production Action binary and manifest. A reviewed change plus matching version bump is the sole human release authorization. The release workflow builds the Linux x64 artifact once from signed source merge M, generates attestations bound to M and refs/heads/main in a dedicated least-privilege job, and uses the offline script/assemble-action-bundle path to create a production-shaped candidate without downloading an agent, policy, or attestation.
GitHub's commit API creates signed distribution commit D as a one-parent child of M; its exact diff is the mode-0644 binary and schema-4 provenance manifest. The workflow verifies the signature, parent, diff, manifest identity, and byte equality, then runs complete fixed-runner Action acceptance and the strict drift canary against exact D. Only after those gates and final-asset attestation verification pass does the immutable vX.Y.Z tag target D. The action-release.json release asset binds the public version, M, D, artifact digest, manifest schema, and signer identity, and consumers pin the full action_commit SHA.
Post-publication verification re-downloads the assets, verifies checksums and source-bound attestations, and rechecks vX.Y.Z -> D, signed D -> M, the exact generated diff, mapping, and bundled bytes before reporting the consumer SHA. The protected release environment is main-only and has no separate required reviewer, so a second publication approval cannot bypass or replace the reviewed version merge. The Action still performs no runtime agent or policy download. Releases through v0.6.3 retain their historical tag semantics. See GitHub's artifact attestation documentation.
The local TCP DNS listeners previously served connections serially without a client read deadline, and forwarded their queries over upstream TCP even though the protected firewall permits only root-owned upstream UDP DNS. Accepted client sockets now use bounded deadlines, and both TCP and UDP clients share the same bounded, connected UDP resolver path without widening the firewall exception.
The UDP mediator previously accepted the first datagram received on its ephemeral upstream socket without binding the socket to the fixed resolver or checking the transaction identifier. The mediator now connects every UDP upstream socket to the fixed resolver and rejects responses whose identifier does not match the mediator-owned upstream query for either local transport.
The provisional Docker DNS rewrite previously read the existing daemon configuration through an unbounded path-following file read. Existing input is now opened with no-follow, close-on-exec, and non-blocking flags; non-regular, symlink, and oversized files fail closed. Replacement refuses non-regular or symlink destinations and validates the opened output file before writing.
DNS routing setup previously waited indefinitely for fixed systemctl and
resolvectl subprocesses. The trusted-launcher systemctl show MainPID
identity query also used an unbounded wait. Those commands now have fixed
execution deadlines and are killed on timeout.
The native nftables executor previously wrote its generated program to child
stdin before beginning deadline enforcement. A child that stopped reading
could block that write indefinitely. Stdin writing now occurs in a joined
worker while the parent enforces the command deadline and kills a stalled
child.
The NFLOG parser previously accepted duplicate payload or prefix attributes and could ignore trailing bytes outside aligned attributes. It now rejects those ambiguous event shapes before approved metadata extraction.
Retained NFLOG findings can now be correlated with a unique local Linux socket
owner through bounded /proc snapshots. Fence records only attribution status,
actor class, PID, executable basename, and at most four parent executable
basenames. The worker has fixed queue, socket, process, and file-descriptor
limits and is supervised with the other resident workers. Local socket tuples
remain internal, and Fence does not record command arguments, full paths,
environments, working directories, payloads, or telemetry.
The hosted-runner fingerprint path previously bounded sudo policy bytes and rejected symlinks but did not require a regular file after opening. Policy sources now use a non-blocking open and fail closed unless the opened object is a bounded regular file.
Earlier hosted evidence showed additional exact whole-file digests for the fixed 90-cloud-init-users sudo-policy source. During a mixed image rollout, three independent hosted VMs on the new image matched one additional digest while a separate older-image control retained an already accepted variant; after excluding non-enforced volatile device, inode, PID, and start-time identifiers, the complete bounded observations were otherwise identical. Fingerprint schema 2 accepted each reviewed digest as an additional exact value while retaining the same source name, regular-file, ownership, mode, non-writability, marker, unit, socket, resolver, principal, group, and local-control checks.
Cloud-init constructs the first 90-cloud-init-users line from its package version and the current RFC 2822 timestamp before writing the policy body, so whole-file digests turn expected image-build metadata changes into recurring fingerprint maintenance. Cloud-init's own integration coverage compares this file after omitting that first line. Fingerprint schema 3 therefore requires a digest profile for every sudo source: sudoers, README, and runner remain exact_file_v1, while only the exact drop_in / 90-cloud-init-users identity may use cloud_init_generated_header_v1. That profile accepts exactly one first line matching the reviewed cloud-init version/timestamp grammar, rejects missing, malformed, repeated, or non-UTC headers, and computes a domain-separated SHA-256 over every remaining byte without normalizing comments, whitespace, rules, or line endings. The lockdown path separately pins the raw whole-file SHA-256 after acceptance, so a later header-only or body mutation still fails resident verification. The relevant upstream behavior is public in cloud-init's header generator and its sudo policy integration test.
The production fingerprint previously relied on ownership and ordinary mode
metadata without proving the runner's effective access after ACL processing.
Fingerprint schema 2 introduced records for every trusted executable, its reviewed
ancestor directories, and every accepted sudo source. Before mutation, Fence
uses descriptor-pinned sudo and descriptor-pinned /usr/bin/test to require
that the runner cannot write any of those paths, can execute the fixed commands
and searchable ancestors, and cannot search /etc/sudoers.d. Each probe is
bound to exact path or policy identity immediately before and after execution,
followed by a full trusted-executable and sudo-inventory recheck. Disposable
hosted evidence adds an ACL that grants runner search without changing the
root-owned directory's accepted 0750 mode and proves the effective-access
check rejects it before readiness or mutation.
Security-critical host commands previously executed reviewed absolute paths
after a metadata check, leaving a path replacement window between validation
and execution. Fence now captures the twelve accepted root-owned command files
with no-follow, close-on-exec descriptors and revalidates canonical path,
device, inode, owner, group, and mode before every use. Root commands execute
the captured inode through /proc/self/fd; effective runner probes use pinned
outer sudo to execute the pinned target without a raw-path fallback. Standard
descriptors are reserved throughout capture and retained executable descriptors
must be at least 3. The sudo transport requires the transient service to have
no controlling terminal, and hosted audit evidence exercises that path.
This provides forward identity under the first-step and trusted hosted-image assumptions. It does not hash or authenticate pre-capture bytes, same-inode modification by an already privileged process, the dynamic loader or shared libraries, or a malicious root/platform component.
Standard lockdown previously verified only named Docker/containerd units and
sockets, so an additive rootful control endpoint outside that fixed list could
escape the support gate. Three corrected same-image observations supplied one
stable, within-bounds, reachability-complete, and ownership-complete reference.
Fingerprint schema 2 introduced acceptance of the exact two root container identities,
wildcard IPv4/IPv6 TCP port 22 listeners, and ten domain-separated Unix
listener identities with reviewed owner sets and multiplicities.
Production re-observes that inventory before mutation. Standard block may only
remove accepted container processes or owners, plus the exact
fingerprint-tagged Docker Unix listener after its reviewed dockerd owner has
exited and the accepted socket unit is stopped. If that listener remains, its
non-container owners and multiplicity remain exact. Fence then pins the
verified reduction as its resident baseline; audit and degraded block require
the full exact reference. The baseline is checked again before readiness and
every five seconds afterward. Relative filesystem socket names, incomplete
ownership, unavailable or unreviewed root identity, collection bounds,
unreviewed reductions, additive endpoints, and post-ready drift fail closed.
Filesystem reachability probes are limited to possible root-control candidates,
capped at forty, and share a five-second deadline so nonroot socket volume
cannot create unbounded privileged probes. Hosted rejection evidence covers an
unexpected pre-ready root listener and a separate post-ready listener that
makes resident health permanently critical.
The accepted runner sudo source is now captured only in bounded memory and
compared exactly again before removal. Pre-ready rollback recreates it with
exclusive no-follow creation and verifies bytes, mode, ownership, digest,
complete policy inventory, visudo, and uncached runner capability. Sudo and
container rollback are attempted independently and dual failures are
aggregated. Once readiness is created, the control enters an explicit
no-restore state before any later fallible report update, and every rollback
attempt is rejected.
The ephemeral source-built candidate and published distribution bundle expose fingerprint schema 3. Action acceptance recursively validates the bounded schema-4 live observation and compares every enforced executable, ancestor, effective-access, resolver, profile-specific sudo source digest, principal, group, unit, socket, workload, and local-control fact before destructive activation. The candidate, release validation, and fixed-runner canary require normal activation and do not accept a classifier skip. Malformed, incomplete, mismatched, and unknown drift fails.
The Action wrapper rejected consecutive hyphens in invocation identifiers, while several Rust validators accepted them. The Rust configuration, runtime, and service validators now enforce the same lowercase internal-hyphen grammar as the wrapper.
Late DNS observation report write failures were previously ignored by the proxy threads. The recorder now retains a failure flag that resident audit or block verification converts into a bounded critical finding in the primary report.
The DNS mediator previously waited for an approved HTTPS address to enter the owned nftables ruleset but returned the upstream answer before the verified firewall update was active. Block mode now submits bounded materialization requests to the single resident firewall owner and releases an approved address answer only after that owner applies and structurally verifies the matching rules. Address-bearing responses are all-or-nothing: every returned address must be materializable before any answer is released. An approved zero-TTL address receives a one-second materialization lifetime, and a valid zero-TTL CNAME edge receives a one-second effective lineage lifetime, before the existing refresh overlap. Partial coverage, queue rejection, service disconnection, or an explicit failed result returns a minimal retryable SERVFAIL response. Names outside policy and over-budget user wildcard names receive a minimal REFUSED response without forwarding. Both responses contain the original DNS question but no answer, authority, additional, or raw upstream data. Rejections increment bounded warning evidence; backend apply and verification failures remain critical findings.
CNAME retention previously evaluated every answer edge against process-wide hostname authorization. An unrelated answer owner that was independently allowed could therefore seed a derived authorization unrelated to the echoed question. Fence now parses each complete DNS response once and accepts only one linear, acyclic alias chain rooted at that question. Every CNAME must belong to the chain, every address must belong to its terminal name, and the chain keeps the queried root's origins and transports even if an intermediate name is also directly allowed. Address records must match the echoed question family, and duplicate terminal endpoints use the minimum TTL. Conflicts, cycles, unrelated records, self-referential aliases, invalid depth, and capacity failures reject the whole block-mode response without committing partial state. In block mode, valid derived authorization is committed only after the address rules are applied and verified. Audit may forward invalid upstream data but does not retain authorization from it. A fully rooted CNAME response with no address records is forwarded as address-family NODATA and retains no derived authorization in either mode.
The resident firewall owner rechecks queued block responses in order against a cloned authorization state before rendering any replacement. Stale, expired, or over-capacity transactions contribute no candidate address rules. After successful structural verification, the owner publishes the candidate authorization and active-materialization states before reporting success to the DNS worker. Validation-time expiry is absolute and is not restarted by queue delay.
GitHub's runner uploads job logs and summaries to signed Azure Blob URLs. An unbounded static numeric account list would be brittle, while a general *.blob.core.windows.net rule would authorize unrelated globally registered storage accounts. Fence instead permits five exact reviewed static compatibility roots, routes host DNS directly to its local mediator, pins the unique reviewed Runner.Worker identity, and authorizes at most four additional exact productionresultssa<1-to-5-decimal-digits>.blob.core.windows.net accounts only when a matching host DNS socket belongs to that pinned process by default. PID reuse, executable replacement, ambiguous ownership, Docker-originated requests, and ordinary workflow-process requests fail closed. The DNS answer remains withheld until TCP 443 access is atomically applied and structurally verified.
Block-only allow_github_artifacts: true is an explicit, default-off compatibility exception for GitHub artifact, Pages, and cache actions. It accepts a uniquely owned host DNS socket from a runner-UID-matching, bounded descendant of the pinned Runner.Worker, records origin opt_in_github_artifact_dns, and shares the existing maximum of four exact dynamic accounts with runner-authorized traffic. It revalidates the reviewed worker identity, descendant start time and ancestry, unique socket owner, strict account grammar, CNAME and TTL bounds, TCP-443-only materialization, and structural firewall verification. It rejects Docker, wrong-UID, unrelated or ambiguous sockets, general Azure Blob Storage, and enabled artifact compatibility in audit mode.
The opt-in changes the trust boundary: neither the account-name grammar nor the requesting process proves GitHub owns the account, that the official artifact action made the request, or that uploaded files are safe. Later workflow code can use an authorized account to upload source, secrets, or other data. Runtime evidence and the post-job report must explicitly identify the enabled option, exact account provenance, and artifact-upload warning.
The exact productionresultssa19.blob.core.windows.net compatibility account appears in GitHub's Actions domain inventory. Fence also permits the exact source-reviewed productionresultssa13.blob.core.windows.net, productionresultssa9.blob.core.windows.net, productionresultssa15.blob.core.windows.net, and productionresultssa17.blob.core.windows.net accounts. These five static roots are available without process attribution, while every other matching account continues to require the runner-bound authorization above. Fence does not allow the general Azure Blob suffix.
Configuration rejects exact user entries for every non-static matching account before mutation, and bootstrap response processing independently refuses to materialize any hostname marked as requiring runner provenance. Response-local CNAME lineage also rejects non-static matching targets, so exact and wildcard user hostnames cannot turn a restricted account into an unattributed derived allowance. User wildcard policy remains lazy and cannot bypass the same attribution and four-account cap.
The Action launcher previously had no timeout for fixed privileged subprocess
calls. It now enforces a bounded deadline. The wrapper source is also
dependency-free TypeScript executed directly through Node 24 built-in type
stripping. Its unit suite uses Node's built-in node:test and node:assert
modules. Fence does not carry an npm dependency tree, install Node packages, or
compile TypeScript at workflow runtime. See Node's
built-in TypeScript documentation.
- Approved GitHub workflow-bootstrap DNS and HTTPS channels remain available to
later workflow code and therefore remain possible exfiltration channels. By
default this includes
github.com,api.github.com,release-assets.githubusercontent.com, the optional exact watchdog root, and up to eight single-label*.githubapp.comnames;disable_broad_github_domains: trueremoves those broad channels but retains core Actions status/finalization channels. - Explicit user wildcard patterns may contain one or two leading whole-label wildcards and authorize at most eight concrete names per invocation across all patterns. Each
*matches exactly one DNS label, but the admitted query labels, matching HTTPS destinations, shared resolved addresses, and bounded external CNAME descendants remain exfiltration channels. Fence validates DNS structure rather than registrable-domain ownership and carries no public-suffix database. - The five exact reviewed static results-storage accounts are always reachable TCP
443compatibility channels. Other exact matching results-storage accounts are restricted to the pinned runner by default; explicitly enabled artifact compatibility extends the same bounded four-account dynamic budget to uniquely attributed, runner-UID-matching descendants of the pinned worker. - A runner-authorized or explicitly artifact-authorized results-storage account is also reachable by later workflow code at its resolved HTTPS addresses. Fence does not prove GitHub account ownership, authenticate an official action, inspect signed URLs, credentials, or encrypted request content, or prevent artifact-based data exfiltration.
- The fixed upstream DNS resolver remains a trusted platform dependency. Fence bounds, canonicalizes, and filters requests and validates response source and transaction identity, but does not add DNSSEC validation.
- Azure documents
168.63.129.16as its fixed platform virtual address for DNS, VM-agent, and health communication. Fence permits its root-resident DNS mediator to reach UDP53and UID0host traffic to reach WireServer TCP80and32526. The latter is a dedicated platform-service rule class, not a workflow or user allowance. Unprivileged and forwarded traffic does not match it. A separate shared platform rule permits host and forwarded traffic to Azure IMDS at169.254.169.254on TCP80only. - Any root-owned host process can use the two WireServer ports. Standard block
relies on verified sudo and container lockdown to prevent later workflow code
from obtaining UID
0; degraded block and audit already disclaim ordinary containment. - Untrusted workflow code can intentionally deny service to its own job. Fence bounds individual mediator and subprocess waits but does not claim local availability against malicious later steps.
- Process attribution is best effort. Short-lived processes, shared sockets, namespace boundaries, and bounded scan limits can produce missing or ambiguous ownership instead of a guessed actor.
- Trusted executable capture prevents path replacement after capture but is not a content-authentication boundary for an already compromised image or root process. Pre-capture byte modification with restored metadata, same-inode privileged writes, the dynamic loader, and shared libraries remain inside the trusted hosted-image/platform assumption.
- GitHub-hosted runner image drift intentionally fails closed until the pinned fingerprint is reviewed and updated.
- macOS, Windows, ARM, self-hosted runners, and job-container protection remain unsupported.