Compare commits
2
Commits
v0.2.2
..
505c9e8c0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
505c9e8c0a | ||
|
|
4be046c8d0 |
@@ -25,6 +25,7 @@ class VerifyArgumentsError extends Error {}
|
||||
|
||||
const USAGE =
|
||||
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
|
||||
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
|
||||
|
||||
export interface VerifyModulePackageArguments {
|
||||
readonly archivePath: string;
|
||||
@@ -90,6 +91,16 @@ export function parseSha256File(contents: string): string {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
|
||||
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
|
||||
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
|
||||
if (configured === undefined) return undefined;
|
||||
if (configured === "" || !path.isAbsolute(configured)) {
|
||||
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
async function readJson(file: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
@@ -121,6 +132,7 @@ export async function readModulePackagePlatform(
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
|
||||
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
|
||||
let expectedSha256 = parsed.expectedSha256;
|
||||
if (parsed.sha256File !== undefined) {
|
||||
let contents: string;
|
||||
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
|
||||
const result = await verifyModulePackage({
|
||||
archivePath: parsed.archivePath,
|
||||
platform,
|
||||
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
|
||||
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
|
||||
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -73,7 +73,8 @@ export type ModulePackageVerificationErrorCode =
|
||||
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
||||
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
||||
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
|
||||
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
|
||||
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
|
||||
|
||||
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||
@@ -118,6 +119,8 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
|
||||
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
|
||||
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
|
||||
"The package metadata contains transitional facts this platform does not understand.",
|
||||
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
|
||||
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
|
||||
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
|
||||
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
|
||||
@@ -157,6 +160,12 @@ export interface VerifyModulePackageOptions {
|
||||
readonly platform: ModulePackagePlatform;
|
||||
/** A directory that must not exist yet; the verified module directory is written there. */
|
||||
readonly extractTo?: string;
|
||||
/**
|
||||
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
|
||||
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
|
||||
* executable scratch mount; each verification creates and removes its own child directory.
|
||||
*/
|
||||
readonly executionScratchDirectory?: string;
|
||||
readonly loadTimeoutMs?: number;
|
||||
}
|
||||
|
||||
@@ -414,12 +423,66 @@ function probeEntrypointPath(): string {
|
||||
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
|
||||
}
|
||||
|
||||
function decodedMountPath(value: string): string {
|
||||
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
|
||||
String.fromCharCode(Number.parseInt(octal, 8)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
|
||||
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
|
||||
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
|
||||
async function isNoExecMount(directory: string): Promise<boolean> {
|
||||
if (process.platform !== "linux") return false;
|
||||
let mountinfo: string;
|
||||
let canonicalDirectory: string;
|
||||
try {
|
||||
[mountinfo, canonicalDirectory] = await Promise.all([
|
||||
readFile("/proc/self/mountinfo", "utf8"),
|
||||
realpath(directory),
|
||||
]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const mounts = mountinfo
|
||||
.split("\n")
|
||||
.map((line) => line.split(" "))
|
||||
.filter((fields) => fields.length > 6 && fields.includes("-"))
|
||||
.map((fields) => ({
|
||||
mountPoint: decodedMountPath(fields[4] ?? ""),
|
||||
options: (fields[5] ?? "").split(","),
|
||||
}))
|
||||
.filter(
|
||||
({ mountPoint }) =>
|
||||
canonicalDirectory === mountPoint ||
|
||||
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
|
||||
)
|
||||
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
|
||||
return mounts[0]?.options.includes("noexec") ?? false;
|
||||
}
|
||||
|
||||
async function createExecutionScratch(baseDirectory: string): Promise<string> {
|
||||
if (!path.isAbsolute(baseDirectory)) {
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
try {
|
||||
if (await isNoExecMount(baseDirectory)) {
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
|
||||
} catch (error) {
|
||||
if (error instanceof ModulePackageVerificationError) throw error;
|
||||
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
||||
* only whether each loaded and exported a definition naming this module. */
|
||||
async function loadEntrypoints(
|
||||
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
||||
entrypoints: Readonly<Record<string, string>>,
|
||||
timeoutMs: number,
|
||||
executionDirectory: string,
|
||||
): Promise<void> {
|
||||
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
||||
let settled = false;
|
||||
@@ -445,7 +508,7 @@ async function loadEntrypoints(
|
||||
}),
|
||||
],
|
||||
{
|
||||
cwd: tmpdir(),
|
||||
cwd: executionDirectory,
|
||||
env: {},
|
||||
execArgv: [],
|
||||
serialization: "json",
|
||||
@@ -514,7 +577,7 @@ export async function verifyModulePackage(
|
||||
const manifest = checkIdentity(archive, metadata, files);
|
||||
checkCompatibility(metadata, manifest, files, options.platform);
|
||||
|
||||
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
|
||||
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
|
||||
try {
|
||||
// The candidate alone, in a scratch inventory of its own: never module storage.
|
||||
const modulesDirectory = path.join(scratch, "candidates");
|
||||
@@ -550,7 +613,7 @@ export async function verifyModulePackage(
|
||||
|
||||
// Executed only now, and first in isolation: a schema is imported in this process below only
|
||||
// after the probe proved it loads on its own.
|
||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
|
||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
|
||||
try {
|
||||
await discoverModuleSchemas(discoveryOptions);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
@@ -48,6 +48,12 @@ async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
|
||||
return { archivePath, sha256: fixture.sha256 };
|
||||
}
|
||||
|
||||
async function executionScratch(): Promise<string> {
|
||||
const directory = path.join(await scratch(), "execution");
|
||||
await mkdir(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function rejection(
|
||||
options: PackageFixtureOptions,
|
||||
code: ModulePackageVerificationErrorCode,
|
||||
@@ -100,6 +106,52 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an explicit execution scratch for pure JavaScript and cleans concurrent runs", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const executionScratchDirectory = await executionScratch();
|
||||
const runs = Array.from({ length: 4 }, () =>
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
);
|
||||
await expect(Promise.all(runs)).resolves.toHaveLength(4);
|
||||
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("cleans its execution scratch after a backend load failure", async () => {
|
||||
const { archivePath } = await archiveFile({
|
||||
serverSource:
|
||||
'import "dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0" };\n',
|
||||
});
|
||||
const executionScratchDirectory = await executionScratch();
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" });
|
||||
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a missing execution scratch as a verifier environment error", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({
|
||||
code: "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE",
|
||||
message:
|
||||
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||
});
|
||||
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("rejects an unsafe archive before consulting the execution scratch", async () => {
|
||||
const { archivePath } = await archiveFile(
|
||||
withEntry({ path: "example/../attacker-chosen", data: "x" }),
|
||||
);
|
||||
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||
await expect(
|
||||
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||
).rejects.toMatchObject({ code: "MODULE_PACKAGE_UNSAFE_PATH" });
|
||||
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
|
||||
const { archivePath } = await archiveFile();
|
||||
const target = path.join(await scratch(), "example");
|
||||
|
||||
@@ -3,6 +3,8 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MODULE_VERIFIER_SCRATCH_ENV,
|
||||
moduleVerifierScratchDirectory,
|
||||
parseSha256File,
|
||||
parseVerifyModulePackageArguments,
|
||||
readModulePackagePlatform,
|
||||
@@ -57,6 +59,21 @@ describe("verify-module-package digest files", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify-module-package execution scratch", () => {
|
||||
it("accepts an absolute operator-configured directory and otherwise leaves the library default", () => {
|
||||
expect(moduleVerifierScratchDirectory({})).toBeUndefined();
|
||||
expect(
|
||||
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: "/run/ekklesia-verifier" }),
|
||||
).toBe("/run/ekklesia-verifier");
|
||||
});
|
||||
|
||||
it.each(["", "relative/path"])("refuses the invalid configured path %j", (configured) => {
|
||||
expect(() =>
|
||||
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: configured }),
|
||||
).toThrow(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verify-module-package platform facts", () => {
|
||||
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
|
||||
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
|
||||
|
||||
+12
-1
@@ -170,9 +170,20 @@ identity, target, and Node.js range against this platform, runs the platform's m
|
||||
validation, and loads the backend once in a short-lived process with no environment. Only then does
|
||||
it write the verified module directory, to a new directory outside module storage:
|
||||
|
||||
The backend probe executes the package's server-side code; the verifier container is its isolation
|
||||
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
|
||||
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
|
||||
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
|
||||
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
|
||||
the verifier creates a unique child for each run and removes it on success or failure.
|
||||
|
||||
```sh
|
||||
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
|
||||
docker run --rm --network none --read-only --tmpfs /tmp \
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
|
||||
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
|
||||
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
|
||||
|
||||
@@ -701,7 +701,10 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
|
||||
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
|
||||
special files, and escaping paths. It checks every digest and identity, the target and runtime,
|
||||
runs the platform's own module, schema, and migration validation, and loads the backend once in a
|
||||
short-lived process with an empty environment. Only then does it extract anything.
|
||||
short-lived process with an empty environment. Only then does it copy the package to a requested
|
||||
output directory. In the hardened container invocation, `/tmp` remains `noexec`; the temporary
|
||||
candidate lives on a dedicated, bounded executable tmpfs because a native addon's code must be
|
||||
mapped executable to load.
|
||||
- **A native package's host checks.** It is accepted only on a host whose operating system, CPU,
|
||||
C library family and version, and Node-API version Node.js itself reports as compatible.
|
||||
- **ELF files.** Every ELF file in it must be built for the target's machine.
|
||||
|
||||
@@ -484,7 +484,13 @@ before it is installed: its digest, its archive safety (no links, special files,
|
||||
escaping paths), every file's digest, and its identity and compatibility. The digest proves
|
||||
integrity, not who published the package; signing is not implemented. The verifier loads the backend
|
||||
in a short-lived process with an empty environment, so module code never sees a database URL or a
|
||||
secret while it is being checked.
|
||||
secret while it is being checked. That load executes package code: `noexec` is retained on the
|
||||
general `/tmp`, but native packages require the verifier's narrow, bounded execution scratch to be
|
||||
executable. The one-shot hardened verifier container—not the scratch mount—is the isolation
|
||||
boundary, and it is not claimed to be a perfect sandbox. Use the supported invocation in
|
||||
[`deploy/README.md`](../../deploy/README.md#installed-modules), which also keeps the root filesystem
|
||||
read-only, runs as the image's non-root user, disables networking and privilege escalation, and
|
||||
drops Linux capabilities.
|
||||
|
||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||||
|
||||
|
||||
@@ -318,7 +318,10 @@ require exact agreement with Core. The legacy facade has not been removed.
|
||||
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
|
||||
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
|
||||
installing it. Pass `--extract-to <new directory>` to receive the verified module directory.
|
||||
[deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation.
|
||||
[deploy/README.md](../../deploy/README.md#installed-modules) shows the supported hardened container
|
||||
invocation; use it rather than inventing mount flags. Backend verification executes package code.
|
||||
Its container is therefore the isolation boundary: general `/tmp` remains `noexec`, while a narrow,
|
||||
bounded, ephemeral verifier scratch tmpfs permits execution so native addons can load.
|
||||
|
||||
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
|
||||
installed package normally has no source, so the platform cannot check it the same way. Your
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the repository's real sharp proof as a native Module Package for the hardened verifier
|
||||
// container test. Run inside deploy/Dockerfile's build stage, where the workspace is already built
|
||||
// and has exactly the locked dependencies the runtime image was built against.
|
||||
|
||||
import { cp, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { packModule } from "@ekklesia/module-pack";
|
||||
|
||||
const repositoryRoot = path.resolve(import.meta.dirname, "..", "..", "..", "..");
|
||||
const outDirectory = process.argv[2];
|
||||
if (outDirectory === undefined || !path.isAbsolute(outDirectory)) {
|
||||
throw new Error("An absolute output directory is required.");
|
||||
}
|
||||
|
||||
const temporaryRoot = await mkdtemp(path.join(tmpdir(), "ekklesia-native-verifier-proof-"));
|
||||
try {
|
||||
const moduleDirectory = path.join(temporaryRoot, "native-proof");
|
||||
await cp(
|
||||
path.join(repositoryRoot, "packages", "module-pack", "tests", "fixtures", "native-module"),
|
||||
moduleDirectory,
|
||||
{ recursive: true },
|
||||
);
|
||||
const packageFile = path.join(moduleDirectory, "package.json");
|
||||
const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
|
||||
packageJson.engines = { node: ">=24.11.0 <25" };
|
||||
await writeFile(packageFile, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
const modulesDirectory = path.join(moduleDirectory, "node_modules");
|
||||
await mkdir(path.join(modulesDirectory, "@ekklesia"), { recursive: true });
|
||||
await symlink(
|
||||
await realpath(path.join(repositoryRoot, "packages", "module-pack", "node_modules", "sharp")),
|
||||
path.join(modulesDirectory, "sharp"),
|
||||
);
|
||||
await symlink(
|
||||
await realpath(
|
||||
path.join(
|
||||
repositoryRoot,
|
||||
"packages",
|
||||
"module-pack",
|
||||
"node_modules",
|
||||
"@ekklesia",
|
||||
"module-sdk",
|
||||
),
|
||||
),
|
||||
path.join(modulesDirectory, "@ekklesia", "module-sdk"),
|
||||
);
|
||||
|
||||
const result = await packModule({
|
||||
licenseFile: path.join(repositoryRoot, "LICENSE"),
|
||||
moduleDirectory,
|
||||
outDirectory,
|
||||
sourceRevision: "0".repeat(40),
|
||||
target: "linux-x64-glibc",
|
||||
});
|
||||
process.stdout.write(`${path.basename(result.archivePath)}\n`);
|
||||
} finally {
|
||||
await rm(temporaryRoot, { force: true, recursive: true });
|
||||
}
|
||||
+67
-1
@@ -6,7 +6,9 @@
|
||||
# - its module storage mount point, /var/lib/ekklesia/modules, exists, is root-owned, and is empty;
|
||||
# - EKKLESIA_MODULES_DIR names that directory, in the image configuration and in a running process;
|
||||
# - a checkout with a populated modules/ directory builds the same image: the runtime file system
|
||||
# of both builds is identical, and no module from that directory enters it.
|
||||
# of both builds is identical, and no module from that directory enters it;
|
||||
# - the hardened verifier keeps /tmp noexec while a dedicated bounded execution scratch loads and
|
||||
# runs the real sharp 0.35.5/libvips 1.3.4 native proof package.
|
||||
#
|
||||
# bash scripts/check-image.sh builds both images from this checkout
|
||||
# EKKLESIA_CHECK_IMAGE=<image> bash scripts/check-image.sh checks one existing image's contents
|
||||
@@ -103,6 +105,7 @@ context_copy() {
|
||||
tag_base="ekklesia-ci:${GITHUB_SHA:-local}-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}-"
|
||||
generic_image="${tag_base}generic-$$"
|
||||
populated_image="${tag_base}populated-$$"
|
||||
builder_image="${tag_base}native-proof-builder-$$"
|
||||
|
||||
# build_image <context> <tag>
|
||||
build_image() {
|
||||
@@ -143,4 +146,67 @@ else
|
||||
echo "note the two builds' layers differ (a cold build cache); their file systems do not"
|
||||
fi
|
||||
|
||||
step "Proving native package verification with /tmp noexec and a dedicated execution scratch"
|
||||
built_images+=("$builder_image")
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file "$work/generic/deploy/Dockerfile" \
|
||||
--target build --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--label "${EKKLESIA_CI_CREATED_LABEL}=$(date +%s)" \
|
||||
--tag "$builder_image" "$work/generic" >/dev/null || fail "the native proof builder did not build."
|
||||
mkdir "$work/native-package"
|
||||
package_archive="$({
|
||||
docker run --rm --network none --user "$(id -u):$(id -g)" \
|
||||
-v "$work/native-package:/proof" --entrypoint node "$builder_image" \
|
||||
packages/module-pack/tests/support/pack-native-verifier-proof.mjs /proof
|
||||
} | tail -1)" || fail "the real native proof package did not build."
|
||||
[[ "$package_archive" == native-proof-0.1.0-linux-x64-glibc.tar.gz ]] ||
|
||||
fail "the native proof builder returned an unexpected archive."
|
||||
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e "PROOF_ARCHIVE=$package_archive" -v "$work/native-package:/package:ro" \
|
||||
--entrypoint sh "$generic_image" -ec '
|
||||
fail() { echo "check-image: $*" >&2; exit 1; }
|
||||
verifier() {
|
||||
EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
|
||||
--sha256-file "/package/$PROOF_ARCHIVE.sha256" "$@"
|
||||
}
|
||||
scratch_failure() {
|
||||
if output="$(EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR="$1" \
|
||||
node core/dist/cli/verify-module-package.js "/package/$PROOF_ARCHIVE" \
|
||||
--sha256-file "/package/$PROOF_ARCHIVE.sha256" 2>&1)"; then
|
||||
fail "the verifier accepted unusable execution scratch $1."
|
||||
fi
|
||||
printf "%s\n" "$output" | grep -Fq "[MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE]" ||
|
||||
fail "the verifier did not identify unusable execution scratch $1."
|
||||
}
|
||||
|
||||
grep -Eq " /tmp .*noexec" /proc/mounts || fail "/tmp is not mounted noexec."
|
||||
verifier --extract-to /tmp/noexec-installed >/tmp/noexec-verify.log
|
||||
if env -i /usr/local/bin/node --input-type=module --eval \
|
||||
"await import(\"file:///tmp/noexec-installed/server/index.js\");" \
|
||||
>/tmp/noexec-load.log 2>&1; then
|
||||
fail "the native addon unexpectedly loaded from noexec /tmp."
|
||||
fi
|
||||
rm -rf /tmp/noexec-installed
|
||||
scratch_failure /tmp
|
||||
scratch_failure /missing-verifier-scratch
|
||||
scratch_failure /package
|
||||
|
||||
verifier --extract-to /run/ekklesia-verifier/installed >/tmp/native-verify.log
|
||||
env -i /usr/local/bin/node --input-type=module --eval \
|
||||
"const m=await import(\"file:///run/ekklesia-verifier/installed/server/index.js\"); const actual=await m.proof(); const expected={pixels:[255,0,0,255,0,0,255,0,0,255,0,0],png:{format:\"png\",height:3,width:5},resized:{channels:3,height:2,width:2}}; if(JSON.stringify(actual)!==JSON.stringify(expected)) throw new Error(\"sharp proof mismatch\");"
|
||||
rm -rf /run/ekklesia-verifier/installed
|
||||
|
||||
verifier >/tmp/native-verify-a.log 2>&1 & first=$!
|
||||
verifier >/tmp/native-verify-b.log 2>&1 & second=$!
|
||||
wait "$first" || { cat /tmp/native-verify-a.log >&2; fail "first concurrent verification failed."; }
|
||||
wait "$second" || { cat /tmp/native-verify-b.log >&2; fail "second concurrent verification failed."; }
|
||||
[ -z "$(find /run/ekklesia-verifier -mindepth 1 -print -quit)" ] ||
|
||||
fail "the verifier left execution scratch behind."
|
||||
' || fail "the hardened native verifier proof failed."
|
||||
echo "ok /tmp stayed noexec; native verification, sharp execution, rejection, concurrency, and cleanup passed"
|
||||
|
||||
echo "Generic platform image check passed."
|
||||
|
||||
@@ -446,8 +446,11 @@ module_storage() {
|
||||
install_test_module() {
|
||||
local verified
|
||||
verified="$(
|
||||
docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL \
|
||||
--security-opt no-new-privileges \
|
||||
docker run --rm --network none --read-only \
|
||||
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||
--cap-drop ALL --security-opt no-new-privileges \
|
||||
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||
-v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \
|
||||
--entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \
|
||||
--sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1
|
||||
|
||||
@@ -157,6 +157,21 @@ describe("CI scripts on a shared Docker daemon", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("exercise a real native package through the hardened verifier container", async () => {
|
||||
const imageCheck = (await source("scripts/check-image.sh")).replace(/\\\n\s*/gu, " ");
|
||||
expect(imageCheck).toContain(
|
||||
"packages/module-pack/tests/support/pack-native-verifier-proof.mjs",
|
||||
);
|
||||
expect(imageCheck).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
|
||||
expect(imageCheck).toContain(
|
||||
"--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000",
|
||||
);
|
||||
expect(imageCheck).toContain("const actual=await m.proof()");
|
||||
expect(imageCheck).toContain("file:///tmp/noexec-installed/server/index.js");
|
||||
expect(imageCheck).toContain("scratch_failure /tmp");
|
||||
expect(imageCheck).toContain("scratch_failure /package");
|
||||
});
|
||||
|
||||
it("install the smoke test's module only as a canonical package the image itself verified (ADR 0049)", async () => {
|
||||
const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
|
||||
const packaging = await source("scripts/lib/build-module-package.Dockerfile");
|
||||
@@ -166,6 +181,11 @@ describe("CI scripts on a shared Docker daemon", () => {
|
||||
expect(deploy).toMatch(
|
||||
/--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to \/staging\/example/u,
|
||||
);
|
||||
expect(deploy).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m");
|
||||
expect(deploy).toContain(
|
||||
"--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000",
|
||||
);
|
||||
expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier");
|
||||
// Module storage only ever receives the verified tree from staging.
|
||||
expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user