From b959f087fd4d119c70e3855d230c1027038b549b Mon Sep 17 00:00:00 2001 From: telemarkdigital-publisher <307230483+telemarkdigital-publisher@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:59:08 +0200 Subject: [PATCH] Add repository sandbox egress guard --- PR_BODY_REPOSITORY_SANDBOX_EGRESS_10.md | 36 +++ repository-sandbox-egress-guard/README.md | 42 +++ .../data/sample_sandbox_run_plans.json | 178 ++++++++++++ repository-sandbox-egress-guard/package.json | 13 + .../reports/demo.mp4 | Bin 0 -> 54316 bytes .../reports/reviewer-packet.md | 68 +++++ .../reports/summary.json | 262 +++++++++++++++++ .../reports/summary.svg | 17 ++ .../scripts/demo.js | 77 +++++ .../scripts/render-demo-video.js | 60 ++++ repository-sandbox-egress-guard/src/index.js | 273 ++++++++++++++++++ .../repository-sandbox-egress-guard.test.js | 65 +++++ 12 files changed, 1091 insertions(+) create mode 100644 PR_BODY_REPOSITORY_SANDBOX_EGRESS_10.md create mode 100644 repository-sandbox-egress-guard/README.md create mode 100644 repository-sandbox-egress-guard/data/sample_sandbox_run_plans.json create mode 100644 repository-sandbox-egress-guard/package.json create mode 100644 repository-sandbox-egress-guard/reports/demo.mp4 create mode 100644 repository-sandbox-egress-guard/reports/reviewer-packet.md create mode 100644 repository-sandbox-egress-guard/reports/summary.json create mode 100644 repository-sandbox-egress-guard/reports/summary.svg create mode 100644 repository-sandbox-egress-guard/scripts/demo.js create mode 100644 repository-sandbox-egress-guard/scripts/render-demo-video.js create mode 100644 repository-sandbox-egress-guard/src/index.js create mode 100644 repository-sandbox-egress-guard/test/repository-sandbox-egress-guard.test.js diff --git a/PR_BODY_REPOSITORY_SANDBOX_EGRESS_10.md b/PR_BODY_REPOSITORY_SANDBOX_EGRESS_10.md new file mode 100644 index 00000000..94b1ea44 --- /dev/null +++ b/PR_BODY_REPOSITORY_SANDBOX_EGRESS_10.md @@ -0,0 +1,36 @@ +/claim #10 + +## Summary + +Adds `repository-sandbox-egress-guard/`, a focused Project Repository & Version Control guard for computation-aware reproducibility sandboxes. + +This is distinct from existing #10 slices for repository ledgers, branch protection, component-owner approval, semantic tags, Git LFS pointer integrity, notebook output diffs, fork provenance, release signatures, external-reference pinning, citation impact, and export bundle manifests. It focuses only on whether a repository reproducibility pipeline can run, merge, or support a DOI/tagged release without unsafe network egress, secret exposure, privileged execution, host mounts, or workspace-escaping outputs. + +## What is included + +- Dependency-free Node evaluator. +- Synthetic repository execution plans. +- Deterministic `RELEASE`, `REVIEW_MERGE`, and `HOLD_MERGE` decisions. +- Checks for pinned container images, command hashes, lockfiles, network egress, secrets, privileged/root/host namespace settings, unsafe mounts, resource caps, and output paths. +- Reviewer artifacts: + - `reports/summary.json` + - `reports/reviewer-packet.md` + - `reports/summary.svg` + - `reports/demo.mp4` + +## Validation + +Locally verified: + +```text +npm.cmd test +npm.cmd run demo +npm.cmd run check +npm.cmd run video +git diff --check +confirmed reports/demo.mp4 was generated as a non-empty local artifact +``` + +The MP4 demo is an FFmpeg-generated synthetic slate only. It is not a screen recording and contains no desktop capture, credentials, private files, or personal information. + +AI-assisted with OpenAI Codex; I reviewed and locally verified the implementation before submitting. diff --git a/repository-sandbox-egress-guard/README.md b/repository-sandbox-egress-guard/README.md new file mode 100644 index 00000000..a8e1c23f --- /dev/null +++ b/repository-sandbox-egress-guard/README.md @@ -0,0 +1,42 @@ +# Repository Sandbox Egress Guard + +This contribution adds a focused guard for computation-aware repository execution in SCIBASE's Project Repository & Version Control layer. It checks whether a repository reproducibility run can be executed, reviewed, merged, or tagged without leaking secrets, reaching unapproved network destinations, or mutating hosted artifacts outside the declared workspace. + +The slice is intentionally narrow. It does not replace broad repository versioning, file diffs, semantic tags, Git LFS integrity, branch protection, component-owner approvals, notebook replay, release signatures, citation badges, or export-bundle manifests. It covers the execution sandbox boundary for reproducibility pipelines. + +## What It Checks + +- Public release, DOI, or protected-branch merge is frozen when sandbox findings are unresolved. +- Reproducibility runs use pinned container images, locked dependency manifests, and hashed commands. +- Network egress is denied or limited to an explicit allowlist. +- Secret environment variables, host secret mounts, and credential-like runtime inputs are blocked. +- Privileged containers, root execution, host PID/IPC sharing, and writable Docker socket mounts are held. +- Resource caps for CPU, memory, runtime, and artifact size are present. +- Output paths stay inside declared `results/` or `artifacts/` locations. +- Generated reviewer packets explain deterministic `RELEASE`, `REVIEW_MERGE`, or `HOLD_MERGE` decisions. + +## Running Locally + +```bash +npm test +npm run demo +npm run check +npm run video +``` + +The demo uses only synthetic repository execution plans in `data/sample_sandbox_run_plans.json`. + +Generated files: + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +The MP4 is generated from an FFmpeg color source and drawtext slate. It is not a screen recording and contains no desktop capture or private information. + +## Why This Matters + +SCIBASE repository releases are credible only when reproducibility pipelines are repeatable and contained. A notebook or analysis script that can read host secrets, call arbitrary services, or write outside the repository workspace can undermine trust even when the version history looks clean. + +This guard gives reviewers a deterministic packet to hold risky execution plans, request sandbox hardening, and keep protected-branch merges or tagged DOI releases frozen until the computation boundary is safe. diff --git a/repository-sandbox-egress-guard/data/sample_sandbox_run_plans.json b/repository-sandbox-egress-guard/data/sample_sandbox_run_plans.json new file mode 100644 index 00000000..14bf63f6 --- /dev/null +++ b/repository-sandbox-egress-guard/data/sample_sandbox_run_plans.json @@ -0,0 +1,178 @@ +[ + { + "id": "repo-release-001", + "title": "Clean climate notebook reproduction", + "repository": { + "publicReleaseEnabled": true, + "protectedBranchMerge": true, + "doiTag": "v1.2.0" + }, + "execution": { + "pipeline": "notebooks/run_analysis.ipynb", + "commandHashPresent": true, + "lockfilePresent": true, + "runtime": "node18-python311" + }, + "container": { + "image": "ghcr.io/scibase/repro-runner", + "digest": "sha256:7b8a8f2f9d8a73c7aa11188e9b3188bde11e2f3b21fcf5a8b70c1f949820ed13", + "privileged": false, + "user": "1000:1000", + "hostPid": false, + "hostIpc": false, + "mounts": [ + { "source": "workspace", "target": "/workspace", "mode": "ro" }, + { "source": "results", "target": "/workspace/results", "mode": "rw" } + ] + }, + "network": { + "mode": "deny", + "allowedHosts": [] + }, + "secrets": { + "env": [], + "mounts": [] + }, + "resources": { + "cpu": "2", + "memoryMb": 4096, + "timeoutSeconds": 900, + "artifactLimitMb": 250 + }, + "outputs": { + "paths": ["results/figures/summary.png", "results/tables/model_metrics.csv"] + } + }, + { + "id": "repo-hold-002", + "title": "Model benchmark rerun with open egress and host secrets", + "repository": { + "publicReleaseEnabled": true, + "protectedBranchMerge": true, + "doiTag": "preprint-v3" + }, + "execution": { + "pipeline": "notebooks/benchmark.ipynb", + "commandHashPresent": false, + "lockfilePresent": false, + "runtime": "python311" + }, + "container": { + "image": "python:latest", + "digest": "", + "privileged": true, + "user": "root", + "hostPid": true, + "hostIpc": false, + "mounts": [ + { "source": "/var/run/docker.sock", "target": "/var/run/docker.sock", "mode": "rw" }, + { "source": "workspace", "target": "/workspace", "mode": "rw" } + ] + }, + "network": { + "mode": "open", + "allowedHosts": ["*"] + }, + "secrets": { + "env": ["AWS_SECRET_ACCESS_KEY", "SCIBASE_API_TOKEN"], + "mounts": ["/home/researcher/.ssh"] + }, + "resources": { + "cpu": "", + "memoryMb": 0, + "timeoutSeconds": 0, + "artifactLimitMb": 0 + }, + "outputs": { + "paths": ["../published/model.pkl", "/tmp/raw-dump.csv"] + } + }, + { + "id": "repo-review-003", + "title": "Materials simulation with approved package mirror", + "repository": { + "publicReleaseEnabled": false, + "protectedBranchMerge": false, + "doiTag": "" + }, + "execution": { + "pipeline": "code/run_simulation.py", + "commandHashPresent": true, + "lockfilePresent": false, + "runtime": "python311" + }, + "container": { + "image": "ghcr.io/scibase/materials-runner", + "digest": "sha256:2d9f0d60c20afaf0a724f911ba894c30df83f061f1da7c8119f79a26b985bf91", + "privileged": false, + "user": "1000:1000", + "hostPid": false, + "hostIpc": false, + "mounts": [ + { "source": "workspace", "target": "/workspace", "mode": "ro" }, + { "source": "results", "target": "/workspace/results", "mode": "rw" } + ] + }, + "network": { + "mode": "allowlist", + "allowedHosts": ["packages.scibase.example"] + }, + "secrets": { + "env": [], + "mounts": [] + }, + "resources": { + "cpu": "4", + "memoryMb": 8192, + "timeoutSeconds": 1800, + "artifactLimitMb": 500 + }, + "outputs": { + "paths": ["results/simulation/report.json"] + } + }, + { + "id": "repo-hold-004", + "title": "Private dataset release with writable home mount", + "repository": { + "publicReleaseEnabled": false, + "protectedBranchMerge": true, + "doiTag": "" + }, + "execution": { + "pipeline": "code/reproduce.sh", + "commandHashPresent": true, + "lockfilePresent": true, + "runtime": "conda" + }, + "container": { + "image": "ghcr.io/scibase/conda-runner", + "digest": "sha256:a14d0ea51f321418f1b7b4957677f0baaa18b6eab0e41a75032c5f0ee9913e58", + "privileged": false, + "user": "root", + "hostPid": false, + "hostIpc": true, + "mounts": [ + { "source": "/home/researcher", "target": "/host-home", "mode": "rw" }, + { "source": "workspace", "target": "/workspace", "mode": "rw" } + ] + }, + "network": { + "mode": "deny", + "allowedHosts": [] + }, + "secrets": { + "env": ["DATABASE_URL"], + "mounts": [] + }, + "resources": { + "cpu": "2", + "memoryMb": 4096, + "timeoutSeconds": 1200, + "artifactLimitMb": 200 + }, + "outputs": { + "paths": ["results/private-summary.csv"] + } + } +] diff --git a/repository-sandbox-egress-guard/package.json b/repository-sandbox-egress-guard/package.json new file mode 100644 index 00000000..4ec32d3d --- /dev/null +++ b/repository-sandbox-egress-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "repository-sandbox-egress-guard", + "version": "1.0.0", + "description": "Dependency-free guard for computation-aware repository execution sandboxes.", + "main": "src/index.js", + "scripts": { + "test": "node --test", + "demo": "node scripts/demo.js", + "video": "node scripts/render-demo-video.js", + "check": "node --check src/index.js && node --check scripts/demo.js && node --check scripts/render-demo-video.js" + }, + "license": "MIT" +} diff --git a/repository-sandbox-egress-guard/reports/demo.mp4 b/repository-sandbox-egress-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..d0bfe2affc12926c4a59f018162432d41f4874d0 GIT binary patch literal 54316 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 Pnf{5^qI8*`Y8U$tBB>@5 literal 0 HcmV?d00001 diff --git a/repository-sandbox-egress-guard/reports/reviewer-packet.md b/repository-sandbox-egress-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..8908497c --- /dev/null +++ b/repository-sandbox-egress-guard/reports/reviewer-packet.md @@ -0,0 +1,68 @@ +# Repository Sandbox Egress Guard Report + +Generated: 2026-08-16T14:57:25.834Z +Execution plans analyzed: 4 +Decision counts: RELEASE 1, REVIEW_MERGE 1, HOLD_MERGE 2 + +## Findings + +### repo-release-001: Clean climate notebook reproduction + +Decision: RELEASE +Pipeline: notebooks/run_analysis.ipynb + +- No sandbox egress or containment risk detected. + +Recommended actions: +- Allow repository execution, merge, and tagged release with normal provenance. + +### repo-hold-002: Model benchmark rerun with open egress and host secrets + +Decision: HOLD_MERGE +Pipeline: notebooks/benchmark.ipynb + +- MAJOR CONTAINER_IMAGE_NOT_PINNED: The execution container is not pinned by a content digest. +- MAJOR EXECUTION_PROVENANCE_INCOMPLETE: The command hash or dependency lockfile evidence is missing. +- CRITICAL SANDBOX_NETWORK_EGRESS_OPEN: The reproducibility run allows open or wildcard network egress. +- CRITICAL SECRET_ENVIRONMENT_EXPOSED: Secret-like environment variables are available to the repository execution sandbox. +- CRITICAL SECRET_MOUNT_EXPOSED: Host secret paths are mounted into the repository execution sandbox. +- CRITICAL PRIVILEGED_SANDBOX_RUNTIME: The sandbox uses privileged, root, host PID, or host IPC execution settings. +- CRITICAL UNSAFE_HOST_OR_WRITE_MOUNT: The sandbox has unsafe host mounts or writable paths outside declared artifact locations. +- MAJOR RESOURCE_CAPS_MISSING: The execution plan lacks one or more resource caps. +- CRITICAL OUTPUT_PATH_ESCAPES_WORKSPACE: One or more output paths escape declared results or artifacts directories. +- CRITICAL REPOSITORY_RELEASE_NOT_FROZEN_FOR_SANDBOX_RISK: Repository merge or public release remains enabled while sandbox findings are unresolved. + +Recommended actions: +- Freeze protected-branch merge, DOI tagging, and public reproducibility badges. +- Attach sandbox findings to the repository review packet. +- Re-run the exact reproducibility pipeline after remediation. +- Preserve reviewer evidence with the tagged release record. + +### repo-review-003: Materials simulation with approved package mirror + +Decision: REVIEW_MERGE +Pipeline: code/run_simulation.py + +- MAJOR EXECUTION_PROVENANCE_INCOMPLETE: The command hash or dependency lockfile evidence is missing. +- MAJOR SANDBOX_NETWORK_ALLOWLIST_REVIEW: The reproducibility run uses limited network egress and needs reviewer approval before merge. + +Recommended actions: +- Attach sandbox findings to the repository review packet. +- Re-run the exact reproducibility pipeline after remediation. +- Preserve reviewer evidence with the tagged release record. + +### repo-hold-004: Private dataset release with writable home mount + +Decision: HOLD_MERGE +Pipeline: code/reproduce.sh + +- CRITICAL SECRET_ENVIRONMENT_EXPOSED: Secret-like environment variables are available to the repository execution sandbox. +- CRITICAL PRIVILEGED_SANDBOX_RUNTIME: The sandbox uses privileged, root, host PID, or host IPC execution settings. +- CRITICAL UNSAFE_HOST_OR_WRITE_MOUNT: The sandbox has unsafe host mounts or writable paths outside declared artifact locations. +- CRITICAL REPOSITORY_RELEASE_NOT_FROZEN_FOR_SANDBOX_RISK: Repository merge or public release remains enabled while sandbox findings are unresolved. + +Recommended actions: +- Freeze protected-branch merge, DOI tagging, and public reproducibility badges. +- Attach sandbox findings to the repository review packet. +- Re-run the exact reproducibility pipeline after remediation. +- Preserve reviewer evidence with the tagged release record. diff --git a/repository-sandbox-egress-guard/reports/summary.json b/repository-sandbox-egress-guard/reports/summary.json new file mode 100644 index 00000000..41f8977c --- /dev/null +++ b/repository-sandbox-egress-guard/reports/summary.json @@ -0,0 +1,262 @@ +{ + "generatedAt": "2026-08-16T14:57:25.834Z", + "totalPlans": 4, + "counts": { + "RELEASE": 1, + "REVIEW_MERGE": 1, + "HOLD_MERGE": 2 + }, + "results": [ + { + "id": "repo-release-001", + "title": "Clean climate notebook reproduction", + "decision": "RELEASE", + "pipeline": "notebooks/run_analysis.ipynb", + "findings": [], + "recommendedActions": [ + "Allow repository execution, merge, and tagged release with normal provenance." + ] + }, + { + "id": "repo-hold-002", + "title": "Model benchmark rerun with open egress and host secrets", + "decision": "HOLD_MERGE", + "pipeline": "notebooks/benchmark.ipynb", + "findings": [ + { + "code": "CONTAINER_IMAGE_NOT_PINNED", + "severity": "major", + "message": "The execution container is not pinned by a content digest.", + "evidence": { + "image": "python:latest", + "digest": null + }, + "remediation": "Pin the runner image with a sha256 digest before publishing or merging the reproducibility run." + }, + { + "code": "EXECUTION_PROVENANCE_INCOMPLETE", + "severity": "major", + "message": "The command hash or dependency lockfile evidence is missing.", + "evidence": { + "commandHashPresent": false, + "lockfilePresent": false + }, + "remediation": "Attach hashed commands and dependency lock evidence for the exact pipeline revision." + }, + { + "code": "SANDBOX_NETWORK_EGRESS_OPEN", + "severity": "critical", + "message": "The reproducibility run allows open or wildcard network egress.", + "evidence": { + "mode": "open", + "allowedHosts": [ + "*" + ] + }, + "remediation": "Deny egress by default or replace it with a narrow host allowlist and cached inputs." + }, + { + "code": "SECRET_ENVIRONMENT_EXPOSED", + "severity": "critical", + "message": "Secret-like environment variables are available to the repository execution sandbox.", + "evidence": { + "env": [ + "AWS_SECRET_ACCESS_KEY", + "SCIBASE_API_TOKEN" + ] + }, + "remediation": "Remove secrets from reproducibility runs and replace live credentials with synthetic or read-only fixtures." + }, + { + "code": "SECRET_MOUNT_EXPOSED", + "severity": "critical", + "message": "Host secret paths are mounted into the repository execution sandbox.", + "evidence": { + "mounts": [ + "/home/researcher/.ssh" + ] + }, + "remediation": "Remove host secret mounts and use declared, non-sensitive repository fixtures." + }, + { + "code": "PRIVILEGED_SANDBOX_RUNTIME", + "severity": "critical", + "message": "The sandbox uses privileged, root, host PID, or host IPC execution settings.", + "evidence": { + "privileged": true, + "user": "root", + "hostPid": true, + "hostIpc": false + }, + "remediation": "Run the container as a non-root user without privileged mode or host namespace sharing." + }, + { + "code": "UNSAFE_HOST_OR_WRITE_MOUNT", + "severity": "critical", + "message": "The sandbox has unsafe host mounts or writable paths outside declared artifact locations.", + "evidence": { + "mounts": [ + { + "source": "/var/run/docker.sock", + "target": "/var/run/docker.sock", + "mode": "rw" + }, + { + "source": "workspace", + "target": "/workspace", + "mode": "rw" + } + ] + }, + "remediation": "Restrict mounts to read-only workspace inputs and writable results/artifacts directories." + }, + { + "code": "RESOURCE_CAPS_MISSING", + "severity": "major", + "message": "The execution plan lacks one or more resource caps.", + "evidence": { + "missingCaps": [ + "cpu", + "memoryMb", + "timeoutSeconds", + "artifactLimitMb" + ] + }, + "remediation": "Set CPU, memory, timeout, and artifact-size caps before queueing the run." + }, + { + "code": "OUTPUT_PATH_ESCAPES_WORKSPACE", + "severity": "critical", + "message": "One or more output paths escape declared results or artifacts directories.", + "evidence": { + "outputs": [ + "../published/model.pkl", + "/tmp/raw-dump.csv" + ] + }, + "remediation": "Write outputs only under repository-scoped results/ or artifacts/ paths." + }, + { + "code": "REPOSITORY_RELEASE_NOT_FROZEN_FOR_SANDBOX_RISK", + "severity": "critical", + "message": "Repository merge or public release remains enabled while sandbox findings are unresolved.", + "evidence": { + "publicReleaseEnabled": true, + "protectedBranchMerge": true, + "freezeRelease": false + }, + "remediation": "Freeze protected-branch merge, DOI tagging, and public reproducibility badges until sandbox findings are cleared." + } + ], + "recommendedActions": [ + "Freeze protected-branch merge, DOI tagging, and public reproducibility badges.", + "Attach sandbox findings to the repository review packet.", + "Re-run the exact reproducibility pipeline after remediation.", + "Preserve reviewer evidence with the tagged release record." + ] + }, + { + "id": "repo-review-003", + "title": "Materials simulation with approved package mirror", + "decision": "REVIEW_MERGE", + "pipeline": "code/run_simulation.py", + "findings": [ + { + "code": "EXECUTION_PROVENANCE_INCOMPLETE", + "severity": "major", + "message": "The command hash or dependency lockfile evidence is missing.", + "evidence": { + "commandHashPresent": true, + "lockfilePresent": false + }, + "remediation": "Attach hashed commands and dependency lock evidence for the exact pipeline revision." + }, + { + "code": "SANDBOX_NETWORK_ALLOWLIST_REVIEW", + "severity": "major", + "message": "The reproducibility run uses limited network egress and needs reviewer approval before merge.", + "evidence": { + "mode": "allowlist", + "allowedHosts": [ + "packages.scibase.example" + ] + }, + "remediation": "Confirm every allowed host is required for deterministic package retrieval, then mirror or pin where possible." + } + ], + "recommendedActions": [ + "Attach sandbox findings to the repository review packet.", + "Re-run the exact reproducibility pipeline after remediation.", + "Preserve reviewer evidence with the tagged release record." + ] + }, + { + "id": "repo-hold-004", + "title": "Private dataset release with writable home mount", + "decision": "HOLD_MERGE", + "pipeline": "code/reproduce.sh", + "findings": [ + { + "code": "SECRET_ENVIRONMENT_EXPOSED", + "severity": "critical", + "message": "Secret-like environment variables are available to the repository execution sandbox.", + "evidence": { + "env": [ + "DATABASE_URL" + ] + }, + "remediation": "Remove secrets from reproducibility runs and replace live credentials with synthetic or read-only fixtures." + }, + { + "code": "PRIVILEGED_SANDBOX_RUNTIME", + "severity": "critical", + "message": "The sandbox uses privileged, root, host PID, or host IPC execution settings.", + "evidence": { + "privileged": false, + "user": "root", + "hostPid": false, + "hostIpc": true + }, + "remediation": "Run the container as a non-root user without privileged mode or host namespace sharing." + }, + { + "code": "UNSAFE_HOST_OR_WRITE_MOUNT", + "severity": "critical", + "message": "The sandbox has unsafe host mounts or writable paths outside declared artifact locations.", + "evidence": { + "mounts": [ + { + "source": "/home/researcher", + "target": "/host-home", + "mode": "rw" + }, + { + "source": "workspace", + "target": "/workspace", + "mode": "rw" + } + ] + }, + "remediation": "Restrict mounts to read-only workspace inputs and writable results/artifacts directories." + }, + { + "code": "REPOSITORY_RELEASE_NOT_FROZEN_FOR_SANDBOX_RISK", + "severity": "critical", + "message": "Repository merge or public release remains enabled while sandbox findings are unresolved.", + "evidence": { + "publicReleaseEnabled": false, + "protectedBranchMerge": true, + "freezeRelease": false + }, + "remediation": "Freeze protected-branch merge, DOI tagging, and public reproducibility badges until sandbox findings are cleared." + } + ], + "recommendedActions": [ + "Freeze protected-branch merge, DOI tagging, and public reproducibility badges.", + "Attach sandbox findings to the repository review packet.", + "Re-run the exact reproducibility pipeline after remediation.", + "Preserve reviewer evidence with the tagged release record." + ] + } + ] +} diff --git a/repository-sandbox-egress-guard/reports/summary.svg b/repository-sandbox-egress-guard/reports/summary.svg new file mode 100644 index 00000000..a2554151 --- /dev/null +++ b/repository-sandbox-egress-guard/reports/summary.svg @@ -0,0 +1,17 @@ + + + Repository Sandbox Egress Guard + Synthetic reproducibility-run audit before protected merge or DOI release. + Decision distribution + HOLD + + 2 + REVIEW + + 1 + RELEASE + + 1 + Checks egress, secrets, image pins, host mounts, root/privileged mode, resource caps, and output paths. + Synthetic generated artifact only. No desktop capture or private data. + diff --git a/repository-sandbox-egress-guard/scripts/demo.js b/repository-sandbox-egress-guard/scripts/demo.js new file mode 100644 index 00000000..1f3f3924 --- /dev/null +++ b/repository-sandbox-egress-guard/scripts/demo.js @@ -0,0 +1,77 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { analyzeSandboxRunPlans } = require("../src"); + +const root = path.resolve(__dirname, ".."); +const dataPath = path.join(root, "data", "sample_sandbox_run_plans.json"); +const outDir = path.join(root, "reports"); +const plans = JSON.parse(fs.readFileSync(dataPath, "utf8")); +const report = analyzeSandboxRunPlans(plans); + +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(report, null, 2)}\n`); + +const markdown = [ + "# Repository Sandbox Egress Guard Report", + "", + `Generated: ${report.generatedAt}`, + `Execution plans analyzed: ${report.totalPlans}`, + `Decision counts: RELEASE ${report.counts.RELEASE}, REVIEW_MERGE ${report.counts.REVIEW_MERGE}, HOLD_MERGE ${report.counts.HOLD_MERGE}`, + "", + "## Findings", + "", + ...report.results.flatMap((item) => [ + `### ${item.id}: ${item.title}`, + "", + `Decision: ${item.decision}`, + `Pipeline: ${item.pipeline || "not declared"}`, + "", + item.findings.length === 0 + ? "- No sandbox egress or containment risk detected." + : item.findings.map((finding) => `- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`).join("\n"), + "", + "Recommended actions:", + ...item.recommendedActions.map((action) => `- ${action}`), + "" + ]) +].join("\n"); + +fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), markdown); + +const total = Math.max(1, report.totalPlans); +const maxBar = 620; +const holdWidth = Math.round((report.counts.HOLD_MERGE / total) * maxBar); +const reviewWidth = Math.round((report.counts.REVIEW_MERGE / total) * maxBar); +const releaseWidth = Math.round((report.counts.RELEASE / total) * maxBar); +const svg = ` + + Repository Sandbox Egress Guard + Synthetic reproducibility-run audit before protected merge or DOI release. + Decision distribution + HOLD + + ${report.counts.HOLD_MERGE} + REVIEW + + ${report.counts.REVIEW_MERGE} + RELEASE + + ${report.counts.RELEASE} + Checks egress, secrets, image pins, host mounts, root/privileged mode, resource caps, and output paths. + Synthetic generated artifact only. No desktop capture or private data. + +`; + +fs.writeFileSync(path.join(outDir, "summary.svg"), svg); + +console.log(JSON.stringify({ + plans: report.totalPlans, + counts: report.counts, + outputs: [ + path.join(outDir, "summary.json"), + path.join(outDir, "reviewer-packet.md"), + path.join(outDir, "summary.svg") + ] +}, null, 2)); diff --git a/repository-sandbox-egress-guard/scripts/render-demo-video.js b/repository-sandbox-egress-guard/scripts/render-demo-video.js new file mode 100644 index 00000000..993e8621 --- /dev/null +++ b/repository-sandbox-egress-guard/scripts/render-demo-video.js @@ -0,0 +1,60 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const ffmpeg = path.resolve( + root, + "..", + "..", + "tool_downloads", + "video_tools", + "node_modules", + "ffmpeg-static", + "ffmpeg.exe" +); +const outDir = path.join(root, "reports"); +const out = path.join(outDir, "demo.mp4"); +const font = "C\\:/Windows/Fonts/arial.ttf"; + +fs.mkdirSync(outDir, { recursive: true }); + +const draw = [ + `drawtext=fontfile=${font}:text='Repository Sandbox Egress Guard':x=64:y=56:fontsize=42:fontcolor=black`, + `drawtext=fontfile=${font}:text='Reproducibility run containment before merge or DOI release':x=64:y=122:fontsize=25:fontcolor=0x34405a`, + `drawtext=fontfile=${font}:text='Synthetic execution plans analyzed 4':x=64:y=214:fontsize=34:fontcolor=black`, + `drawtext=fontfile=${font}:text='HOLD 2 REVIEW 1 RELEASE 1':x=64:y=274:fontsize=40:fontcolor=0x225c9c`, + `drawtext=fontfile=${font}:text='Detects open egress secrets privileged mode unsafe mounts':x=64:y=366:fontsize=26:fontcolor=0x34405a`, + `drawtext=fontfile=${font}:text='Freezes protected merge and public release until sandbox risks clear':x=64:y=428:fontsize=26:fontcolor=0x34405a`, + `drawtext=fontfile=${font}:text='Synthetic generated slate only no desktop capture or private data':x=64:y=492:fontsize=26:fontcolor=0x34405a` +].join(","); + +if (!fs.existsSync(ffmpeg)) { + throw new Error(`ffmpeg binary not found at ${ffmpeg}`); +} + +const result = spawnSync(ffmpeg, [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0xf7fbff:s=1280x720:d=8:r=30", + "-vf", + draw, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + out +], { encoding: "utf8" }); + +if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exit(result.status || 1); +} + +console.log(JSON.stringify({ out, bytes: fs.statSync(out).size }, null, 2)); diff --git a/repository-sandbox-egress-guard/src/index.js b/repository-sandbox-egress-guard/src/index.js new file mode 100644 index 00000000..c725d69a --- /dev/null +++ b/repository-sandbox-egress-guard/src/index.js @@ -0,0 +1,273 @@ +"use strict"; + +function list(value) { + return Array.isArray(value) ? value : []; +} + +function normalizeText(value) { + return String(value || "").trim().toLowerCase(); +} + +function finding(code, severity, message, evidence, remediation) { + return { code, severity, message, evidence, remediation }; +} + +function normalizePlan(plan) { + return { + id: plan.id, + title: plan.title || "Untitled repository sandbox plan", + repository: plan.repository || {}, + execution: plan.execution || {}, + container: plan.container || {}, + network: plan.network || {}, + secrets: plan.secrets || {}, + resources: plan.resources || {}, + outputs: plan.outputs || {} + }; +} + +function hasPinnedImage(plan) { + return /^sha256:[a-f0-9]{64}$/i.test(String(plan.container.digest || "")); +} + +function hasLockedExecution(plan) { + return plan.execution.commandHashPresent === true && plan.execution.lockfilePresent === true; +} + +function networkFinding(plan) { + const mode = normalizeText(plan.network.mode); + const allowedHosts = list(plan.network.allowedHosts).map(normalizeText).filter(Boolean); + + if (mode === "deny") return null; + if (mode === "allowlist" && allowedHosts.length > 0 && !allowedHosts.includes("*")) { + return finding( + "SANDBOX_NETWORK_ALLOWLIST_REVIEW", + "major", + "The reproducibility run uses limited network egress and needs reviewer approval before merge.", + { mode, allowedHosts }, + "Confirm every allowed host is required for deterministic package retrieval, then mirror or pin where possible." + ); + } + + return finding( + "SANDBOX_NETWORK_EGRESS_OPEN", + "critical", + "The reproducibility run allows open or wildcard network egress.", + { mode: mode || "unspecified", allowedHosts }, + "Deny egress by default or replace it with a narrow host allowlist and cached inputs." + ); +} + +function secretFindings(plan) { + const env = list(plan.secrets.env).filter(Boolean); + const mounts = list(plan.secrets.mounts).filter(Boolean); + const findings = []; + + if (env.length > 0) { + findings.push(finding( + "SECRET_ENVIRONMENT_EXPOSED", + "critical", + "Secret-like environment variables are available to the repository execution sandbox.", + { env }, + "Remove secrets from reproducibility runs and replace live credentials with synthetic or read-only fixtures." + )); + } + + if (mounts.length > 0) { + findings.push(finding( + "SECRET_MOUNT_EXPOSED", + "critical", + "Host secret paths are mounted into the repository execution sandbox.", + { mounts }, + "Remove host secret mounts and use declared, non-sensitive repository fixtures." + )); + } + + return findings; +} + +function unsafeMounts(plan) { + return list(plan.container.mounts).filter((mount) => { + const source = normalizeText(mount.source); + const target = normalizeText(mount.target); + return source.includes("docker.sock") || + target.includes("docker.sock") || + source.startsWith("/") && !source.startsWith("/workspace") || + normalizeText(mount.mode) === "rw" && target !== "/workspace/results" && target !== "/workspace/artifacts"; + }); +} + +function missingResourceCaps(plan) { + const resources = plan.resources; + return ["cpu", "memoryMb", "timeoutSeconds", "artifactLimitMb"].filter((key) => { + const value = resources[key]; + if (typeof value === "string") return value.trim() === ""; + return !Number.isFinite(Number(value)) || Number(value) <= 0; + }); +} + +function unsafeOutputPaths(plan) { + return list(plan.outputs.paths).filter((rawPath) => { + const value = String(rawPath || "").replace(/\\/g, "/"); + return value.startsWith("/") || + value.includes("../") || + !(value.startsWith("results/") || value.startsWith("artifacts/")); + }); +} + +function analyzePlan(input) { + const plan = normalizePlan(input); + const findings = []; + const network = networkFinding(plan); + const mounts = unsafeMounts(plan); + const missingCaps = missingResourceCaps(plan); + const outputs = unsafeOutputPaths(plan); + + if (!hasPinnedImage(plan)) { + findings.push(finding( + "CONTAINER_IMAGE_NOT_PINNED", + "major", + "The execution container is not pinned by a content digest.", + { image: plan.container.image || null, digest: plan.container.digest || null }, + "Pin the runner image with a sha256 digest before publishing or merging the reproducibility run." + )); + } + + if (!hasLockedExecution(plan)) { + findings.push(finding( + "EXECUTION_PROVENANCE_INCOMPLETE", + "major", + "The command hash or dependency lockfile evidence is missing.", + { + commandHashPresent: plan.execution.commandHashPresent || false, + lockfilePresent: plan.execution.lockfilePresent || false + }, + "Attach hashed commands and dependency lock evidence for the exact pipeline revision." + )); + } + + if (network) findings.push(network); + findings.push(...secretFindings(plan)); + + if (plan.container.privileged === true || normalizeText(plan.container.user) === "root" || plan.container.hostPid === true || plan.container.hostIpc === true) { + findings.push(finding( + "PRIVILEGED_SANDBOX_RUNTIME", + "critical", + "The sandbox uses privileged, root, host PID, or host IPC execution settings.", + { + privileged: plan.container.privileged || false, + user: plan.container.user || null, + hostPid: plan.container.hostPid || false, + hostIpc: plan.container.hostIpc || false + }, + "Run the container as a non-root user without privileged mode or host namespace sharing." + )); + } + + if (mounts.length > 0) { + findings.push(finding( + "UNSAFE_HOST_OR_WRITE_MOUNT", + "critical", + "The sandbox has unsafe host mounts or writable paths outside declared artifact locations.", + { mounts }, + "Restrict mounts to read-only workspace inputs and writable results/artifacts directories." + )); + } + + if (missingCaps.length > 0) { + findings.push(finding( + "RESOURCE_CAPS_MISSING", + "major", + "The execution plan lacks one or more resource caps.", + { missingCaps }, + "Set CPU, memory, timeout, and artifact-size caps before queueing the run." + )); + } + + if (outputs.length > 0) { + findings.push(finding( + "OUTPUT_PATH_ESCAPES_WORKSPACE", + "critical", + "One or more output paths escape declared results or artifacts directories.", + { outputs }, + "Write outputs only under repository-scoped results/ or artifacts/ paths." + )); + } + + if ( + findings.length > 0 && + (plan.repository.publicReleaseEnabled === true || plan.repository.protectedBranchMerge === true) && + plan.repository.freezeRelease !== true + ) { + findings.push(finding( + "REPOSITORY_RELEASE_NOT_FROZEN_FOR_SANDBOX_RISK", + "critical", + "Repository merge or public release remains enabled while sandbox findings are unresolved.", + { + publicReleaseEnabled: plan.repository.publicReleaseEnabled || false, + protectedBranchMerge: plan.repository.protectedBranchMerge || false, + freezeRelease: plan.repository.freezeRelease || false + }, + "Freeze protected-branch merge, DOI tagging, and public reproducibility badges until sandbox findings are cleared." + )); + } + + const decision = decisionFor(findings); + return { + id: plan.id, + title: plan.title, + decision, + pipeline: plan.execution.pipeline || null, + findings, + recommendedActions: buildRecommendedActions(decision, findings) + }; +} + +function decisionFor(findings) { + if (findings.some((item) => item.severity === "critical")) return "HOLD_MERGE"; + if (findings.some((item) => item.severity === "major")) return "REVIEW_MERGE"; + return "RELEASE"; +} + +function buildRecommendedActions(decision, findings) { + if (decision === "RELEASE") { + return ["Allow repository execution, merge, and tagged release with normal provenance."]; + } + + const actions = [ + "Attach sandbox findings to the repository review packet.", + "Re-run the exact reproducibility pipeline after remediation.", + "Preserve reviewer evidence with the tagged release record." + ]; + + if (findings.some((item) => item.code === "REPOSITORY_RELEASE_NOT_FROZEN_FOR_SANDBOX_RISK")) { + actions.unshift("Freeze protected-branch merge, DOI tagging, and public reproducibility badges."); + } + + return actions; +} + +function analyzeSandboxRunPlans(plans) { + const results = list(plans).map(analyzePlan); + const counts = results.reduce((acc, item) => { + acc[item.decision] = (acc[item.decision] || 0) + 1; + return acc; + }, { RELEASE: 0, REVIEW_MERGE: 0, HOLD_MERGE: 0 }); + + return { + generatedAt: new Date().toISOString(), + totalPlans: results.length, + counts, + results + }; +} + +module.exports = { + analyzePlan, + analyzeSandboxRunPlans, + hasPinnedImage, + hasLockedExecution, + unsafeMounts, + unsafeOutputPaths, + missingResourceCaps +}; diff --git a/repository-sandbox-egress-guard/test/repository-sandbox-egress-guard.test.js b/repository-sandbox-egress-guard/test/repository-sandbox-egress-guard.test.js new file mode 100644 index 00000000..df26c400 --- /dev/null +++ b/repository-sandbox-egress-guard/test/repository-sandbox-egress-guard.test.js @@ -0,0 +1,65 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { + analyzePlan, + analyzeSandboxRunPlans, + hasPinnedImage, + hasLockedExecution, + unsafeMounts, + unsafeOutputPaths, + missingResourceCaps +} = require("../src"); +const samples = require("../data/sample_sandbox_run_plans.json"); + +test("accepts a pinned, locked, egress-denied repository run", () => { + const plan = samples.find((item) => item.id === "repo-release-001"); + const result = analyzePlan(plan); + assert.equal(hasPinnedImage(plan), true); + assert.equal(hasLockedExecution(plan), true); + assert.equal(result.decision, "RELEASE"); + assert.equal(result.findings.length, 0); +}); + +test("holds open egress and host-secret exposure", () => { + const result = analyzePlan(samples.find((item) => item.id === "repo-hold-002")); + assert.equal(result.decision, "HOLD_MERGE"); + assert.ok(result.findings.some((item) => item.code === "SANDBOX_NETWORK_EGRESS_OPEN")); + assert.ok(result.findings.some((item) => item.code === "SECRET_ENVIRONMENT_EXPOSED")); + assert.ok(result.findings.some((item) => item.code === "SECRET_MOUNT_EXPOSED")); +}); + +test("detects unsafe mounts and workspace-escaping outputs", () => { + const plan = samples.find((item) => item.id === "repo-hold-002"); + assert.ok(unsafeMounts(plan).length >= 2); + assert.deepEqual(unsafeOutputPaths(plan), ["../published/model.pkl", "/tmp/raw-dump.csv"]); +}); + +test("routes allowlisted egress and missing lockfiles for review", () => { + const result = analyzePlan(samples.find((item) => item.id === "repo-review-003")); + assert.equal(result.decision, "REVIEW_MERGE"); + assert.ok(result.findings.some((item) => item.code === "SANDBOX_NETWORK_ALLOWLIST_REVIEW")); + assert.ok(result.findings.some((item) => item.code === "EXECUTION_PROVENANCE_INCOMPLETE")); +}); + +test("detects privileged runtime settings and missing resource caps", () => { + const plan = samples.find((item) => item.id === "repo-hold-002"); + const result = analyzePlan(plan); + assert.deepEqual(missingResourceCaps(plan), ["cpu", "memoryMb", "timeoutSeconds", "artifactLimitMb"]); + assert.ok(result.findings.some((item) => item.code === "PRIVILEGED_SANDBOX_RUNTIME")); + assert.ok(result.findings.some((item) => item.code === "RESOURCE_CAPS_MISSING")); +}); + +test("holds root or host namespace execution even when egress is denied", () => { + const result = analyzePlan(samples.find((item) => item.id === "repo-hold-004")); + assert.equal(result.decision, "HOLD_MERGE"); + assert.ok(result.findings.some((item) => item.code === "PRIVILEGED_SANDBOX_RUNTIME")); + assert.ok(result.findings.some((item) => item.code === "UNSAFE_HOST_OR_WRITE_MOUNT")); +}); + +test("aggregates sandbox run decisions", () => { + const report = analyzeSandboxRunPlans(samples); + assert.equal(report.totalPlans, 4); + assert.deepEqual(report.counts, { RELEASE: 1, REVIEW_MERGE: 1, HOLD_MERGE: 2 }); +});