Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 5 additions & 36 deletions .grype.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,49 +23,18 @@ ignore:
# binary. `gh` is a client: it does not run a gRPC server, use xDS RBAC,
# or accept HTTP/2 from untrusted peers, so none of the vulnerable code
# paths are exercised. The finding is present but not exploitable here.
# Present in the agent image (gh via the GitHub CLI apt repo) and the
# cli-proxy image (gh from the official release tarball).
# Present in the cli-proxy image (gh from the official release tarball).
#
# No fix is shippable today: gh 2.96.0 (latest release as of 2026-07-23)
# still pins grpc v1.81.1, and building gh from unreleased trunk would ship
# an untagged, non-official binary (worse supply-chain risk than a
# non-reachable finding). The fix (grpc v1.82.1) is already merged on gh
# trunk, so the next tagged gh release will carry it.
# Revisit: once gh CLI >= v2.97.0 (grpc >= 1.82.1) is released, bump the
# `gh=` pin in agent/Dockerfile and cli-proxy/Dockerfile and DELETE this
# entry. Tracked in github/gh-aw-firewall.
# gh 2.97.0 fixes this in the agent image. The cli-proxy image remains on
# gh 2.96.0, so this exception remains narrowly scoped to that binary.
# Revisit: bump GH_VERSION in cli-proxy/Dockerfile to >= 2.97.0 and DELETE
# this entry. Tracked in github/gh-aw-firewall.
- vulnerability: GHSA-hrxh-6v49-42gf
package:
name: google.golang.org/grpc
version: "v1.81.1"
type: go-module

# ── golang.org/x/text v0.38.0 embedded in gh CLI binary (agent image) ────────
#
# GO-2026-5970 / CVE-2026-56852 (golang.org/x/text v0.38.0 -> 0.39.0, HIGH):
# A norm.Iter can enter an infinite loop when handling input containing
# invalid UTF-8 bytes (denial of service).
#
# Risk acceptance — BOUNDED DoS only, no other impact:
# x/text v0.38.0 is compiled into the `gh` CLI binary. In the agent image,
# `gh` is a client tool invoked by the agent; the worst-case outcome if
# invalid UTF-8 reaches gh's text-normalization path is a hang of that gh
# process — no privilege escalation, data exfiltration, or RCE vector.
# Scoped to /usr/bin/gh (agent image, installed via apt repo, gh=2.96.0).
#
# No fix is shippable today: gh 2.96.0 (latest release as of 2026-07-24)
# still bundles x/text v0.38.0, and building gh from unreleased trunk would
# ship an untagged, non-official binary (worse supply-chain risk).
# Revisit: once a gh CLI release bundling x/text >= v0.39.0 is available,
# bump the `gh=` pin in agent/Dockerfile and DELETE this entry. Tracked in
# github/gh-aw-firewall.
- vulnerability: GO-2026-5970
package:
name: golang.org/x/text
version: "v0.38.0"
type: go-module
location: "/usr/bin/gh"

# ── golang.org/x/text v0.38.0 embedded in gh CLI binary (cli-proxy image) ────
#
# GO-2026-5970 / CVE-2026-56852 (golang.org/x/text v0.38.0 -> 0.39.0, HIGH):
Expand Down
8 changes: 4 additions & 4 deletions containers/agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ RUN if getent hosts azure.archive.ubuntu.com >/dev/null 2>&1; then \
echo "Azure apt mirror not reachable, using default archive.ubuntu.com"; \
fi

# Install required packages, GitHub CLI 2.96.0, Node.js 22.23.1, and npm 11.18.0
# Install required packages, GitHub CLI 2.97.0, Node.js 22.23.1, and npm 11.18.0
# Note: Some packages may already exist in runner-like base images, apt handles this gracefully
# apt_update_retry: retries up to 3 times with backoff; if all fail, reverts to archive.ubuntu.com
RUN set -eux; \
Expand Down Expand Up @@ -95,15 +95,15 @@ RUN set -eux; \
gosu --version && \
# Prefer system binaries over runner toolcache (e.g., act images) for Node checks.
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH" && \
# Install GitHub CLI 2.96.0 from the official GitHub CLI apt repository
# Install GitHub CLI 2.97.0 from the official GitHub CLI apt repository
# (Ubuntu's bundled gh package is 2.4.0 compiled with Go 1.18 — vulnerable)
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \
chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list && \
apt-get update && \
apt_install_retry gh=2.96.0 && \
apt_install_retry gh=2.97.0 && \
# Install Node.js 22.23.1 from the official Node.js binary distribution
# (nodejs.org), NOT the NodeSource apt repo. The NodeSource .deb is tracked by
# a Debian/NodeSource security feed that yields false-positive CVE matches
Expand All @@ -117,7 +117,7 @@ RUN set -eux; \
UNDER_QEMU=false && \
if [ -f /dev/.buildkit_qemu_emulator ] || [ -n "${QEMU_CPU:-}" ]; then UNDER_QEMU=true; fi && \
# Remove any existing nodejs packages first to avoid conflicting detections
apt-get remove -y nodejs npm || true && \
(apt-get remove -y nodejs npm || true) && \
NODE_VERSION="v22.23.1" && \
NODE_DPKG_ARCH="$(dpkg --print-architecture)" && \
case "$NODE_DPKG_ARCH" in \
Expand Down
4 changes: 4 additions & 0 deletions containers/bounded-query/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ RUN chmod -R a-w /opt/awf \
&& node --check /opt/awf/broker/framing.js \
&& node --check /opt/awf/broker/workspace.js \
&& node --check /opt/awf/broker/query-runner.js \
&& node --check /opt/awf/broker/query-runner-spec.js \
&& node --check /opt/awf/broker/docker-client.js \
&& node --check /opt/awf/broker/docker-query-runner.js \
&& node --check /opt/awf/broker/gvisor-query-runner.js \
&& node --check /opt/awf/broker/healthcheck.js

