Skip to content
Open
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
9 changes: 8 additions & 1 deletion docs/lab-runtime-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The default `container` isolation mode above shares the host kernel with every l
### Enabling it

- Per-lab: pass `isolation: "microvm"` (and optionally `microvmRuntimeClass`) to the lab create/start/restart API. The choice is persisted in lab metadata, so subsequent starts reuse it.
- Workload class: selecting `workloadClass: "untrusted-code"` or `workloadClass: "malware-analysis"` requires and persists MicroVM isolation. An explicit request to run either class with `isolation: "container"` is rejected; omitting isolation selects `microvm` and then follows the same fail-closed runtime-availability check. Containers carry both isolation and workload-class labels for external inspection.
- Host-wide default: set `PROJECT_LAB_ISOLATION_MODE=microvm`, and optionally `PROJECT_LAB_MICROVM_RUNTIME_CLASS` to override the default runtime class.
- Runtime class names map directly to `docker run --runtime <class>` and must already be registered in the Docker daemon's `runtimes` config (`/etc/docker/daemon.json`) by a Kata Containers install:
- `kata-fc` (default) — Firecracker VMM. Smallest device model and strongest isolation, but only virtio net/block/vsock devices are available to the guest.
Expand All @@ -36,7 +37,13 @@ The default `container` isolation mode above shares the host kernel with every l

### Fail-closed behavior

Because isolation strength is a security property, the runtime never silently downgrades from `microvm` to plain containers. Before starting a lab with `isolation: "microvm"`, it runs `docker info --format '{{json .Runtimes}}'` and refuses to start (throwing `MicrovmRuntimeUnavailableError`) if the requested runtime class is not registered with the Docker daemon. In `dry-run` mode the availability check is included in the planned command list but is never executed or enforced.
Because isolation strength is a security property, the runtime never silently downgrades from `microvm` to plain containers. Before starting a lab with `isolation: "microvm"`, it runs `docker info --format '{{json .Runtimes}}'` and refuses to start (throwing `MicrovmRuntimeUnavailableError`) if the requested runtime class is not registered with the Docker daemon.

Registration is only a preflight. After every MicroVM start path, the runtime also asks the external Docker daemon for the running container's ID, state, selected OCI runtime, isolation label, and workload-class label. The lab is trusted as running only when those observed facts match the request. Missing, malformed, stopped, or mismatched observations trigger fail-safe removal of the recorder, egress controller, and workload. The failed attestation and cleanup outcome remain in the lab's durable runtime metadata.

The project lab service also records each failed MicroVM start attestation as a redacted canonical Artifact using the `exploit-hunter.lab-containment-event.v1` schema. The event distinguishes an isolation-attestation failure from an unresolved cleanup failure, preserves requested and daemon-observed isolation facts, records the cleanup target and independently verified removal outcome, and carries the project, active thread, and trusted research-run correlation available at the lifecycle boundary. A verified start does not emit a violation Artifact. Artifact storage is forensic best effort: an Artifact outage is surfaced in diagnostics but never replaces, suppresses, or weakens the original fail-closed containment result.

`dry-run` mode records an attestation as `planned`, never `verified`. The project lab service refuses to persist a MicroVM lab as running until it receives a verified external attestation. This check proves what the Docker daemon selected; it does not independently prove guest-kernel health or cover ongoing namespace, mount, privilege, device, socket, firewall, or listener integrity.

### Host setup

Expand Down
1 change: 1 addition & 0 deletions src/server/evidence/ingestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const EVIDENCE_SOURCES = [
"patch-verification",
"patch-remediation",
"stage-handoff",
"containment-observation",
] as const;

export type EvidenceSource = (typeof EVIDENCE_SOURCES)[number];
Expand Down
40 changes: 37 additions & 3 deletions src/server/labs/docker-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
LAB_HARDENING_LABELS,
LAB_HOME_PATH,
LAB_WORKSPACE_PATH,
resolveLabIsolationMode,
resolveLabIsolationForWorkload,
resolveMicrovmRuntimeClass,
SAFE_MICROVM_RUNTIME_CLASS,
} from "./hardening";
Expand Down Expand Up @@ -180,6 +180,8 @@ export const labContainerIdentity = (options: LabContainerOptions): LabContainer
home: `${namePrefix}-home`,
workspace: `${namePrefix}-workspace`,
};
const workloadClass = options.workloadClass ?? "standard";
const isolation = resolveLabIsolationForWorkload(options.isolation, workloadClass);

