Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions actions/setup/js/update_network_allowed.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// @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. "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.
*
* 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";
/** @typedef {{allowDomains?: string[]}} AWFNetworkConfig */
/** @typedef {Record<string, unknown> & {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<void>}
*/
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 {AWFConfig} */
let config;
try {
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
} catch (/** @type {unknown} */ err) {
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}: ${errMessage}\n`);
} else {
process.stderr.write(`Failed to read AWF config file at ${configPath}: ${errMessage}\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<string, string[]>} */
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);
}

// 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 = {};
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typeof [] === 'object' means the Array guard silently passes and corrupts the config.

💡 Suggested fix

The guard on line 74 is:

if (!config.network || typeof config.network !== 'object') {
  config.network = {};
}

typeof [] returns 'object' and ![] is false, so if the existing config file has "network": ["something"] (malformed but a realistic file corruption scenario), the guard passes and the code proceeds to assign allowDomains as a non-index property directly onto the array object. Nothing is written to stderr, AWF gets a config where network.allowDomains is an own-property of an array, and the result is silently wrong.

Add an Array check:

if (!config.network || typeof config.network !== 'object' || Array.isArray(config.network)) {
  config.network = {};
}

const network = toNetworkConfig(config.network);
if (!Array.isArray(network.allowDomains)) {
network.allowDomains = [];
}
const allowDomains = toStringArray(network.allowDomains);
const seen = new Set(allowDomains);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice use of a Set to dedupe domains efficiently — O(1) membership checks keep this clean.

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 errMessage = err instanceof Error ? err.message : String(err);
process.stderr.write(`Failed to write AWF config file at ${configPath}: ${errMessage}\n`);
process.exit(1);
}
}

module.exports = { main };

if (require.main === module) {
main().catch((/** @type {unknown} */ err) => {
const errMessage = err instanceof Error ? err.message : String(err);
process.stderr.write(`Error: ${errMessage}\n`);
process.exit(1);
});
}
181 changes: 181 additions & 0 deletions actions/setup/js/update_network_allowed.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// @ts-check
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] ESM import statement in a .cjs file — this conflicts with the file extension and may prevent tests from running.

Node.js treats .cjs files as CommonJS and refuses to parse import/export syntax. Vitest may transform the file, but import.meta.url on line 8 is ESM-only and unlikely to survive that transformation correctly.

💡 Suggested fix

Rename the test file to .test.mjs to match its ESM syntax, and update any test runner config to pick it up:

actions/setup/js/update_network_allowed.test.mjs

Alternatively, rewrite with CJS-style require() calls throughout (removing import.meta.url too). Either way, run node --input-type=commonjs < update_network_allowed.test.cjs to verify the file parses cleanly as CJS before merging.

import { createRequire } from "module";
import { tmpdir } from "os";
import { join } from "path";
import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync } 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<string, string | undefined>} */
let savedEnv;

beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "update-network-allowed-test-"));
const ghAwDir = join(tempDir, "gh-aw");
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two error paths reachable in the implementation have no test coverage.

💡 Suggested tests

The test for RUNNER_TEMP missing (which correctly uses vi.spyOn(process, 'exit')) shows the scaffolding is already in place. These two paths are just missing:

1. GH_AW_ECOSYSTEM_MAP_JSON is unset when tokens are present (lines 65-69 of impl):

it('exits 1 when GH_AW_ECOSYSTEM_MAP_JSON is not set', async () => {
  writeFileSync(configPath, JSON.stringify({ network: { allowDomains: [] } }) + '\n');
  process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = 'npm';
  delete process.env.GH_AW_ECOSYSTEM_MAP_JSON;

  const exitSpy = vi.spyOn(process, 'exit').mockImplementation(_code => {
    throw new Error('process.exit called');
  });
  try {
    await expect(main()).rejects.toThrow();
  } finally {
    exitSpy.mockRestore();
  }
});

2. GH_AW_ECOSYSTEM_MAP_JSON contains invalid JSON (line 72 of impl — currently unguarded):

it('exits 1 when GH_AW_ECOSYSTEM_MAP_JSON contains invalid JSON', async () => {
  writeFileSync(configPath, JSON.stringify({ network: { allowDomains: [] } }) + '\n');
  process.env.GH_AW_WORKFLOW_CALL_NETWORK_ALLOWED = 'npm';
  process.env.GH_AW_ECOSYSTEM_MAP_JSON = '{broken json';

  const exitSpy = vi.spyOn(process, 'exit').mockImplementation(_code => {
    throw new Error('process.exit called');
  });
  try {
    await expect(main()).rejects.toThrow();
  } finally {
    exitSpy.mockRestore();
  }
});

The second test will fail until the unguarded JSON.parse at line 72 is wrapped (see that comment).

// Compact JSON: no spaces after : or ,
expect(raw).not.toMatch(/: /);
expect(raw).not.toMatch(/, /);
});

it("exits 1 when RUNNER_TEMP is not set", async () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Missing error-path test: GH_AW_ECOSYSTEM_MAP_JSON is set but contains invalid JSON. The implementation correctly guards against a missing value, but a malformed JSON string will fall through to the unhandled top-level catch — and there is no test covering that path.

💡 Suggested test to add
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();
  } finally {
    exitSpy.mockRestore();
  }
});

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();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test verifies process.exit is called but does not assert which exit code is passed. A future regression that exits with code 0 on a missing RUNNER_TEMP would not be caught.

💡 Suggested assertion
const exitSpy = vi.spyOn(process, "exit").mockImplementation(_code => {
  throw new Error("process.exit called");
});
try {
  await expect(main()).rejects.toThrow();
  expect(exitSpy).toHaveBeenCalledWith(1);  // ← add this
} finally {
  exitSpy.mockRestore();
}

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();
}
});
});
5 changes: 4 additions & 1 deletion pkg/workflow/awf_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
43 changes: 6 additions & 37 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,43 +174,12 @@ func buildWorkflowCallNetworkAllowedUpdateScript() (string, error) {
return "", fmt.Errorf("marshal network allowed ecosystem map: %w", err)
}

return fmt.Sprintf(`python3 - <<'PY'
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(r'''%s''')
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), string(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 quote-injection risk:
// shellEscapeArg safely single-quotes and escapes the JSON payload.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call switching to shellEscapeArg + node — this removes the quote-injection surface from the prior Python heredoc.

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.
Expand Down
Loading