# Fixed broker-only mount points.
Expand Down
22 changes: 20 additions & 2 deletions containers/bounded-query/broker/broker.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const {
const { createLedger } = require('./ledger');
const { createRealClock, waitForBucket } = require('./scheduler');
const defaultWorkspace = require('./workspace');
const defaultRunner = require('./query-runner');

/**
* The trusted bounded-query broker (protocol v2).
Expand Down Expand Up @@ -51,12 +50,16 @@ const defaultRunner = require('./query-runner');
function createBroker(params) {
const { config, seedMap, runId, audit } = params;
const workspace = params.workspace || defaultWorkspace;
const runner = params.runner || defaultRunner;
if (!params.runner) {
throw new Error('createBroker requires a trusted QueryRunner');
}
const runner = params.runner;
const clock = params.clock || createRealClock();
const ledger = params.ledger || createLedger(seedMap);

let invocationsUsed = 0;
let tail = Promise.resolve();
let accepting = true;

/**
* Executes one request and reports its canonical result through
Expand Down Expand Up @@ -200,6 +203,11 @@ function createBroker(params) {
}

return {
/** Stops admitting new invocations while letting admitted work drain. */
close() {
accepting = false;
},

/**
* Handles one request. `respond` is called exactly once with the
* canonical result JSON, as soon as it is ready to send (which, for any
Expand All @@ -219,6 +227,11 @@ function createBroker(params) {
respond(json);
};

if (!accepting) {
safeRespond(CANONICAL_ERROR_JSON);
return Promise.resolve();
}

// The invocation-count cap is operational and independent of the bit
// ledger: it is consumed per *response*, not per launch, so every
// response the agent observes — including a rejection — counts
Expand All @@ -241,6 +254,11 @@ function createBroker(params) {
return queued;
},

/** Resolves when every admitted invocation has finished broker-side work. */
drain() {
return tail;
},

/** @internal Exposed for tests. */
get invocationsUsed() {
return invocationsUsed;
Expand Down
8 changes: 4 additions & 4 deletions containers/bounded-query/broker/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ function loadConfig() {
throw new Error('AWF_BOUNDED_QUERY_MEMORY must be a Docker memory limit (e.g. "512m")');
}

const dockerRuntime = process.env.AWF_BOUNDED_QUERY_RUNTIME || '';
if (dockerRuntime && !/^[A-Za-z0-9_.-]+$/.test(dockerRuntime)) {
throw new Error('AWF_BOUNDED_QUERY_RUNTIME contains unexpected characters');
const queryBackend = requireEnv('AWF_BOUNDED_QUERY_BACKEND');
if (queryBackend !== 'docker' && queryBackend !== 'gvisor') {
throw new Error(`Unsupported AWF_BOUNDED_QUERY_BACKEND: ${queryBackend}`);
}

return {
Expand All @@ -97,7 +97,7 @@ function loadConfig() {
// The daemon resolves query bind-mount sources in *its* filesystem view,
// which is not necessarily the broker's (ARC/DinD split filesystems).
hostWorkDir: requireEnv('AWF_BOUNDED_QUERY_HOST_WORK_DIR'),
dockerRuntime,
queryBackend,
timeoutSeconds: parseTimeoutSeconds(),
maxInvocations: parsePositiveInt('AWF_BOUNDED_QUERY_MAX_INVOCATIONS', 32),
memoryLimit,
Expand Down
31 changes: 31 additions & 0 deletions containers/bounded-query/broker/docker-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict';

const { execFile } = require('child_process');

/**
* Executes the Docker CLI with bounded output and no inherited credentials.
*/
function runDocker(args, timeoutMs) {
return new Promise((resolve) => {
execFile(
'docker',
args,
{
timeout: timeoutMs,
killSignal: 'SIGKILL',
maxBuffer: 64 * 1024,
env: { PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin' },
},
(error, stdout, stderr) => {
resolve({
exitCode: error && typeof error.code === 'number' ? error.code : error ? 1 : 0,
timedOut: Boolean(error && error.killed),
stderr: typeof stderr === 'string' ? stderr.slice(0, 2000) : '',
stdout: typeof stdout === 'string' ? stdout.slice(0, 2000) : '',
});
},
);
});
}

module.exports = { runDocker };
111 changes: 111 additions & 0 deletions containers/bounded-query/broker/docker-query-runner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use strict';

const defaultDockerClient = require('./docker-client');
const {
CLI_GRACE_MS,
buildRemoveArgs,
deriveQueryContainerSpec,
normalizeTimeoutMs,
} = require('./query-runner-spec');

/**
* QueryRunner using the Docker daemon's default OCI runtime.
*
* The optional runtimeName is constructor-controlled so subclasses can select
* a fixed trusted runtime without accepting runtime data per invocation.
*/
class DockerQueryRunner {
constructor(config, deps = {}, runtimeName = undefined) {
this.config = config;
this.runtimeName = runtimeName;
this.docker = deps.docker || defaultDockerClient;
this.cleanupTail = Promise.resolve();
}

async assertAvailable() {
const image = await this.docker.runDocker(['image', 'inspect', this.config.queryImage], 60_000);
if (image.exitCode !== 0) {
throw new Error(`Query image is not available locally: ${this.config.queryImage}`);
}
}

spec(runId, invocationId) {
return deriveQueryContainerSpec({
config: this.config,
runId,
invocationId,
runtimeName: this.runtimeName,
});
}

async listContainerIds(args) {
const listed = await this.docker.runDocker(args, 30_000);
if (listed.exitCode !== 0) {
throw new Error('Failed to reconcile bounded-query containers');
}
const ids = listed.stdout.split('\n').map((id) => id.trim()).filter(Boolean);
if (ids.some((id) => !/^[0-9a-f]{12,64}$/.test(id))) {
throw new Error('Docker returned an invalid bounded-query container id');
}
return ids;
}

async removeListed(args) {
const ids = await this.listContainerIds(args);
if (ids.length === 0) return;
const removed = await this.docker.runDocker(buildRemoveArgs(ids), 30_000);
if (removed.exitCode !== 0) {
throw new Error('Failed to remove bounded-query containers');
}
}

serializeCleanup(operation) {
const queued = this.cleanupTail.then(operation, operation);
this.cleanupTail = queued.then(
() => undefined,
() => undefined,
);
return queued;
}

async reconcileRun(runId) {
const spec = this.spec(runId, 'reconcile');
await this.serializeCleanup(() => this.removeListed(spec.runListArgs));
}

async cleanupInvocation(runId, invocationId) {
const spec = this.spec(runId, invocationId);
await this.serializeCleanup(() => this.removeListed(spec.invocationListArgs));
}

async runQueryContainer(params) {
const spec = this.spec(params.runId, params.invocationId);
const timeoutMs = normalizeTimeoutMs(
(params.timeoutMs ?? this.config.timeoutSeconds * 1000) + CLI_GRACE_MS,
);

let result;
let runError;
try {
result = await this.docker.runDocker(spec.launchArgs, timeoutMs);
} catch (error) {
runError = error;
}

try {
await this.cleanupInvocation(params.runId, params.invocationId);
} catch (cleanupError) {
// A successful `docker run` means the container has already stopped, so
// preserve its result as before this refactor. Timeout/error paths may
// still have a live sandbox and therefore fail closed when cleanup fails.
if (!result || result.timedOut || result.exitCode !== 0) {
throw cleanupError;
}
}

if (runError) throw runError;
return result;
}
}

module.exports = { DockerQueryRunner };
35 changes: 35 additions & 0 deletions containers/bounded-query/broker/gvisor-query-runner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use strict';

const { DockerQueryRunner } = require('./docker-query-runner');

const RUNSC_RUNTIME = 'runsc';

/** QueryRunner using Docker with the fixed runsc OCI runtime. */
class GvisorQueryRunner extends DockerQueryRunner {
constructor(config, deps = {}) {
super(config, deps, RUNSC_RUNTIME);
}

async assertAvailable() {
await super.assertAvailable();
const result = await this.docker.runDocker(
['info', '--format', '{{json .Runtimes}}'],
30_000,
);
if (result.exitCode !== 0) {
throw new Error('Unable to inspect Docker OCI runtimes for gVisor');
}

let runtimes;
try {
runtimes = JSON.parse(result.stdout);
} catch {
throw new Error('Docker returned malformed OCI runtime information');
}
if (!runtimes || !Object.prototype.hasOwnProperty.call(runtimes, RUNSC_RUNTIME)) {
throw new Error('gVisor query backend requires the runsc OCI runtime; no fallback is permitted');
}
}
}

module.exports = { GvisorQueryRunner, RUNSC_RUNTIME };
Loading
Loading