assertSafeValue("container name", containerName, SAFE_DOCKER_NAME);
assertSafeValue("hostname", hostname, SAFE_DOCKER_NAME);
Expand All @@ -197,6 +199,8 @@ export const labContainerIdentity = (options: LabContainerOptions): LabContainer
"exploit-hunter.boundary": options.boundary,
"exploit-hunter.thread-id": safeThreadWorkspaceSegment(options.threadId, "default-thread"),
"exploit-hunter.workspace-layout": "thread-bind-v1",
"exploit-hunter.isolation": isolation,
"exploit-hunter.workload-class": workloadClass,
},
};
};
Expand Down Expand Up @@ -234,7 +238,10 @@ export const buildRunLabCommand = (options: LabContainerOptions): DockerCommandS
const user = options.user ?? DEFAULT_KALI_LAB_USER;
const networkProfile = getLabNetworkProfile(options.boundary, options.networkProfile);
const limits = mergeResourceLimits(options);
const isolation = resolveLabIsolationMode(options.isolation);
const isolation = resolveLabIsolationForWorkload(
options.isolation,
options.workloadClass ?? "standard",
);
const microvmRuntimeClass =
isolation === "microvm" ? resolveMicrovmRuntimeClass(options.microvmRuntimeClass) : null;
const trafficRecording = trafficRecordingModeFor(options);
Expand Down Expand Up @@ -269,6 +276,7 @@ export const buildRunLabCommand = (options: LabContainerOptions): DockerCommandS
"exploit-hunter.network-profile": networkProfile.id,
"exploit-hunter.image-ref": image,
"exploit-hunter.isolation": isolation,
"exploit-hunter.workload-class": options.workloadClass ?? "standard",
"exploit-hunter.traffic-recording": trafficRecording,
...(microvmRuntimeClass
? { "exploit-hunter.microvm-runtime-class": microvmRuntimeClass }
Expand Down Expand Up @@ -506,7 +514,10 @@ export const buildStartLabCommands = (options: LabContainerOptions): DockerComma
export const buildMicrovmRuntimeCheckCommand = (
options: LabContainerOptions,
): DockerCommandSpec | null => {
if (resolveLabIsolationMode(options.isolation) !== "microvm") {
if (
resolveLabIsolationForWorkload(options.isolation, options.workloadClass ?? "standard") !==
"microvm"
) {
return null;
}
const runtimeClass = resolveMicrovmRuntimeClass(options.microvmRuntimeClass);
Expand All @@ -518,6 +529,29 @@ export const buildMicrovmRuntimeCheckCommand = (
};
};

export const buildMicrovmStartAttestationCommand = (
options: LabContainerOptions,
): DockerCommandSpec | null => {
if (
resolveLabIsolationForWorkload(options.isolation, options.workloadClass ?? "standard") !==
"microvm"
) {
return null;
}
return {
command: "docker",
args: [
"container",
"inspect",
"--format",
"{{json .Id}}\n{{json .State.Running}}\n{{json .HostConfig.Runtime}}\n{{json .Config.Labels}}",
labContainerIdentity(options).containerName,
],
reason:
"Attest the running lab isolation boundary from the external Docker daemon before trusting it.",
};
};

export const buildStopLabCommand = (options: LabContainerOptions): DockerCommandSpec => ({
command: "docker",
args: ["stop", labContainerIdentity(options).containerName],
Expand Down
18 changes: 17 additions & 1 deletion src/server/labs/hardening.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { LabBoundary, LabIsolationMode, LabResourceLimits } from "./types";
import type { LabBoundary, LabIsolationMode, LabResourceLimits, LabWorkloadClass } from "./types";

export const PREVIOUS_KALI_LAB_IMAGE = "exploit-hunter/kali-workspace:latest";
export const DEFAULT_KALI_LAB_IMAGE = "exploit-hunter/kali-workspace:iptables";
Expand Down Expand Up @@ -97,6 +97,22 @@ export const resolveLabIsolationMode = (isolation?: LabIsolationMode): LabIsolat
: DEFAULT_LAB_ISOLATION_MODE;
};

export class HighRiskIsolationRequiredError extends Error {
constructor(readonly workloadClass: Exclude<LabWorkloadClass, "standard">) {
super(`Workload class "${workloadClass}" requires MicroVM isolation.`);
this.name = "HighRiskIsolationRequiredError";
}
}

export const resolveLabIsolationForWorkload = (
isolation: LabIsolationMode | undefined,
workloadClass: LabWorkloadClass,
): LabIsolationMode => {
if (workloadClass === "standard") return resolveLabIsolationMode(isolation);
if (isolation === "container") throw new HighRiskIsolationRequiredError(workloadClass);
return "microvm";
};

export const resolveMicrovmRuntimeClass = (runtimeClass?: string): string =>
runtimeClass?.trim() ||
process.env.PROJECT_LAB_MICROVM_RUNTIME_CLASS?.trim() ||
Expand Down
Loading
Loading