Skip to content

Latest commit

 

History

History
264 lines (204 loc) · 24.8 KB

File metadata and controls

264 lines (204 loc) · 24.8 KB

Fence Security Review

Scope

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.

Release Provenance

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.

Findings Addressed

DNS TCP client deadlines

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.

DNS upstream response binding

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.

Docker DNS configuration file safety

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.

Bounded fixed-command execution

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.

Bounded nftables subprocess input

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.

NFLOG attribute ambiguity

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.

Bounded local incident attribution

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.

Sudo policy source file type

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.

Exact hosted sudo-policy variants

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.

Generated cloud-init sudo header normalization

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.

Effective sudo and trusted-path access

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.

Descriptor-pinned privileged commands

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.

Closed local root-control inventory

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.

In-memory pre-ready rollback and no-restore commit

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.

Source-before-bundle host compatibility

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.

Invocation slug consistency

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.

DNS evidence write propagation

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.

DNS answer and firewall activation ordering

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.

Response-local DNS alias authorization

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.

Runner-Bound And Explicitly Opted-In Results-Storage Authorization

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.

Action child-process deadlines and dependency surface

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.

Residual Risks And Boundaries

  • 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.com names; disable_broad_github_domains: true removes 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 443 compatibility 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.16 as its fixed platform virtual address for DNS, VM-agent, and health communication. Fence permits its root-resident DNS mediator to reach UDP 53 and UID 0 host traffic to reach WireServer TCP 80 and 32526. 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 at 169.254.169.254 on TCP 80 only.
  • 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.