fix(security): ignore Docker rerun artifact commands

This commit is contained in:
Vincent Koc
2026-06-21 13:55:24 +02:00
parent 12c34fc3a9
commit c037a34ba7
2 changed files with 336 additions and 25 deletions

View File

@@ -92,6 +92,55 @@ function maybeGhcrImage(value) {
return typeof value === "string" && value.startsWith("ghcr.io/") ? value : "";
}
const TRUSTED_WORKFLOW_INPUTS = new Map([
["package_artifact_run_id", "packageArtifactRunId"],
["package_artifact_name", "packageArtifactName"],
["docker_e2e_bare_image", "bareImage"],
["docker_e2e_functional_image", "functionalImage"],
["published_upgrade_survivor_baseline", "publishedUpgradeSurvivorBaseline"],
["published_upgrade_survivor_baselines", "publishedUpgradeSurvivorBaselines"],
["published_upgrade_survivor_scenarios", "publishedUpgradeSurvivorScenarios"],
]);
const REUSE_INPUT_KEYS = [
"packageArtifactRunId",
"packageArtifactName",
"bareImage",
"functionalImage",
"workflowRef",
"publishedUpgradeSurvivorBaseline",
"publishedUpgradeSurvivorBaselines",
"publishedUpgradeSurvivorScenarios",
];
const WORKFLOW_INPUT_RE = /(?:^|\s)-f\s+([a-z0-9_]+)=('([^']*)'|[^\s]+)/gu;
const WORKFLOW_REF_RE = /(?:^|\s)--ref\s+('([^']*)'|[^\s]+)/u;
function trustedReuseInputsFromCommand(command) {
const text = String(command ?? "");
if (!/^\s*gh\s+workflow\s+run\s/u.test(text)) {
return {};
}
const inputs = {};
const refValue = text.match(WORKFLOW_REF_RE);
if (refValue) {
inputs.workflowRef = (refValue[2] ?? refValue[1] ?? "").replace(/^'/u, "").replace(/'$/u, "");
}
for (const match of text.matchAll(WORKFLOW_INPUT_RE)) {
const target = TRUSTED_WORKFLOW_INPUTS.get(match[1]);
const value = (match[3] ?? match[2] ?? "").replace(/^'/u, "").replace(/'$/u, "");
if (!target || !value) {
continue;
}
const normalized =
target === "bareImage" || target === "functionalImage" ? maybeGhcrImage(value) : value;
if (normalized) {
inputs[target] = normalized;
}
}
return inputs;
}
function reuseInputsFromJson(parsed) {
const packageArtifactRunId = parsed.github?.runId || "";
if (!packageArtifactRunId) {
@@ -107,12 +156,11 @@ function reuseInputsFromJson(parsed) {
}
function sameReuseInputs(left, right) {
return (
(left?.packageArtifactRunId || "") === (right?.packageArtifactRunId || "") &&
(left?.packageArtifactName || "") === (right?.packageArtifactName || "") &&
(left?.bareImage || "") === (right?.bareImage || "") &&
(left?.functionalImage || "") === (right?.functionalImage || "")
);
return REUSE_INPUT_KEYS.every((key) => (left?.[key] || "") === (right?.[key] || ""));
}
function reuseInputsKey(inputs) {
return JSON.stringify(REUSE_INPUT_KEYS.map((key) => inputs?.[key] || ""));
}
function commonReuseInputs(entries) {
@@ -124,8 +172,25 @@ function commonReuseInputs(entries) {
return inputs.every((input) => sameReuseInputs(first, input)) ? first : {};
}
function groupByReuseInputs(entries) {
const groups = new Map();
for (const entry of entries) {
const key = reuseInputsKey(entry.reuseInputs);
const group = groups.get(key);
if (group) {
group.push(entry);
} else {
groups.set(key, [entry]);
}
}
return [...groups.values()];
}
function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) {
const workflowRef = process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF || process.env.GITHUB_REF_NAME;
const workflowRef =
reuseInputs.workflowRef ||
process.env.OPENCLAW_DOCKER_E2E_WORKFLOW_REF ||
process.env.GITHUB_REF_NAME;
const releasePath = lanes.some(laneNeedsReleasePath);
const fields = [
"gh workflow run",
@@ -159,6 +224,30 @@ function ghWorkflowCommand(lanes, ref, workflow, reuseInputs = {}) {
if (reuseInputs.functionalImage) {
fields.push("-f", `docker_e2e_functional_image=${shellQuote(reuseInputs.functionalImage)}`);
}
if (reuseInputs.publishedUpgradeSurvivorBaseline) {
fields.push(
"-f",
`published_upgrade_survivor_baseline=${shellQuote(
reuseInputs.publishedUpgradeSurvivorBaseline,
)}`,
);
}
if (reuseInputs.publishedUpgradeSurvivorBaselines) {
fields.push(
"-f",
`published_upgrade_survivor_baselines=${shellQuote(
reuseInputs.publishedUpgradeSurvivorBaselines,
)}`,
);
}
if (reuseInputs.publishedUpgradeSurvivorScenarios) {
fields.push(
"-f",
`published_upgrade_survivor_scenarios=${shellQuote(
reuseInputs.publishedUpgradeSurvivorScenarios,
)}`,
);
}
return fields.join(" ");
}
@@ -169,20 +258,31 @@ function failureName(failure) {
function failedEntryFromRecord(failure, file, ref, workflow, reuseInputs) {
const lane = failureName(failure);
const targetable = failure.targetable !== false;
const workflowInputs = {
...trustedReuseInputsFromCommand(failure.ghWorkflowCommand),
...reuseInputs,
};
return {
ghWorkflowCommand: targetable
? failure.ghWorkflowCommand || ghWorkflowCommand([lane], ref, workflow, reuseInputs)
: "",
lane,
localRerunCommand: failure.rerunCommand,
logFile: failure.logFile,
reuseInputs,
reuseInputs: workflowInputs,
source: file,
status: failure.status,
targetable,
};
}
function mergeReuseInputs(left = {}, right = {}) {
const merged = { ...left };
for (const [key, value] of Object.entries(right)) {
if (value) {
merged[key] = value;
}
}
return merged;
}
function detectRepo() {
return run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]).trim();
}
@@ -222,7 +322,18 @@ function failedLaneEntriesFromJson(file, ref, workflow) {
function mergeByLane(entries) {
const byLane = new Map();
for (const entry of entries) {
if (!byLane.has(entry.lane)) {
const existing = byLane.get(entry.lane);
if (existing) {
byLane.set(entry.lane, {
...existing,
...entry,
localRerunCommand: existing.localRerunCommand || entry.localRerunCommand,
logFile: existing.logFile || entry.logFile,
reuseInputs: mergeReuseInputs(existing.reuseInputs, entry.reuseInputs),
source: existing.source || entry.source,
targetable: existing.targetable !== false && entry.targetable !== false,
});
} else {
byLane.set(entry.lane, entry);
}
}
@@ -288,22 +399,33 @@ function printEntries(entries, ref, workflow, runValue) {
console.log(`Failed Docker E2E entries: ${entries.map((entry) => entry.lane).join(", ")}`);
if (workflowEntries.length > 0) {
console.log("");
console.log("Combined GitHub rerun:");
console.log(
ghWorkflowCommand(
workflowEntries.map((entry) => entry.lane),
ref,
workflow,
commonReuseInputs(workflowEntries),
),
);
console.log("");
console.log("Per-lane GitHub reruns:");
for (const entry of workflowEntries) {
const workflowGroups = groupByReuseInputs(workflowEntries);
if (workflowGroups.length === 1) {
console.log("Combined GitHub rerun:");
console.log(
`- ${entry.lane}: ${entry.ghWorkflowCommand || ghWorkflowCommand([entry.lane], ref, workflow)}`,
ghWorkflowCommand(
workflowEntries.map((entry) => entry.lane),
ref,
workflow,
commonReuseInputs(workflowEntries),
),
);
} else {
console.log("Combined GitHub reruns:");
for (const group of workflowGroups) {
const lanes = group.map((entry) => entry.lane);
console.log(
`- ${lanes.join(", ")}: ${ghWorkflowCommand(lanes, ref, workflow, group[0]?.reuseInputs)}`,
);
}
}
console.log("");
console.log("Per-lane GitHub reruns:");
for (const entry of workflowEntries) {
console.log(
`- ${entry.lane}: ${ghWorkflowCommand([entry.lane], ref, workflow, entry.reuseInputs)}`,
);
}
} else {
console.log("");
console.log("No targetable failed Docker E2E lanes found.");

View File

@@ -187,4 +187,193 @@ describe("Docker E2E helper CLIs", () => {
}
},
);
it("ignores artifact-provided GitHub rerun commands", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-command-`);
try {
const file = path.join(root, "failures.json");
writeFileSync(
file,
`${JSON.stringify(
{
lanes: [
{
ghWorkflowCommand: "echo poisoned-command",
name: "gateway-network",
status: 1,
},
],
status: "failed",
},
null,
2,
)}\n`,
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("docker_lanes='gateway-network'");
expect(result.stdout).not.toContain("poisoned-command");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("preserves whitelisted rerun inputs from artifact commands", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-inputs-`);
try {
const file = path.join(root, "failures.json");
writeFileSync(
file,
`${JSON.stringify(
{
lanes: [
{
ghWorkflowCommand:
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f package_artifact_run_id='12345' -f package_artifact_name='docker-e2e-package' -f docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test' -f published_upgrade_survivor_baselines='openclaw@2026.5.3' -f published_upgrade_survivor_scenarios='plugin-dependency-cleanup' -f unsafe_input='do-not-copy'",
name: "published-upgrade-survivor-openclaw-2026-5-3",
status: 1,
},
],
status: "failed",
},
null,
2,
)}\n`,
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
const combinedCommand = result.stdout.match(/Combined GitHub rerun:\n([^\n]+)/u)?.[1] ?? "";
expect(combinedCommand).toContain("--ref 'release/2026.6'");
expect(combinedCommand).toContain("package_artifact_run_id='12345'");
expect(combinedCommand).toContain(
"docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test'",
);
expect(combinedCommand).toContain(
"published_upgrade_survivor_baselines='openclaw@2026.5.3'",
);
expect(combinedCommand).toContain(
"published_upgrade_survivor_scenarios='plugin-dependency-cleanup'",
);
expect(combinedCommand).not.toContain("unsafe_input");
expect(result.stdout).toContain("package_artifact_run_id='12345'");
expect(result.stdout).toContain(
"docker_e2e_bare_image='ghcr.io/openclaw/openclaw-bare:test'",
);
expect(result.stdout).toContain(
"published_upgrade_survivor_baselines='openclaw@2026.5.3'",
);
expect(result.stdout).toContain(
"published_upgrade_survivor_scenarios='plugin-dependency-cleanup'",
);
expect(result.stdout).not.toContain("unsafe_input");
expect(result.stdout).not.toContain("do-not-copy");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("groups combined reruns by recovered workflow inputs", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-groups-`);
try {
const file = path.join(root, "failures.json");
writeFileSync(
file,
`${JSON.stringify(
{
lanes: [
{
ghWorkflowCommand:
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.3'",
name: "published-upgrade-survivor-openclaw-2026-5-3",
status: 1,
},
{
ghWorkflowCommand:
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.2'",
name: "published-upgrade-survivor-openclaw-2026-5-2",
status: 1,
},
],
status: "failed",
},
null,
2,
)}\n`,
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Combined GitHub reruns:");
expect(result.stdout).toContain(
"- published-upgrade-survivor-openclaw-2026-5-3: gh workflow run",
);
expect(result.stdout).toContain(
"- published-upgrade-survivor-openclaw-2026-5-2: gh workflow run",
);
expect(result.stdout).toContain(
"docker_lanes='published-upgrade-survivor-openclaw-2026-5-3'",
);
expect(result.stdout).toContain(
"docker_lanes='published-upgrade-survivor-openclaw-2026-5-2'",
);
expect(result.stdout).not.toContain(
"docker_lanes='published-upgrade-survivor-openclaw-2026-5-3 published-upgrade-survivor-openclaw-2026-5-2'",
);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("merges duplicate lane entries before printing reruns", () => {
const root = mkdtempSync(`${tmpdir()}/openclaw-docker-e2e-rerun-merge-`);
try {
const file = path.join(root, "failures.json");
writeFileSync(
file,
`${JSON.stringify(
{
lanes: [
{
name: "published-upgrade-survivor-openclaw-2026-5-3",
status: 1,
},
{
ghWorkflowCommand:
"gh workflow run 'openclaw-live-and-e2e-checks-reusable.yml' --ref 'release/2026.6' -f published_upgrade_survivor_baselines='openclaw@2026.5.3'",
name: "published-upgrade-survivor-openclaw-2026-5-3",
status: 1,
},
],
status: "failed",
},
null,
2,
)}\n`,
"utf8",
);
const result = runHelper("scripts/docker-e2e-rerun.mjs", file, "--ref", "abc123");
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
const combinedCommand = result.stdout.match(/Combined GitHub rerun:\n([^\n]+)/u)?.[1] ?? "";
expect(combinedCommand).toContain("--ref 'release/2026.6'");
expect(combinedCommand).toContain(
"published_upgrade_survivor_baselines='openclaw@2026.5.3'",
);
} finally {
rmSync(root, { force: true, recursive: true });
}
});
});