From 7f4f9e3c0ffb23618d7eab6240a0842eea4f7c4d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:28:58 +0000 Subject: [PATCH 1/6] fix: prevent unsafe quoting injection in network-allowed update script Alert: #600 (go/unsafe-quoting, critical) CWE-116: Improper Encoding or Escaping of Output The ecosystemJSON value was interpolated directly into a Python raw triple-single-quoted string (r'''%s''') inside a bash heredoc. If any ecosystem name or domain string contained three consecutive single quotes ('''), it would break out of the Python string literal and could allow code injection. Fix: base64-encode the ecosystem JSON in Go using encoding/base64.StdEncoding.EncodeToString, then decode it in Python via base64.b64decode('%s').decode(). Base64 output contains only [A-Za-z0-9+/=] characters, making injection through the string delimiter impossible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/workflow/awf_helpers.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index f84f96f4857..605417cf1d7 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -23,6 +23,7 @@ package workflow import ( + "encoding/base64" "encoding/json" "fmt" "sort" @@ -175,6 +176,7 @@ func buildWorkflowCallNetworkAllowedUpdateScript() (string, error) { } return fmt.Sprintf(`python3 - <<'PY' +import base64 import json import os from pathlib import Path @@ -197,7 +199,7 @@ network_allowed = os.environ.get(%q, "") tokens = [token.strip() for token in network_allowed.split(",") if token.strip()] if tokens: - ecosystem_map = json.loads(r'''%s''') + ecosystem_map = json.loads(base64.b64decode('%s').decode()) allow_domains = config.setdefault("network", {}).setdefault("allowDomains", []) seen = set(allow_domains) for token in tokens: @@ -210,7 +212,7 @@ try: config_path.write_text(json.dumps(config, separators=(",", ":"), ensure_ascii=False) + "\n") except OSError as exc: raise SystemExit(f"Failed to write AWF config file at {config_path}: {exc}") from exc -PY`, string(WorkflowCallNetworkAllowedEnvVar), string(ecosystemJSON)), nil +PY`, string(WorkflowCallNetworkAllowedEnvVar), base64.StdEncoding.EncodeToString(ecosystemJSON)), nil } // BuildAWFCommand builds a complete AWF command with all arguments. From 8a31bec0dd01a62b13da6e9ff7bf6c16403bd126 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:59:07 +0000 Subject: [PATCH 2/6] fix: replace Python network-allowed updater with JavaScript in actions/setup/js Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_network_allowed.cjs | 112 ++++++++++++ .../setup/js/update_network_allowed.test.cjs | 162 ++++++++++++++++++ pkg/workflow/awf_config_test.go | 5 +- pkg/workflow/awf_helpers.go | 46 +---- 4 files changed, 285 insertions(+), 40 deletions(-) create mode 100644 actions/setup/js/update_network_allowed.cjs create mode 100644 actions/setup/js/update_network_allowed.test.cjs diff --git a/actions/setup/js/update_network_allowed.cjs b/actions/setup/js/update_network_allowed.cjs new file mode 100644 index 00000000000..58877bde33c --- /dev/null +++ b/actions/setup/js/update_network_allowed.cjs @@ -0,0 +1,112 @@ +// @ts-check +"use strict"; + +/** + * update_network_allowed.cjs + * + * Updates the AWF config file's network.allowDomains list based on the + * GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED environment variable. + * + * The variable contains a comma-separated list of ecosystem tokens (e.g. "npm,pip") + * or raw domain names. Each token is expanded to its known set of domains using the + * ecosystem map embedded via the GH_AW_ECOSYSTEM_MAP_JSON environment variable. + * Unknown tokens are treated as raw domain names. + * + * Environment variables: + * RUNNER_TEMP - GitHub Actions runner temp directory + * GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED - Comma-separated allowed tokens/domains + * GH_AW_ECOSYSTEM_MAP_JSON - JSON object mapping ecosystem names to domain arrays + * + * Exit codes: + * 0 — Success (including when no tokens are specified) + * 1 — Fatal error (missing RUNNER_TEMP, unreadable/invalid config file, write failure) + */ + +const fs = require("fs"); +const path = require("path"); + +const NETWORK_ALLOWED_ENV_VAR = "GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED"; + +/** + * @returns {Promise} + */ +async function main() { + const runnerTemp = process.env.RUNNER_TEMP; + if (!runnerTemp) { + process.stderr.write("RUNNER_TEMP is not set\n"); + process.exit(1); + } + + const configPath = path.join(runnerTemp, "gh-aw", "awf-config.json"); + + /** @type {Record} */ + let config; + try { + config = JSON.parse(fs.readFileSync(configPath, "utf8")); + } catch (/** @type {unknown} */ err) { + const e = /** @type {NodeJS.ErrnoException} */ err; + if (e.code === "ENOENT") { + process.stderr.write(`Missing AWF config file at ${configPath}\n`); + } else if (err instanceof SyntaxError) { + process.stderr.write(`Invalid AWF config JSON at ${configPath}: ${e.message}\n`); + } else { + process.stderr.write(`Failed to read AWF config file at ${configPath}: ${e.message}\n`); + } + process.exit(1); + } + + const networkAllowed = process.env[NETWORK_ALLOWED_ENV_VAR] || ""; + const tokens = networkAllowed + .split(",") + .map(t => t.trim()) + .filter(t => t.length > 0); + + if (tokens.length > 0) { + const ecosystemMapJSON = process.env.GH_AW_ECOSYSTEM_MAP_JSON; + if (!ecosystemMapJSON) { + process.stderr.write("GH_AW_ECOSYSTEM_MAP_JSON is not set\n"); + process.exit(1); + } + + /** @type {Record} */ + const ecosystemMap = JSON.parse(ecosystemMapJSON); + + if (!config.network || typeof config.network !== "object") { + config.network = {}; + } + const network = /** @type {Record} */ config.network; + if (!Array.isArray(network.allowDomains)) { + network.allowDomains = []; + } + const allowDomains = /** @type {string[]} */ network.allowDomains; + const seen = new Set(allowDomains); + + for (const token of tokens) { + const domains = ecosystemMap[token] || [token]; + for (const domain of domains) { + if (!seen.has(domain)) { + allowDomains.push(domain); + seen.add(domain); + } + } + } + } + + try { + fs.writeFileSync(configPath, JSON.stringify(config) + "\n"); + } catch (/** @type {unknown} */ err) { + const e = /** @type {NodeJS.ErrnoException} */ err; + process.stderr.write(`Failed to write AWF config file at ${configPath}: ${e.message}\n`); + process.exit(1); + } +} + +module.exports = { main }; + +if (require.main === module) { + main().catch((/** @type {unknown} */ err) => { + const e = /** @type {Error} */ err; + process.stderr.write(`Error: ${e.message}\n`); + process.exit(1); + }); +} diff --git a/actions/setup/js/update_network_allowed.test.cjs b/actions/setup/js/update_network_allowed.test.cjs new file mode 100644 index 00000000000..71bc2ad3743 --- /dev/null +++ b/actions/setup/js/update_network_allowed.test.cjs @@ -0,0 +1,162 @@ +// @ts-check +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createRequire } from "module"; +import { tmpdir } from "os"; +import { join } from "path"; +import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "fs"; + +const req = createRequire(import.meta.url); +const { main } = req("./update_network_allowed.cjs"); + +const ECOSYSTEM_MAP = { + npm: ["registry.npmjs.org", "nodejs.org"], + python: ["pypi.org", "files.pythonhosted.org"], +}; + +describe("update_network_allowed.cjs", () => { + /** @type {string} */ + let tempDir; + /** @type {string} */ + let configPath; + /** @type {Record} */ + let savedEnv; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "update-network-allowed-test-")); + const ghAwDir = join(tempDir, "gh-aw"); + require("fs").mkdirSync(ghAwDir); + configPath = join(ghAwDir, "awf-config.json"); + + savedEnv = { + RUNNER_TEMP: process.env.RUNNER_TEMP, + GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED: process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED, + GH_AW_ECOSYSTEM_MAP_JSON: process.env.GH_AW_ECOSYSTEM_MAP_JSON, + }; + + process.env.RUNNER_TEMP = tempDir; + process.env.GH_AW_ECOSYSTEM_MAP_JSON = JSON.stringify(ECOSYSTEM_MAP); + delete process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("leaves allowDomains unchanged when no tokens are set", async () => { + const initial = { network: { allowDomains: ["example.com"] } }; + writeFileSync(configPath, JSON.stringify(initial) + "\n"); + + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = ""; + await main(); + + const result = JSON.parse(readFileSync(configPath, "utf8")); + expect(result.network.allowDomains).toEqual(["example.com"]); + }); + + it("expands an ecosystem token to its domains", async () => { + const initial = { network: { allowDomains: [] } }; + writeFileSync(configPath, JSON.stringify(initial) + "\n"); + + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = "npm"; + await main(); + + const result = JSON.parse(readFileSync(configPath, "utf8")); + expect(result.network.allowDomains).toContain("registry.npmjs.org"); + expect(result.network.allowDomains).toContain("nodejs.org"); + }); + + it("expands multiple ecosystem tokens", async () => { + const initial = { network: { allowDomains: [] } }; + writeFileSync(configPath, JSON.stringify(initial) + "\n"); + + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = "npm,python"; + await main(); + + const result = JSON.parse(readFileSync(configPath, "utf8")); + expect(result.network.allowDomains).toContain("registry.npmjs.org"); + expect(result.network.allowDomains).toContain("pypi.org"); + }); + + it("treats unknown tokens as raw domain names", async () => { + const initial = { network: { allowDomains: [] } }; + writeFileSync(configPath, JSON.stringify(initial) + "\n"); + + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = "custom.example.com"; + await main(); + + const result = JSON.parse(readFileSync(configPath, "utf8")); + expect(result.network.allowDomains).toContain("custom.example.com"); + }); + + it("does not add duplicate domains", async () => { + const initial = { network: { allowDomains: ["registry.npmjs.org"] } }; + writeFileSync(configPath, JSON.stringify(initial) + "\n"); + + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = "npm"; + await main(); + + const result = JSON.parse(readFileSync(configPath, "utf8")); + const count = result.network.allowDomains.filter((/** @type {string} */ d) => d === "registry.npmjs.org").length; + expect(count).toBe(1); + }); + + it("initialises network.allowDomains when not present", async () => { + const initial = { apiProxy: { enabled: true } }; + writeFileSync(configPath, JSON.stringify(initial) + "\n"); + + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = "npm"; + await main(); + + const result = JSON.parse(readFileSync(configPath, "utf8")); + expect(Array.isArray(result.network.allowDomains)).toBe(true); + expect(result.network.allowDomains).toContain("registry.npmjs.org"); + }); + + it("trims whitespace around tokens", async () => { + const initial = { network: { allowDomains: [] } }; + writeFileSync(configPath, JSON.stringify(initial) + "\n"); + + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = " npm , python "; + await main(); + + const result = JSON.parse(readFileSync(configPath, "utf8")); + expect(result.network.allowDomains).toContain("registry.npmjs.org"); + expect(result.network.allowDomains).toContain("pypi.org"); + }); + + it("writes compact JSON with a trailing newline", async () => { + const initial = { network: { allowDomains: [] } }; + writeFileSync(configPath, JSON.stringify(initial) + "\n"); + + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = "npm"; + await main(); + + const raw = readFileSync(configPath, "utf8"); + expect(raw.endsWith("\n")).toBe(true); + // Compact JSON: no spaces after : or , + expect(raw).not.toMatch(/: /); + expect(raw).not.toMatch(/, /); + }); + + it("exits 1 when RUNNER_TEMP is not set", async () => { + delete process.env.RUNNER_TEMP; + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = "npm"; + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(_code => { + throw new Error("process.exit called"); + }); + try { + await expect(main()).rejects.toThrow(); + } finally { + exitSpy.mockRestore(); + } + }); +}); diff --git a/pkg/workflow/awf_config_test.go b/pkg/workflow/awf_config_test.go index 3355c44b1d8..e2feffd6593 100644 --- a/pkg/workflow/awf_config_test.go +++ b/pkg/workflow/awf_config_test.go @@ -1396,7 +1396,10 @@ func TestBuildAWFCommand_WorkflowCallNetworkAllowedUpdaterUsesRunnerTempEnv(t *t command := BuildAWFCommand(config) - assert.Contains(t, command, `os.environ.get("RUNNER_TEMP")`, "workflow_call network updater should resolve RUNNER_TEMP inside Python") + assert.Contains(t, command, `update_network_allowed.cjs`, "workflow_call network updater should invoke the JavaScript implementation") + assert.Contains(t, command, `GH_AW_ECOSYSTEM_MAP_JSON=`, "workflow_call network updater should pass ecosystem map via env var") + assert.Contains(t, command, `"${RUNNER_TEMP}/gh-aw/actions/update_network_allowed.cjs"`, "workflow_call network updater should resolve RUNNER_TEMP at runtime via shell expansion") + assert.NotContains(t, command, `os.environ.get("RUNNER_TEMP")`, "workflow_call network updater should not use Python os.environ") assert.NotContains(t, command, `Path("${RUNNER_TEMP}/gh-aw/awf-config.json")`, "workflow_call network updater should not embed an unexpanded RUNNER_TEMP literal") } diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index 605417cf1d7..8b2c50592d6 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -23,7 +23,6 @@ package workflow import ( - "encoding/base64" "encoding/json" "fmt" "sort" @@ -175,44 +174,13 @@ func buildWorkflowCallNetworkAllowedUpdateScript() (string, error) { return "", fmt.Errorf("marshal network allowed ecosystem map: %w", err) } - return fmt.Sprintf(`python3 - <<'PY' -import base64 -import json -import os -from pathlib import Path - -runner_temp = os.environ.get("RUNNER_TEMP") -if not runner_temp: - raise SystemExit("RUNNER_TEMP is not set") - -config_path = Path(runner_temp) / "gh-aw" / "awf-config.json" -try: - config = json.loads(config_path.read_text()) -except FileNotFoundError as exc: - raise SystemExit(f"Missing AWF config file at {config_path}") from exc -except json.JSONDecodeError as exc: - raise SystemExit(f"Invalid AWF config JSON at {config_path}: {exc}") from exc -except OSError as exc: - raise SystemExit(f"Failed to read AWF config file at {config_path}: {exc}") from exc - -network_allowed = os.environ.get(%q, "") -tokens = [token.strip() for token in network_allowed.split(",") if token.strip()] - -if tokens: - ecosystem_map = json.loads(base64.b64decode('%s').decode()) - allow_domains = config.setdefault("network", {}).setdefault("allowDomains", []) - seen = set(allow_domains) - for token in tokens: - for domain in ecosystem_map.get(token, [token]): - if domain not in seen: - allow_domains.append(domain) - seen.add(domain) - -try: - config_path.write_text(json.dumps(config, separators=(",", ":"), ensure_ascii=False) + "\n") -except OSError as exc: - raise SystemExit(f"Failed to write AWF config file at {config_path}: {exc}") from exc -PY`, string(WorkflowCallNetworkAllowedEnvVar), base64.StdEncoding.EncodeToString(ecosystemJSON)), nil + // Pass the ecosystem map JSON via an env var and invoke the JavaScript + // implementation deployed by actions/setup to ${RUNNER_TEMP}/gh-aw/actions/. + // Using node avoids any Python dependency and eliminates the risk of quote + // injection: the ecosystem map is single-quoted by shellEscapeArg (JSON only + // contains double-quoted strings so no single quotes can appear). + return fmt.Sprintf(`GH_AW_ECOSYSTEM_MAP_JSON=%s node "${RUNNER_TEMP}/gh-aw/actions/update_network_allowed.cjs"`, + shellEscapeArg(string(ecosystemJSON))), nil } // BuildAWFCommand builds a complete AWF command with all arguments. From 5202f10bc76f1e2facd64315be67afd35817bf3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:00:47 +0000 Subject: [PATCH 3/6] fix: explicit compact JSON and import vi in update_network_allowed files Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_network_allowed.cjs | 2 +- actions/setup/js/update_network_allowed.test.cjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/update_network_allowed.cjs b/actions/setup/js/update_network_allowed.cjs index 58877bde33c..7000500c588 100644 --- a/actions/setup/js/update_network_allowed.cjs +++ b/actions/setup/js/update_network_allowed.cjs @@ -93,7 +93,7 @@ async function main() { } try { - fs.writeFileSync(configPath, JSON.stringify(config) + "\n"); + fs.writeFileSync(configPath, JSON.stringify(config, null, 0) + "\n"); } catch (/** @type {unknown} */ err) { const e = /** @type {NodeJS.ErrnoException} */ err; process.stderr.write(`Failed to write AWF config file at ${configPath}: ${e.message}\n`); diff --git a/actions/setup/js/update_network_allowed.test.cjs b/actions/setup/js/update_network_allowed.test.cjs index 71bc2ad3743..ae581ef48f1 100644 --- a/actions/setup/js/update_network_allowed.test.cjs +++ b/actions/setup/js/update_network_allowed.test.cjs @@ -1,5 +1,5 @@ // @ts-check -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createRequire } from "module"; import { tmpdir } from "os"; import { join } from "path"; From 6195e80a92b8da558dd7bdb78468b5f7698007ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:52:46 +0000 Subject: [PATCH 4/6] fix network-allowed updater typing and review feedback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_network_allowed.cjs | 34 ++++++++++++------- .../setup/js/update_network_allowed.test.cjs | 23 +++++++++++-- pkg/workflow/awf_helpers.go | 5 ++- 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/actions/setup/js/update_network_allowed.cjs b/actions/setup/js/update_network_allowed.cjs index 7000500c588..1ca9eae6f6c 100644 --- a/actions/setup/js/update_network_allowed.cjs +++ b/actions/setup/js/update_network_allowed.cjs @@ -7,7 +7,7 @@ * Updates the AWF config file's network.allowDomains list based on the * GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED environment variable. * - * The variable contains a comma-separated list of ecosystem tokens (e.g. "npm,pip") + * The variable contains a comma-separated list of ecosystem tokens (e.g. "node,python") * or raw domain names. Each token is expanded to its known set of domains using the * ecosystem map embedded via the GH_AW_ECOSYSTEM_MAP_JSON environment variable. * Unknown tokens are treated as raw domain names. @@ -39,18 +39,19 @@ async function main() { const configPath = path.join(runnerTemp, "gh-aw", "awf-config.json"); - /** @type {Record} */ + /** @type {any} */ let config; try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch (/** @type {unknown} */ err) { - const e = /** @type {NodeJS.ErrnoException} */ err; - if (e.code === "ENOENT") { + const errCode = err && typeof err === "object" && "code" in err ? err.code : undefined; + const errMessage = err instanceof Error ? err.message : String(err); + if (errCode === "ENOENT") { process.stderr.write(`Missing AWF config file at ${configPath}\n`); } else if (err instanceof SyntaxError) { - process.stderr.write(`Invalid AWF config JSON at ${configPath}: ${e.message}\n`); + process.stderr.write(`Invalid AWF config JSON at ${configPath}: ${errMessage}\n`); } else { - process.stderr.write(`Failed to read AWF config file at ${configPath}: ${e.message}\n`); + process.stderr.write(`Failed to read AWF config file at ${configPath}: ${errMessage}\n`); } process.exit(1); } @@ -69,9 +70,16 @@ async function main() { } /** @type {Record} */ - const ecosystemMap = JSON.parse(ecosystemMapJSON); + let ecosystemMap; + try { + ecosystemMap = JSON.parse(ecosystemMapJSON); + } catch (/** @type {unknown} */ err) { + const errMessage = err instanceof Error ? err.message : String(err); + process.stderr.write(`Invalid GH_AW_ECOSYSTEM_MAP_JSON: ${errMessage}\n`); + process.exit(1); + } - if (!config.network || typeof config.network !== "object") { + if (!config.network || typeof config.network !== "object" || Array.isArray(config.network)) { config.network = {}; } const network = /** @type {Record} */ config.network; @@ -93,10 +101,10 @@ async function main() { } try { - fs.writeFileSync(configPath, JSON.stringify(config, null, 0) + "\n"); + fs.writeFileSync(configPath, JSON.stringify(config) + "\n"); } catch (/** @type {unknown} */ err) { - const e = /** @type {NodeJS.ErrnoException} */ err; - process.stderr.write(`Failed to write AWF config file at ${configPath}: ${e.message}\n`); + const errMessage = err instanceof Error ? err.message : String(err); + process.stderr.write(`Failed to write AWF config file at ${configPath}: ${errMessage}\n`); process.exit(1); } } @@ -105,8 +113,8 @@ module.exports = { main }; if (require.main === module) { main().catch((/** @type {unknown} */ err) => { - const e = /** @type {Error} */ err; - process.stderr.write(`Error: ${e.message}\n`); + const errMessage = err instanceof Error ? err.message : String(err); + process.stderr.write(`Error: ${errMessage}\n`); process.exit(1); }); } diff --git a/actions/setup/js/update_network_allowed.test.cjs b/actions/setup/js/update_network_allowed.test.cjs index ae581ef48f1..e9a604724c5 100644 --- a/actions/setup/js/update_network_allowed.test.cjs +++ b/actions/setup/js/update_network_allowed.test.cjs @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createRequire } from "module"; import { tmpdir } from "os"; import { join } from "path"; -import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "fs"; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync } from "fs"; const req = createRequire(import.meta.url); const { main } = req("./update_network_allowed.cjs"); @@ -24,7 +24,7 @@ describe("update_network_allowed.cjs", () => { beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "update-network-allowed-test-")); const ghAwDir = join(tempDir, "gh-aw"); - require("fs").mkdirSync(ghAwDir); + mkdirSync(ghAwDir); configPath = join(ghAwDir, "awf-config.json"); savedEnv = { @@ -155,6 +155,25 @@ describe("update_network_allowed.cjs", () => { }); try { await expect(main()).rejects.toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + exitSpy.mockRestore(); + } + }); + + it("exits 1 when GH_AW_ECOSYSTEM_MAP_JSON is invalid JSON", async () => { + const initial = { network: { allowDomains: [] } }; + writeFileSync(configPath, JSON.stringify(initial) + "\n"); + + process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = "npm"; + process.env.GH_AW_ECOSYSTEM_MAP_JSON = "{not valid json"; + + const exitSpy = vi.spyOn(process, "exit").mockImplementation(_code => { + throw new Error("process.exit called"); + }); + try { + await expect(main()).rejects.toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); } finally { exitSpy.mockRestore(); } diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index 8b2c50592d6..d8670d54709 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -176,9 +176,8 @@ func buildWorkflowCallNetworkAllowedUpdateScript() (string, error) { // Pass the ecosystem map JSON via an env var and invoke the JavaScript // implementation deployed by actions/setup to ${RUNNER_TEMP}/gh-aw/actions/. - // Using node avoids any Python dependency and eliminates the risk of quote - // injection: the ecosystem map is single-quoted by shellEscapeArg (JSON only - // contains double-quoted strings so no single quotes can appear). + // Using node avoids any Python dependency and eliminates quote-injection risk: + // shellEscapeArg safely single-quotes and escapes the JSON payload. return fmt.Sprintf(`GH_AW_ECOSYSTEM_MAP_JSON=%s node "${RUNNER_TEMP}/gh-aw/actions/update_network_allowed.cjs"`, shellEscapeArg(string(ecosystemJSON))), nil } From 806f167a93a474eeb03880c9701f976ec4c608fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:56:42 +0000 Subject: [PATCH 5/6] refine updater typing and error handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_network_allowed.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/update_network_allowed.cjs b/actions/setup/js/update_network_allowed.cjs index 1ca9eae6f6c..0c793e021a3 100644 --- a/actions/setup/js/update_network_allowed.cjs +++ b/actions/setup/js/update_network_allowed.cjs @@ -26,6 +26,8 @@ const fs = require("fs"); const path = require("path"); const NETWORK_ALLOWED_ENV_VAR = "GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED"; +/** @typedef {{allowDomains?: string[]}} AWFNetworkConfig */ +/** @typedef {Record & {network?: AWFNetworkConfig | unknown}} AWFConfig */ /** * @returns {Promise} @@ -39,7 +41,7 @@ async function main() { const configPath = path.join(runnerTemp, "gh-aw", "awf-config.json"); - /** @type {any} */ + /** @type {AWFConfig} */ let config; try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); @@ -82,11 +84,11 @@ async function main() { if (!config.network || typeof config.network !== "object" || Array.isArray(config.network)) { config.network = {}; } - const network = /** @type {Record} */ config.network; + const network = /** @type {AWFNetworkConfig} */ config.network; if (!Array.isArray(network.allowDomains)) { network.allowDomains = []; } - const allowDomains = /** @type {string[]} */ network.allowDomains; + const allowDomains = network.allowDomains; const seen = new Set(allowDomains); for (const token of tokens) { From 14304eae183ebcdbe56f267b26abf33f7c6bab19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:02:49 +0000 Subject: [PATCH 6/6] clarify malformed network array handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/update_network_allowed.cjs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/update_network_allowed.cjs b/actions/setup/js/update_network_allowed.cjs index 0c793e021a3..a54a256d25d 100644 --- a/actions/setup/js/update_network_allowed.cjs +++ b/actions/setup/js/update_network_allowed.cjs @@ -29,6 +29,22 @@ const NETWORK_ALLOWED_ENV_VAR = "GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED"; /** @typedef {{allowDomains?: string[]}} AWFNetworkConfig */ /** @typedef {Record & {network?: AWFNetworkConfig | unknown}} AWFConfig */ +/** + * @param {any} value + * @returns {AWFNetworkConfig} + */ +function toNetworkConfig(value) { + return value; +} + +/** + * @param {any} value + * @returns {string[]} + */ +function toStringArray(value) { + return value; +} + /** * @returns {Promise} */ @@ -81,14 +97,15 @@ async function main() { process.exit(1); } + // Arrays are treated as malformed for this field and reset to an object shape. if (!config.network || typeof config.network !== "object" || Array.isArray(config.network)) { config.network = {}; } - const network = /** @type {AWFNetworkConfig} */ config.network; + const network = toNetworkConfig(config.network); if (!Array.isArray(network.allowDomains)) { network.allowDomains = []; } - const allowDomains = network.allowDomains; + const allowDomains = toStringArray(network.allowDomains); const seen = new Set(allowDomains); for (const token of tokens) {