From 57ce6b35cbbd5e0a854f969fbf7b66cf3688e64c Mon Sep 17 00:00:00 2001 From: dash14 Date: Sat, 18 Jul 2026 21:26:31 +0900 Subject: [PATCH 1/2] refactor: move buildRestrictExample into core/lib for reuse --- {report/src => core}/lib/build-example.js | 18 +++++- .../src => core}/lib/build-example.test.js | 62 +++++++++++++++++++ report/dist/main.cjs | 10 ++- report/src/main.js | 2 +- 4 files changed, 86 insertions(+), 6 deletions(-) rename {report/src => core}/lib/build-example.js (66%) rename {report/src => core}/lib/build-example.test.js (71%) diff --git a/report/src/lib/build-example.js b/core/lib/build-example.js similarity index 66% rename from report/src/lib/build-example.js rename to core/lib/build-example.js index fcb9196..60be058 100644 --- a/report/src/lib/build-example.js +++ b/core/lib/build-example.js @@ -11,9 +11,14 @@ const ruleTypeToParam = { * @param {Array<{host: string, port: string, ruleType: string}>} auditedRows * @param {string} actionRepo * @param {string} actionRef - the ref (tag or commit SHA) this action was invoked with + * @param {{actionName?: string, runCommand?: string}} [options] - actionName + * selects which sub-action the example step uses ("setup" for the + * two-step setup+report flow, "run" for the self-contained run action); + * runCommand, only meaningful for actionName "run", is the original + * `run:` input to preserve in the example step. * @returns {string} */ -export function buildRestrictExample(auditedRows, actionRepo, actionRef) { +export function buildRestrictExample(auditedRows, actionRepo, actionRef, { actionName = "setup", runCommand } = {}) { if (!auditedRows || auditedRows.length === 0) return ""; // A 40-char SHA is opaque to the reader and specific to this run, so show a @@ -34,8 +39,17 @@ export function buildRestrictExample(auditedRows, actionRepo, actionRef) { // Build YAML lines let yaml = ""; yaml += "- name: Start Buildcage in restrict mode\n"; - yaml += ` uses: ${actionRepo}/setup@${ref}\n`; + yaml += ` uses: ${actionRepo}/${actionName}@${ref}\n`; yaml += " with:\n"; + // The `run` action is a single self-contained step, so its example must + // repeat the `run:` command alongside the mode switch to stay copy-pasteable + // (matches `run:`'s position as the first input in run/action.yml). + if (actionName === "run" && runCommand) { + yaml += " run: |\n"; + for (const line of runCommand.split(/\r?\n/)) { + yaml += ` ${line}\n`; + } + } yaml += " proxy_mode: restrict\n"; for (const [param, rules] of groups) { yaml += ` ${param}: >-\n`; diff --git a/report/src/lib/build-example.test.js b/core/lib/build-example.test.js similarity index 71% rename from report/src/lib/build-example.test.js rename to core/lib/build-example.test.js index 9ac55cc..197691b 100644 --- a/report/src/lib/build-example.test.js +++ b/core/lib/build-example.test.js @@ -170,4 +170,66 @@ describe("buildRestrictExample", () => { ) ); }); + + it("uses the run action and includes the run command when actionName is 'run'", () => { + const rows = [ + { host: "registry.npmjs.org", port: "443", ruleType: "HTTPS", count: 5 }, + ]; + assert.equal( + buildRestrictExample(rows, REPO, REF, { actionName: "run", runCommand: "npm install" }), + wrap( + [ + "- name: Start Buildcage in restrict mode", + ` uses: ${REPO}/run@${REF}`, + " with:", + " run: |", + " npm install", + " proxy_mode: restrict", + " allowed_https_rules: >-", + " registry.npmjs.org:443", + ].join("\n") + "\n", + ) + ); + }); + + it("preserves multi-line run commands, indented under run: |", () => { + const rows = [ + { host: "registry.npmjs.org", port: "443", ruleType: "HTTPS", count: 1 }, + ]; + assert.equal( + buildRestrictExample(rows, REPO, REF, { actionName: "run", runCommand: "npm ci\nnpm test" }), + wrap( + [ + "- name: Start Buildcage in restrict mode", + ` uses: ${REPO}/run@${REF}`, + " with:", + " run: |", + " npm ci", + " npm test", + " proxy_mode: restrict", + " allowed_https_rules: >-", + " registry.npmjs.org:443", + ].join("\n") + "\n", + ) + ); + }); + + it("actionName 'run' without a runCommand omits the run: block", () => { + const rows = [ + { host: "registry.npmjs.org", port: "443", ruleType: "HTTPS", count: 1 }, + ]; + assert.equal( + buildRestrictExample(rows, REPO, REF, { actionName: "run" }), + wrap( + [ + "- name: Start Buildcage in restrict mode", + ` uses: ${REPO}/run@${REF}`, + " with:", + " proxy_mode: restrict", + " allowed_https_rules: >-", + " registry.npmjs.org:443", + ].join("\n") + "\n", + ) + ); + }); }); diff --git a/report/dist/main.cjs b/report/dist/main.cjs index ea8cef6..a3748a7 100644 --- a/report/dist/main.cjs +++ b/report/dist/main.cjs @@ -221,7 +221,7 @@ let markdown = `## Outbound Traffic Report during Docker Build (${report.mode} m if (isAudit) { const audited = isExplicit ? aggregateAllowedHosts(builds, "AUDIT") : report.sections.audited || []; audited.length > 0 && (markdown += "### 📋 Audited Hosts\n\n" + markdownTable(audited) + "\n"), - markdown += function(auditedRows, actionRepo, actionRef) { + markdown += function(auditedRows, actionRepo, actionRef, {actionName: actionName = "setup", runCommand: runCommand} = {}) { if (!auditedRows || 0 === auditedRows.length) return ""; const ref = /^[0-9a-f]{40}$/i.test(actionRef) ? "" : actionRef, groups = new Map; for (const r of auditedRows) { @@ -230,8 +230,12 @@ if (isAudit) { } if (0 === groups.size) return ""; let yaml = ""; - yaml += "- name: Start Buildcage in restrict mode\n", yaml += ` uses: ${actionRepo}/setup@${ref}\n`, - yaml += " with:\n", yaml += " proxy_mode: restrict\n"; + if (yaml += "- name: Start Buildcage in restrict mode\n", yaml += ` uses: ${actionRepo}/${actionName}@${ref}\n`, + yaml += " with:\n", "run" === actionName && runCommand) { + yaml += " run: |\n"; + for (const line of runCommand.split(/\r?\n/)) yaml += ` ${line}\n`; + } + yaml += " proxy_mode: restrict\n"; for (const [param, rules] of groups) { yaml += ` ${param}: >-\n`; for (const rule of rules) yaml += ` ${rule}\n`; diff --git a/report/src/main.js b/report/src/main.js index b754f54..a3a83e6 100644 --- a/report/src/main.js +++ b/report/src/main.js @@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { buildRestrictExample } from "./lib/build-example.js"; +import { buildRestrictExample } from "../../core/lib/build-example.js"; import { renderCommunicationDetails } from "./lib/command-log.js"; import { selectAllRefs, parseVertexAllowedLog, aggregateAllowedHosts } from "./lib/vertex-log.js"; import { createAnnotation } from "../../core/lib/annotation.js"; From bf096f58377240fea8faabc202b98e8e54579205 Mon Sep 17 00:00:00 2001 From: dash14 Date: Sat, 18 Jul 2026 21:26:40 +0900 Subject: [PATCH 2/2] feat: generate a restrict-mode example in the run action's audit report --- docs/reference.md | 5 +++ run/dist/main.cjs | 86 ++++++++++++++++++++++++++++---------- run/src/lib/report.js | 8 ++-- run/src/lib/report.test.js | 39 ++++++++++++++++- run/src/main.js | 3 ++ 5 files changed, 114 insertions(+), 27 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 66f6f89..18535e7 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -221,6 +221,11 @@ safe when those steps run truly concurrently via GitHub Actions' `background`/`w namespaced by the same per-step random suffix, so concurrent steps never recreate or tear down each other's containers. +In `audit` mode, the Job Summary also includes a ready-to-paste `restrict` mode example — a +`run` step with `proxy_mode: restrict` and allowlist rules generated from the hosts observed +during the audited run — mirroring the same example the `report` action generates for +`setup`/`report` workflows. + ### Parameters | Parameter | Required | Default | Description | diff --git a/run/dist/main.cjs b/run/dist/main.cjs index 94a8058..76b745c 100644 --- a/run/dist/main.cjs +++ b/run/dist/main.cjs @@ -7295,6 +7295,12 @@ function runIsolated({scriptPath: scriptPath, proxyPid: proxyPid, workdir: workd } } +const ruleTypeToParam = { + HTTPS: "allowed_https_rules", + HTTP: "allowed_http_rules", + IP: "allowed_ip_rules" +}; + function markdownTable(rows, {showReason: showReason = !1} = {}) { if (showReason) { const lines = [ "| Host | Rule | Reason | Count |", "| --- | --- | --- | ---: |" ]; @@ -7306,29 +7312,60 @@ function markdownTable(rows, {showReason: showReason = !1} = {}) { return lines.join("\n"); } -function writeReport(report, {stepLabel: stepLabel, failOnBlocked: failOnBlocked} = {}) { - const markdown = function(report, {stepLabel: stepLabel} = {}) { - if (null === report.mode) return `### 🧰 Run${stepLabel ? ` — ${stepLabel}` : ""}\n\nNo proxy logs found.\n`; - const isAudit = "audit" === report.mode; - let markdown = `### 🧰 Run${stepLabel ? ` — ${stepLabel}` : ""} (${report.mode} mode)\n\n`; - if (isAudit) { - const audited = report.sections.audited || []; - audited.length > 0 && (markdown += "**📋 Audited Hosts**\n\n" + markdownTable(audited) + "\n\n"); - const blocked = report.sections.blocked || []; - blocked.length > 0 && (markdown += "**🚫 Blocked Hosts**\n\n" + markdownTable(blocked, { - showReason: !0 - }) + "\n\n"); - } else { - const allowed = report.sections.allowed || []; - allowed.length > 0 && (markdown += "**✅ Allowed Hosts**\n\n" + markdownTable(allowed) + "\n\n"); - const blocked = report.sections.blocked || []; - blocked.length > 0 && (markdown += "**🚫 Blocked Hosts**\n\n" + markdownTable(blocked, { - showReason: !0 - }) + "\n\n"); - } - return markdown; - }(report, { - stepLabel: stepLabel +function buildReportMarkdown(report, {stepLabel: stepLabel, actionRepo: actionRepo, actionRef: actionRef, runCommand: runCommand} = {}) { + if (null === report.mode) return `### 🧰 Run${stepLabel ? ` — ${stepLabel}` : ""}\n\nNo proxy logs found.\n`; + const isAudit = "audit" === report.mode; + let markdown = `### 🧰 Run${stepLabel ? ` — ${stepLabel}` : ""} (${report.mode} mode)\n\n`; + if (isAudit) { + const audited = report.sections.audited || []; + audited.length > 0 && (markdown += "**📋 Audited Hosts**\n\n" + markdownTable(audited) + "\n\n"), + markdown += function(auditedRows, actionRepo, actionRef, {actionName: actionName = "setup", runCommand: runCommand} = {}) { + if (!auditedRows || 0 === auditedRows.length) return ""; + const ref = /^[0-9a-f]{40}$/i.test(actionRef) ? "" : actionRef, groups = new Map; + for (const r of auditedRows) { + const param = ruleTypeToParam[r.ruleType]; + param && (groups.has(param) || groups.set(param, []), groups.get(param).push(`${r.host}:${r.port}`)); + } + if (0 === groups.size) return ""; + let yaml = ""; + if (yaml += "- name: Start Buildcage in restrict mode\n", yaml += ` uses: ${actionRepo}/${actionName}@${ref}\n`, + yaml += " with:\n", "run" === actionName && runCommand) { + yaml += " run: |\n"; + for (const line of runCommand.split(/\r?\n/)) yaml += ` ${line}\n`; + } + yaml += " proxy_mode: restrict\n"; + for (const [param, rules] of groups) { + yaml += ` ${param}: >-\n`; + for (const rule of rules) yaml += ` ${rule}\n`; + } + let md = "\n
\n"; + return md += "🛡️ Switch to restrict mode\n\n", md += "```yaml\n", + md += yaml, md += "```\n\n", md += "
\n", md; + }(audited, actionRepo, actionRef, { + actionName: "run", + runCommand: runCommand + }); + const blocked = report.sections.blocked || []; + blocked.length > 0 && (markdown += "**🚫 Blocked Hosts**\n\n" + markdownTable(blocked, { + showReason: !0 + }) + "\n\n"); + } else { + const allowed = report.sections.allowed || []; + allowed.length > 0 && (markdown += "**✅ Allowed Hosts**\n\n" + markdownTable(allowed) + "\n\n"); + const blocked = report.sections.blocked || []; + blocked.length > 0 && (markdown += "**🚫 Blocked Hosts**\n\n" + markdownTable(blocked, { + showReason: !0 + }) + "\n\n"); + } + return markdown; +} + +function writeReport(report, {stepLabel: stepLabel, failOnBlocked: failOnBlocked, actionRepo: actionRepo, actionRef: actionRef, runCommand: runCommand} = {}) { + const markdown = buildReportMarkdown(report, { + stepLabel: stepLabel, + actionRepo: actionRepo, + actionRef: actionRef, + runCommand: runCommand }), summaryFile = process.env.GITHUB_STEP_SUMMARY; summaryFile ? node_fs.appendFileSync(summaryFile, markdown) : console.log(markdown); const debugSummaryFile = process.env.BUILDCAGE_RUN_DEBUG_SUMMARY_FILE; @@ -7482,6 +7519,9 @@ process.argv[1] === node_url.fileURLToPath("undefined" == typeof document ? requ return JSON.parse(jsonOutput); }(containerName); writeReport(report, { + actionRepo: actionRepo, + actionRef: actionRef, + runCommand: runInput, failOnBlocked: "true" === (env.INPUT_FAIL_ON_BLOCKED || "true").toLowerCase() }); } catch (e) { diff --git a/run/src/lib/report.js b/run/src/lib/report.js index ebc1a57..e3b2c83 100644 --- a/run/src/lib/report.js +++ b/run/src/lib/report.js @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import { appendFileSync } from "node:fs"; import { createAnnotation } from "../../../core/lib/annotation.js"; +import { buildRestrictExample } from "../../../core/lib/build-example.js"; /** * Fetch the structured HAProxy-log report from the (still-running) proxy @@ -33,7 +34,7 @@ function markdownTable(rows, { showReason = false } = {}) { * report. Each `run` step gets its own section (rather than one report * per job), matching the "one proxy container per step" execution model. */ -export function buildReportMarkdown(report, { stepLabel } = {}) { +export function buildReportMarkdown(report, { stepLabel, actionRepo, actionRef, runCommand } = {}) { if (report.mode === null) { return `### 🧰 Run${stepLabel ? ` — ${stepLabel}` : ""}\n\nNo proxy logs found.\n`; } @@ -44,6 +45,7 @@ export function buildReportMarkdown(report, { stepLabel } = {}) { if (isAudit) { const audited = report.sections.audited || []; if (audited.length > 0) markdown += "**📋 Audited Hosts**\n\n" + markdownTable(audited) + "\n\n"; + markdown += buildRestrictExample(audited, actionRepo, actionRef, { actionName: "run", runCommand }); const blocked = report.sections.blocked || []; if (blocked.length > 0) markdown += "**🚫 Blocked Hosts**\n\n" + markdownTable(blocked, { showReason: true }) + "\n\n"; } else { @@ -61,8 +63,8 @@ export function buildReportMarkdown(report, { stepLabel } = {}) { * set the exit code for blocked connections, mirroring report/src/main.js's * behavior but scoped to a single run step's proxy container. */ -export function writeReport(report, { stepLabel, failOnBlocked } = {}) { - const markdown = buildReportMarkdown(report, { stepLabel }); +export function writeReport(report, { stepLabel, failOnBlocked, actionRepo, actionRef, runCommand } = {}) { + const markdown = buildReportMarkdown(report, { stepLabel, actionRepo, actionRef, runCommand }); const summaryFile = process.env.GITHUB_STEP_SUMMARY; if (summaryFile) { appendFileSync(summaryFile, markdown); diff --git a/run/src/lib/report.test.js b/run/src/lib/report.test.js index aab166d..e4d2372 100644 --- a/run/src/lib/report.test.js +++ b/run/src/lib/report.test.js @@ -12,7 +12,7 @@ import assert from "node:assert/strict"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; -import { writeReport } from "./report.js"; +import { writeReport, buildReportMarkdown } from "./report.js"; import { withScratchDir } from "./isolated-exec.js"; // writeReport reads GITHUB_STEP_SUMMARY/BUILDCAGE_RUN_DEBUG_SUMMARY_FILE @@ -66,3 +66,40 @@ describe("writeReport", () => { assert.equal(writeReportWithSummary(report, { failOnBlocked: true }), undefined); }); }); + +describe("buildReportMarkdown", () => { + it("includes a restrict-mode example (as a `run` step) in audit mode with audited hosts", () => { + const report = { + mode: "audit", + blockedCount: 0, + sections: { + audited: [{ host: "registry.npmjs.org", port: "443", ruleType: "HTTPS", count: 3 }], + }, + }; + const markdown = buildReportMarkdown(report, { + actionRepo: "dash14/buildcage", + actionRef: "v2", + runCommand: "npm install", + }); + assert.match(markdown, /Switch to restrict mode/); + assert.match(markdown, /uses: dash14\/buildcage\/run@v2/); + assert.match(markdown, /run: \|\n\s+npm install/); + assert.match(markdown, /allowed_https_rules: >-\n\s+registry\.npmjs\.org:443/); + }); + + it("omits the restrict-mode example in restrict mode", () => { + const report = { + mode: "restrict", + blockedCount: 0, + sections: { allowed: [{ host: "registry.npmjs.org", port: "443", ruleType: "HTTPS", count: 3 }] }, + }; + const markdown = buildReportMarkdown(report, { actionRepo: "dash14/buildcage", actionRef: "v2" }); + assert.doesNotMatch(markdown, /Switch to restrict mode/); + }); + + it("omits the restrict-mode example in audit mode with no audited hosts", () => { + const report = { mode: "audit", blockedCount: 0, sections: {} }; + const markdown = buildReportMarkdown(report, { actionRepo: "dash14/buildcage", actionRef: "v2" }); + assert.doesNotMatch(markdown, /Switch to restrict mode/); + }); +}); diff --git a/run/src/main.js b/run/src/main.js index 74aeed0..6bdc7a7 100644 --- a/run/src/main.js +++ b/run/src/main.js @@ -174,6 +174,9 @@ async function main() { try { const report = fetchReport(containerName); writeReport(report, { + actionRepo, + actionRef, + runCommand: runInput, failOnBlocked: (env.INPUT_FAIL_ON_BLOCKED || "true").toLowerCase() === "true", }); } catch (e) {