#!/usr/bin/env node import { createHash } from "node:crypto"; import { spawnSync } from "node:child_process"; import { cp, link, lstat, mkdir, mkdtemp, readFile, rm, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const fixtureRoot = resolve(scriptDir, "fixtures/gitea-salvage"); const decisionRoot = resolve( scriptDir, "../../../security-incidents/gitea-20260814/confirmed-decisions-v2", ); const artifactDir = resolve( process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"), ); const [patchId = "gitea-incident-salvage-subrelation-closure-20260814-006", ...extra] = process.argv.slice(2); if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { throw new Error( "usage: build-gitea-incident-salvage-artifact.mjs [patch-id]", ); } const composeRelative = "docker-compose.gitea.yml"; const descriptorRelative = "deployment/gitea-incident-salvage-v3.json"; const decisionPrefix = "deployment/gitea-incident-salvage"; const dispositionRelative = `${decisionPrefix}/confirmed-disposition-v1.json`; const closureDispositionRelative = `${decisionPrefix}/confirmed-closure-disposition-v1.json`; const descriptorSha256 = "9b98eb1a1640fd5569cf051a621837379b167eff4527313a43a0a851e7cc181a"; const dispositionSha256 = "0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c"; const closureDispositionSha256 = "7ed66d9848268431a703fe24b22c41afbaa7c5ff48949604d6fc448d93e0d243"; const decisionFiles = [ ["confirmed-decision.json", "dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a"], ["users.decisions.csv", "e3b82f1073a86eea9e567edff062dd1689d21ec0edcf3ed92e844da9351ee8b6"], ["repositories.decisions.csv", "76b4bae2ab5cec490330c19bfc5ae9429abf7636705ae028c1c64fd54a6a0493"], ]; const files = [ composeRelative, descriptorRelative, dispositionRelative, closureDispositionRelative, ...decisionFiles.map(([name]) => `${decisionPrefix}/${name}`), ]; const stage = await mkdtemp(join(tmpdir(), "nodedc-gitea-salvage-artifact-")); const payload = join(stage, "payload"); const target = join(artifactDir, `nodedc-gitea-${patchId}.tgz`); const targetTemporary = join( artifactDir, `.${basename(target)}.${process.pid}.tmp`, ); await assertSourceContract(); try { await mkdir(payload, { recursive: true }); for (const relative of [ composeRelative, descriptorRelative, dispositionRelative, closureDispositionRelative, ]) { await copyRegularFile(resolve(fixtureRoot, relative), join(payload, relative), relative); } for (const [name] of decisionFiles) { await copyRegularFile( resolve(decisionRoot, name), join(payload, decisionPrefix, name), `${decisionPrefix}/${name}`, ); } await writeFile( join(stage, "manifest.env"), `id=${patchId}\ncomponent=gitea\ntype=app-overlay\n`, "utf8", ); await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8"); await mkdir(artifactDir, { recursive: true }); const tar = spawnSync( "python3", ["-c", canonicalTarScript(), targetTemporary, stage], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, ); if (tar.status !== 0) { throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); } try { await link(targetTemporary, target); } catch (error) { if (error?.code === "EEXIST") { throw new Error("gitea_salvage_artifact_target_already_exists"); } throw error; } console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: sha256(await readFile(target)), component: "gitea", transition: "clean-state-incident-salvage", entries: files, services: ["gitea"], image: "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c", decisions: { users: { active: 2, locked: 8, delete: 962 }, repositories: { keep: 45, delete: 2013 }, }, disposition: { sha256: dispositionSha256, forensicRefs: 105, liveRefs: 93, archiveOnlyRefs: 12, }, closureDisposition: { sha256: closureDispositionSha256, state: "plan-report-review-pending", }, stateBoundary: "new-database-config-secrets-identities", repositoryBoundary: "exact-45-object-and-validated-ref-material-only", networkMode: "none", transport: "unix:/run/gitea/gitea.sock", stagePolicy: "after-exact-runner-promotion-plan-only", applyPolicy: "hard-frozen-before-root-creation", }, null, 2)); } finally { await rm(targetTemporary, { force: true }); await rm(stage, { recursive: true, force: true }); } async function copyRegularFile(source, destination, label) { const sourceStat = await lstat(source); if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) { throw new Error(`gitea_salvage_source_type_rejected:${label}`); } await mkdir(dirname(destination), { recursive: true }); await cp(source, destination, { force: true, verbatimSymlinks: true }); } function canonicalJson(value) { if (Array.isArray(value)) { return value.map(canonicalJson); } if (value && typeof value === "object") { return Object.fromEntries( Object.keys(value).sort().map((key) => [key, canonicalJson(value[key])]), ); } return value; } async function assertSourceContract() { for (const [name, expected] of decisionFiles) { const bytes = await readFile(resolve(decisionRoot, name)); if (sha256(bytes) !== expected) { throw new Error(`gitea_salvage_decision_digest_mismatch:${name}`); } } const composeBytes = await readFile(resolve(fixtureRoot, composeRelative)); const compose = composeBytes.toString("utf8"); const descriptorBytes = await readFile( resolve(fixtureRoot, descriptorRelative), ); const descriptor = JSON.parse(descriptorBytes.toString("utf8")); if (sha256(descriptorBytes) !== descriptorSha256) { throw new Error("gitea_salvage_descriptor_digest_mismatch"); } const dispositionBytes = await readFile( resolve(fixtureRoot, dispositionRelative), ); const disposition = JSON.parse(dispositionBytes.toString("utf8")); if (sha256(dispositionBytes) !== dispositionSha256) { throw new Error("gitea_salvage_disposition_digest_mismatch"); } const canonicalDisposition = Buffer.from( `${JSON.stringify(canonicalJson(disposition))}\n`, "utf8", ); if (!dispositionBytes.equals(canonicalDisposition)) { throw new Error("gitea_salvage_disposition_not_canonical"); } const closureDispositionBytes = await readFile( resolve(fixtureRoot, closureDispositionRelative), ); const closureDisposition = JSON.parse(closureDispositionBytes.toString("utf8")); if (sha256(closureDispositionBytes) !== closureDispositionSha256) { throw new Error("gitea_salvage_closure_disposition_digest_mismatch"); } const canonicalClosureDisposition = Buffer.from( `${JSON.stringify(canonicalJson(closureDisposition))}\n`, "utf8", ); if (!closureDispositionBytes.equals(canonicalClosureDisposition)) { throw new Error("gitea_salvage_closure_disposition_not_canonical"); } const expectedImage = "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c"; for (const required of [ `image: ${expectedImage}`, "platform: linux/amd64", "pull_policy: never", "network_mode: none", 'user: "1000:1000"', "entrypoint:", "- /usr/local/bin/gitea", "- /etc/gitea/app.ini", "read_only: true", "cap_drop:", "- ALL", "no-new-privileges:true", "source: /volume1/docker/nodedc-gitea/data", "target: /data", "source: /volume1/docker/nodedc-gitea/config", "target: /etc/gitea", "source: /volume1/docker/nodedc-gitea/socket", "target: /run/gitea", "create_host_path: false", ]) { if (!compose.includes(required)) { throw new Error(`gitea_salvage_compose_boundary_missing:${required}`); } } for (const forbidden of [ "ports:", "networks:", "/var/run/docker.sock", "/volume1/docker/gitea", "privileged: true", "4022", "TWO_FACTOR_AUTH", "LFS_JWT_SECRET", ]) { if (compose.includes(forbidden)) { throw new Error(`gitea_salvage_compose_boundary_violation:${forbidden}`); } } if ( descriptor.schemaVersion !== "nodedc.gitea.incident-salvage.v3" || descriptor.action !== "clean-state-salvage" || descriptor.compose?.sha256 !== sha256(composeBytes) || descriptor.runtime?.image !== expectedImage || descriptor.runtime?.networkMode !== "none" || descriptor.runtime?.database !== "new-sqlite-1.27.2-only" || descriptor.disposition?.file !== dispositionRelative || descriptor.disposition?.sha256 !== dispositionSha256 || descriptor.closureDisposition?.file !== closureDispositionRelative || descriptor.closureDisposition?.sha256 !== closureDispositionSha256 || descriptor.closureDisposition?.predecessorArtifactSha256 !== "d6870b5583a2f329eadb4e6cda65fdf4d271532df5ffbf8bfb1403968a434672" || descriptor.trust?.legacyDatabaseImported !== false || descriptor.trust?.legacyCredentialsImported !== false || descriptor.identity?.twoFactorAuthentication !== "not-configured-by-transition" ) { throw new Error("gitea_salvage_descriptor_contract_mismatch"); } if ( disposition.schemaVersion !== "nodedc.gitea.incident-disposition.v1" || disposition.incidentId !== "gitea-20260814" || disposition.activation?.allowedOperation !== "canonical-plan-only" || disposition.activation?.applyFrozen !== true || disposition.activation?.freezeBoundary !== "before-candidate-root-creation" || disposition.referencePolicy?.forensicScope?.allDiscoveredRefs !== 105 || disposition.referencePolicy?.exactDecisions?.length !== 105 || disposition.referencePolicy?.liveRestore?.totalRefs !== 93 || disposition.referencePolicy?.archiveOnly?.totalRefs !== 12 ) { throw new Error("gitea_salvage_disposition_contract_mismatch"); } if ( closureDisposition.schemaVersion !== "nodedc.gitea.incident-closure-disposition.v1" || closureDisposition.incidentId !== "gitea-20260814" || closureDisposition.activation?.allowedOperation !== "canonical-plan-only" || closureDisposition.activation?.applyFrozen !== true || closureDisposition.closureReport?.expectedSha256 !== null || closureDisposition.predecessor?.dispositionSha256 !== dispositionSha256 || closureDisposition.remainingBlockers ?.includes("closure-report-review-pin-pending") !== true ) { throw new Error("gitea_salvage_closure_disposition_contract_mismatch"); } } function sha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); } function canonicalTarScript() { return [ "import gzip,io,pathlib,sys,tarfile", "root=pathlib.Path(sys.argv[2])", "with open(sys.argv[1],'xb') as out:", " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", " for top in ('manifest.env','files.txt','payload'):", " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", " for x in paths:", " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", ].join("\n"); }