Compare commits

...
Author SHA1 Message Date
Varun Sharma 1188420976 Update non-TLS agent to v0.16.2 2026-07-03 05:17:38 -10:00
Varun Sharma 162cfeac17 Update non-TLS agent to v0.16.1 2026-07-02 05:18:39 -10:00
Varun Sharma eb9e1f4943 Bring macOS runner updates from PR 674 2026-07-01 07:08:34 -07:00
Varun Sharma 1a10b01783 Update Windows agent to v1.0.7 2026-07-01 06:57:34 -07:00
Varun Sharma 8b4a105ef5 Apply npm audit fixes with release-age cooldown 2026-06-29 21:28:45 -07:00
Varun Sharma 3626e03277 Default TLS status check failures to enabled 2026-06-29 21:17:58 -07:00
Varun Sharma 100e08b39c Update agent-ebpf to v1.8.12 2026-06-29 21:12:47 -07:00
Varun Sharma 774f75f2c6 Update agent to v1.8.9 2026-06-12 17:34:35 -07:00
Varun Sharma f312657a64 Extend missing-agent-dir guard to Linux and macOS cleanup paths
handleLinuxCleanup and handleMacosCleanup also did an unconditional writeFileSync
to a path inside the agent dir, throwing ENOENT when Pre-step crashed before
installing the agent (proven by integration test on synthetic-reject branch).
Mirror the same dir-existence check that handleWindowsCleanup already has.
2026-06-11 12:59:24 -07:00
Varun Sharma 0c7c518e1e Address PR review: skip last retry sleep, log error stacks, loosen test bound 2026-06-08 21:33:56 -07:00
Varun Sharma 49e6c282a3 Fix Pre-step hang on agent API timeout + guard against unhandled rejections
- Replace @actions/http-client socketTimeout with fetch + AbortSignal.timeout(3s)
  on monitor, tls-inspect, policy fetch, policy-store fetch, and addSummary,
  so DNS + TCP connect + TLS are bounded (was unbounded, causing ~2-4 min hangs)
- Add unhandledRejection guard in setup.ts and cleanup.ts so Node 22+ does not
  silently kill the step on background async errors from third-party deps
- Skip Windows post-step cleanup when agent dir is missing (Pre-step crashed
  before install), instead of throwing ENOENT on post_event.json
- Bump @types/node to ^24, typescript to ^5, ts-jest to ^29.4 to match node24
  runtime and enable AbortSignal.timeout typings
2026-06-08 21:13:29 -07:00
Varun Sharma 9af89fc715 Merge pull request #667 from step-security/update-agent-v1.8.6
Update agent to v1.8.6
2026-05-21 09:06:08 -07:00
Varun Sharma 485dce8cb5 Update agent to v1.8.6 2026-05-19 07:26:57 -07:00
Varun Sharma ab7a9404c0 Merge pull request #665 from step-security/fix/use-policy-store-default-audit
Default to audit mode when api-key missing with use-policy-store
2026-05-14 14:29:51 -07:00
Varun Sharma ec41b783c2 Default to audit mode when api-key missing with use-policy-store 2026-05-14 14:11:04 -07:00
Varun Sharma 9ca718d3bf Merge pull request #664 from step-security/update-agent-v1.8.5
Update agent to v1.8.5
2026-05-13 13:25:36 -07:00
Varun Sharma 1dee3df8d2 Update agent to v1.8.5 2026-05-12 10:53:30 -07:00
Varun Sharma a5ad31d6a1 Merge pull request #657 from devantler/fix/ubuntu-slim-user-env
fix: detect ubuntu-slim runners early and bail out
2026-05-01 23:21:23 -07:00
Varun Sharma 6e928567d7 build dist and trim ubuntu-slim message
Drop the parenthetical detail from UBUNTU_SLIM_MESSAGE so the user-facing
log is concise, and regenerate dist/ so the action can run from this
branch without a separate build step.
2026-05-01 22:50:08 -07:00
Nikolai Emil Damm 4e0504ee08 Merge branch 'main' into fix/ubuntu-slim-user-env 2026-04-25 17:29:39 +02:00
Nikolai Emil DammandCopilot 376d25a97f fix: detect ubuntu-slim runners early and bail out
ubuntu-slim runners (Hosted Compute Agent Docker containers) are
GitHub-hosted but lack the standard USER environment variable set
on full VM-based runners. This causes chownForFolder to fail with
'chown: invalid user: undefined'.

Instead of patching chownForFolder, detect ubuntu-slim early
informative message, matching the existing patterns for isDocker(),
isARCRunner(), and other unsupported runner types.

Fixes #627

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 12:16:00 +02:00
19 changed files with 1658 additions and 1316 deletions
+17 -17
View File
@@ -31919,6 +31919,8 @@ function detectThirdPartyRunnerProvider() {
return "depot";
if (process.env["NAMESPACE_GITHUB_RUNTIME"])
return "namespace";
if (process.env["BITRISE_IO"])
return "bitrise";
const runnerName = (_a = process.env["RUNNER_NAME"]) !== null && _a !== void 0 ? _a : "";
if (runnerName.startsWith("warp-"))
return "warp";
@@ -31980,8 +31982,8 @@ const processLogLine = (line, tableEntries) => {
}
};
function addSummary() {
var _a;
return __awaiter(this, void 0, void 0, function* () {
var _a;
if (process.env.STATE_addSummary !== "true") {
return;
}
@@ -32022,7 +32024,9 @@ function addSummary() {
// Fetch job summary from API
const apiUrl = `${STEPSECURITY_API_URL}/github/${owner}/${repo}/actions/runs/${run_id}/correlation/${correlation_id}/job-markdown-summary`;
try {
const response = yield fetch(apiUrl);
const response = yield fetch(apiUrl, {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
console.error(`Failed to fetch job summary: ${response.status} ${response.statusText}`);
return;
@@ -32046,6 +32050,7 @@ const HARDEN_RUNNER_UNAVAILABLE_MESSAGE = "Sorry, we are currently experiencing
const ARC_RUNNER_MESSAGE = "Workflow is currently being executed in ARC based runner.";
const ARM64_RUNNER_MESSAGE = "ARM runners are not supported in the Harden-Runner community tier.";
const ARM64_WINDOWS_RUNNER_MESSAGE = "Windows ARM runners are not yet supported by Harden-Runner.";
const UBUNTU_SLIM_MESSAGE = "This job is running on an ubuntu-slim runner. Harden Runner is not supported on ubuntu-slim runners. This job will not be monitored.";
;// CONCATENATED MODULE: external "node:fs"
const external_node_fs_namespaceObject = require("node:fs");
@@ -32086,8 +32091,6 @@ const configs_STEPSECURITY_API_URL = (/* unused pure expression or super */ null
const STEPSECURITY_TELEMETRY_URL = "https://prod.app-api.stepsecurity.io/v1";
const STEPSECURITY_WEB_URL = "https://app.stepsecurity.io";
// EXTERNAL MODULE: ./node_modules/@actions/http-client/lib/index.js
var lib = __nccwpck_require__(4844);
;// CONCATENATED MODULE: ./src/tls-inspect.ts
var tls_inspect_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
@@ -32100,28 +32103,25 @@ var tls_inspect_awaiter = (undefined && undefined.__awaiter) || function (thisAr
};
function isTLSEnabled(owner) {
return tls_inspect_awaiter(this, void 0, void 0, function* () {
let tlsStatusEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/actions/tls-inspection-status`;
let httpClient = new HttpClient();
httpClient.requestOptions = { socketTimeout: 3 * 1000 };
const tlsStatusEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/actions/tls-inspection-status`;
core.info(`[!] Checking TLS_STATUS: ${owner}`);
let isEnabled = false;
try {
let resp = yield httpClient.get(tlsStatusEndpoint);
if (resp.message.statusCode === 200) {
isEnabled = true;
const resp = yield fetch(tlsStatusEndpoint, {
signal: AbortSignal.timeout(3000),
});
if (resp.status === 200) {
core.info(`[!] TLS_ENABLED: ${owner}`);
return true;
}
else {
core.info(`[!] TLS_NOT_ENABLED: ${owner}`);
}
core.info(`[!] TLS_NOT_ENABLED: ${owner}`);
return false;
}
catch (e) {
core.info(`[!] Unable to check TLS_STATUS`);
core.info(`[!] Unable to check TLS_STATUS. Defaulting to TLS enabled.`);
return true;
}
return isEnabled;
});
}
function isGithubHosted() {
+1 -1
View File
File diff suppressed because one or more lines are too long
+49 -20
View File
@@ -31874,10 +31874,10 @@ var __webpack_exports__ = {};
(() => {
"use strict";
// EXTERNAL MODULE: external "fs"
var external_fs_ = __nccwpck_require__(9896);
// EXTERNAL MODULE: ./node_modules/@actions/core/lib/core.js
var lib_core = __nccwpck_require__(7484);
// EXTERNAL MODULE: external "fs"
var external_fs_ = __nccwpck_require__(9896);
;// CONCATENATED MODULE: ./src/configs.ts
const STEPSECURITY_ENV = "agent"; // agent or int
const configs_STEPSECURITY_API_URL = `https://${STEPSECURITY_ENV}.api.stepsecurity.io/v1`;
@@ -31925,6 +31925,8 @@ function detectThirdPartyRunnerProvider() {
return "depot";
if (process.env["NAMESPACE_GITHUB_RUNTIME"])
return "namespace";
if (process.env["BITRISE_IO"])
return "bitrise";
const runnerName = (_a = process.env["RUNNER_NAME"]) !== null && _a !== void 0 ? _a : "";
if (runnerName.startsWith("warp-"))
return "warp";
@@ -31986,8 +31988,8 @@ const processLogLine = (line, tableEntries) => {
}
};
function addSummary() {
var _a;
return __awaiter(this, void 0, void 0, function* () {
var _a;
if (process.env.STATE_addSummary !== "true") {
return;
}
@@ -32028,7 +32030,9 @@ function addSummary() {
// Fetch job summary from API
const apiUrl = `${configs_STEPSECURITY_API_URL}/github/${owner}/${repo}/actions/runs/${run_id}/correlation/${correlation_id}/job-markdown-summary`;
try {
const response = yield fetch(apiUrl);
const response = yield fetch(apiUrl, {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
console.error(`Failed to fetch job summary: ${response.status} ${response.statusText}`);
return;
@@ -32052,6 +32056,7 @@ const HARDEN_RUNNER_UNAVAILABLE_MESSAGE = "Sorry, we are currently experiencing
const ARC_RUNNER_MESSAGE = "Workflow is currently being executed in ARC based runner.";
const ARM64_RUNNER_MESSAGE = "ARM runners are not supported in the Harden-Runner community tier.";
const ARM64_WINDOWS_RUNNER_MESSAGE = "Windows ARM runners are not yet supported by Harden-Runner.";
const UBUNTU_SLIM_MESSAGE = "This job is running on an ubuntu-slim runner. Harden Runner is not supported on ubuntu-slim runners. This job will not be monitored.";
// EXTERNAL MODULE: external "path"
var external_path_ = __nccwpck_require__(6928);
@@ -32134,8 +32139,6 @@ function echo(content) {
cp.execFileSync("echo", [content]);
}
// EXTERNAL MODULE: ./node_modules/@actions/http-client/lib/index.js
var lib = __nccwpck_require__(4844);
;// CONCATENATED MODULE: ./src/tls-inspect.ts
var tls_inspect_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
@@ -32148,28 +32151,25 @@ var tls_inspect_awaiter = (undefined && undefined.__awaiter) || function (thisAr
};
function isTLSEnabled(owner) {
return tls_inspect_awaiter(this, void 0, void 0, function* () {
let tlsStatusEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/actions/tls-inspection-status`;
let httpClient = new HttpClient();
httpClient.requestOptions = { socketTimeout: 3 * 1000 };
const tlsStatusEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/actions/tls-inspection-status`;
core.info(`[!] Checking TLS_STATUS: ${owner}`);
let isEnabled = false;
try {
let resp = yield httpClient.get(tlsStatusEndpoint);
if (resp.message.statusCode === 200) {
isEnabled = true;
const resp = yield fetch(tlsStatusEndpoint, {
signal: AbortSignal.timeout(3000),
});
if (resp.status === 200) {
core.info(`[!] TLS_ENABLED: ${owner}`);
return true;
}
else {
core.info(`[!] TLS_NOT_ENABLED: ${owner}`);
}
core.info(`[!] TLS_NOT_ENABLED: ${owner}`);
return false;
}
catch (e) {
core.info(`[!] Unable to check TLS_STATUS`);
core.info(`[!] Unable to check TLS_STATUS. Defaulting to TLS enabled.`);
return true;
}
return isEnabled;
});
}
function isGithubHosted() {
@@ -32198,6 +32198,13 @@ var cleanup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _
// See setup.ts for rationale — Node 22+ kills the process on unhandled rejections.
process.on("unhandledRejection", (reason) => {
var _a;
const detail = reason instanceof Error ? ((_a = reason.stack) !== null && _a !== void 0 ? _a : reason.message) : String(reason);
lib_core.warning(`Unhandled promise rejection during Post-step: ${detail}`);
});
(() => cleanup_awaiter(void 0, void 0, void 0, function* () {
var _a, _b;
console.log("[harden-runner] post-step");
@@ -32214,6 +32221,10 @@ var cleanup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _
console.log(CONTAINER_MESSAGE);
return;
}
if (isGithubHosted() && process.platform === "linux" && !process.env.USER) {
console.log(UBUNTU_SLIM_MESSAGE);
return;
}
if (isARCRunner()) {
console.log(`[!] ${ARC_RUNNER_MESSAGE}`);
return;
@@ -32289,6 +32300,12 @@ function handleLinuxCleanup() {
if (process.env.STATE_isTLS === "false" && process.arch === "arm64") {
return;
}
// If Pre-step crashed before installing the agent, /home/agent doesn't exist;
// bail out instead of throwing ENOENT on the writeFileSync below.
if (!external_fs_.existsSync("/home/agent")) {
console.log("Linux cleanup: /home/agent not found; agent was not installed (Pre-step likely failed). Skipping.");
return;
}
if (isGithubHosted() && external_fs_.existsSync("/home/agent/post_event.json")) {
console.log("Post step already executed, skipping");
return;
@@ -32348,6 +32365,12 @@ function handleLinuxCleanup() {
function handleMacosCleanup() {
return cleanup_awaiter(this, void 0, void 0, function* () {
const post_event = "/opt/step-security/post_event.json";
// If Pre-step crashed before installing the agent, /opt/step-security doesn't
// exist; bail out instead of throwing ENOENT on the writeFileSync below.
if (!external_fs_.existsSync("/opt/step-security")) {
console.log("macOS cleanup: /opt/step-security not found; agent was not installed (Pre-step likely failed). Skipping.");
return;
}
if (isGithubHosted() && external_fs_.existsSync(post_event)) {
console.log("Post step already executed, skipping");
return;
@@ -32386,7 +32409,7 @@ function handleMacosCleanup() {
console.log("\nSystem log stream for io.stepsecurity.harden-runner:");
const logStreamOutput = external_child_process_.execSync("log show --predicate 'subsystem == \"io.stepsecurity.harden-runner\"' --info --last 10m", {
encoding: "utf8",
maxBuffer: 1024 * 1024 * 10,
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
timeout: 5000, // 5 seconds timeout
});
console.log(logStreamOutput);
@@ -32401,6 +32424,12 @@ function handleWindowsCleanup() {
// windows cleanup
const agentDir = process.env.STATE_agentDir || "C:\\agent";
const postEventFile = external_path_.join(agentDir, "post_event.json");
// If Pre-step crashed before installing the agent, agentDir doesn't exist;
// bail out instead of throwing ENOENT on the writeFileSync below.
if (!external_fs_.existsSync(agentDir)) {
console.log(`Windows cleanup: ${agentDir} not found; agent was not installed (Pre-step likely failed). Skipping.`);
return;
}
if (isGithubHosted() && external_fs_.existsSync(postEventFile)) {
console.log("Windows post step already executed, skipping");
return;
+1 -1
View File
File diff suppressed because one or more lines are too long
+237 -114
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+864 -810
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -37,7 +37,7 @@
},
"devDependencies": {
"@types/jest": "^27.5.2",
"@types/node": "^16.9.0",
"@types/node": "^24.13.1",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"@vercel/ncc": "^0.38.3",
@@ -46,8 +46,8 @@
"jest": "^29.3.1",
"jest-junit": ">=13.0.0",
"nock": "^13.3.0",
"ts-jest": "^29.0.3",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.1",
"typescript": "^4.3.5"
"typescript": "^5.9.3"
}
}
+7 -7
View File
@@ -4,19 +4,19 @@ import * as fs from "fs";
export const CHECKSUMS = {
tls: {
amd64: "713c91e921292027dacf446db44bafbc8e36a3f7f51dff664ba681c6e4398a05", // v1.8.2
arm64: "2c1eb365d6d9ae4cd4b6632a5f833bcdb7e75d0d9604de3391ff22e4e28e8d42",
amd64: "a54c4305b5665ba54bfdc46eb32aee1758699b994550ca3658d698367cc96a3f", // v1.8.12
arm64: "3f401d508e1427b9ba205681d5abecde3b4a0f0a18542d198b6dc14cadd93ab5", // v1.8.12
},
non_tls: {
amd64: "e38de61e1afd98dd339bb9acce4996183875d482be1638fb198ab02b3e25bbef", // v0.16.0
amd64: "4b14d8a3a5fbcef95af55e0c54d3bee6f44da802878c10289a4ca0b79b6d0237", // v0.16.2
},
bravo: {
amd64: "8d002af0c1c4bb73eaef0f2b641f7aa353cc3f4da36a4e418b69895a2baa922c", // v1.8.2
arm64: "1ce74a30d704c2e994246fc809d65af83e3f354aae7b9080b2c2eaee715cf005",
amd64: "c986d0a19637325c9a8d4a331a6c4ed047e4cddb798f56b641d0e66f8bf9b1b2", // v1.8.12
arm64: "5c3df17f82e317c8b288dfbbcee449c2a24a80deeeb3416dccabd0a61982c676", // v1.8.12
},
darwin: "fe26a1f6af4afe9f1a854d8633832f5d18ab542827003cae445b3a64021d612c", // v0.0.5
darwin: "2990f0390d2760fa6262a3830060b6db1233f16a1410ffe1ed2bf13dfda80c38", // v0.0.6
windows: {
amd64: "93f1e5d87c6647e6eca7963d5f4b4bd73107029430f8e6945ffece93007a89f5", // v1.0.2
amd64: "5e3604d08aba65d7bdd1d0684826d5894ffb0c6f56b914c6ecb35c3271e04483", // v1.0.7
},
};
+40
View File
@@ -1,3 +1,4 @@
import * as core from "@actions/core";
import * as fs from "fs";
import * as common from "./common";
import * as cp from "child_process";
@@ -8,6 +9,13 @@ import { isGithubHosted } from "./tls-inspect";
import { context } from "@actions/github";
import { isPlatformSupported, isAgentInstalled, detectThirdPartyRunnerProvider } from "./utils";
// See setup.ts for rationale — Node 22+ kills the process on unhandled rejections.
process.on("unhandledRejection", (reason) => {
const detail =
reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
core.warning(`Unhandled promise rejection during Post-step: ${detail}`);
});
(async () => {
console.log("[harden-runner] post-step");
@@ -26,6 +34,11 @@ import { isPlatformSupported, isAgentInstalled, detectThirdPartyRunnerProvider }
return;
}
if (isGithubHosted() && process.platform === "linux" && !process.env.USER) {
console.log(common.UBUNTU_SLIM_MESSAGE);
return;
}
if (isARCRunner()) {
console.log(`[!] ${common.ARC_RUNNER_MESSAGE}`);
return;
@@ -109,6 +122,15 @@ async function handleLinuxCleanup() {
return;
}
// If Pre-step crashed before installing the agent, /home/agent doesn't exist;
// bail out instead of throwing ENOENT on the writeFileSync below.
if (!fs.existsSync("/home/agent")) {
console.log(
"Linux cleanup: /home/agent not found; agent was not installed (Pre-step likely failed). Skipping."
);
return;
}
if (isGithubHosted() && fs.existsSync("/home/agent/post_event.json")) {
console.log("Post step already executed, skipping");
return;
@@ -180,6 +202,15 @@ async function handleLinuxCleanup() {
async function handleMacosCleanup() {
const post_event = "/opt/step-security/post_event.json";
// If Pre-step crashed before installing the agent, /opt/step-security doesn't
// exist; bail out instead of throwing ENOENT on the writeFileSync below.
if (!fs.existsSync("/opt/step-security")) {
console.log(
"macOS cleanup: /opt/step-security not found; agent was not installed (Pre-step likely failed). Skipping."
);
return;
}
if (isGithubHosted() && fs.existsSync(post_event)) {
console.log("Post step already executed, skipping");
return;
@@ -237,6 +268,15 @@ async function handleWindowsCleanup() {
const agentDir = process.env.STATE_agentDir || "C:\\agent";
const postEventFile = path.join(agentDir, "post_event.json");
// If Pre-step crashed before installing the agent, agentDir doesn't exist;
// bail out instead of throwing ENOENT on the writeFileSync below.
if (!fs.existsSync(agentDir)) {
console.log(
`Windows cleanup: ${agentDir} not found; agent was not installed (Pre-step likely failed). Skipping.`
);
return;
}
if (isGithubHosted() && fs.existsSync(postEventFile)) {
console.log("Windows post step already executed, skipping");
return;
+6 -1
View File
@@ -109,7 +109,9 @@ export async function addSummary() {
const apiUrl = `${STEPSECURITY_API_URL}/github/${owner}/${repo}/actions/runs/${run_id}/correlation/${correlation_id}/job-markdown-summary`;
try {
const response = await fetch(apiUrl);
const response = await fetch(apiUrl, {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
console.error(`Failed to fetch job summary: ${response.status} ${response.statusText}`);
return;
@@ -148,3 +150,6 @@ export const ARM64_RUNNER_MESSAGE =
export const ARM64_WINDOWS_RUNNER_MESSAGE =
"Windows ARM runners are not yet supported by Harden-Runner.";
export const UBUNTU_SLIM_MESSAGE =
"This job is running on an ubuntu-slim runner. Harden Runner is not supported on ubuntu-slim runners. This job will not be monitored.";
+5 -5
View File
@@ -26,7 +26,7 @@ export async function installAgent(
if (isTLS) {
downloadPath = await tc.downloadTool(
`https://github.com/step-security/agent-ebpf/releases/download/v1.8.2/harden-runner_1.8.2_linux_${variant}.tar.gz`,
`https://github.com/step-security/agent-ebpf/releases/download/v1.8.12/harden-runner_1.8.12_linux_${variant}.tar.gz`,
undefined,
auth
);
@@ -36,7 +36,7 @@ export async function installAgent(
return false;
}
downloadPath = await tc.downloadTool(
"https://github.com/step-security/agent/releases/download/v0.16.0/agent_0.16.0_linux_amd64.tar.gz",
"https://github.com/step-security/agent/releases/download/v0.16.2/agent_0.16.2_linux_amd64.tar.gz",
undefined,
auth
);
@@ -76,7 +76,7 @@ export async function installAgentBravo(configStr: string): Promise<boolean> {
const variant = process.arch === "x64" ? "amd64" : "arm64";
const downloadPath = await tc.downloadTool(
`https://github.com/step-security/agent-ebpf/releases/download/v1.8.2/harden-runner-bravo_1.8.2_linux_${variant}.tar.gz`,
`https://github.com/step-security/agent-ebpf/releases/download/v1.8.12/harden-runner-bravo_1.8.12_linux_${variant}.tar.gz`,
undefined,
auth
);
@@ -143,7 +143,7 @@ export async function installMacosAgent(configStr: string): Promise<boolean> {
// Download installer package
const downloadUrl =
"https://github.com/step-security/agent-releases/releases/download/v0.0.5-mac/macos-installer-0.0.5.tar.gz";
"https://github.com/step-security/agent-releases/releases/download/v0.0.6-mac/macos-installer-0.0.6.tar.gz";
core.info(`Downloading macOS installer.. : ${downloadUrl}`);
const downloadPath = await tc.downloadTool(downloadUrl, undefined, auth);
core.info(`✓ Successfully downloaded installer to: ${downloadPath}`);
@@ -226,7 +226,7 @@ export async function installWindowsAgent(configStr: string): Promise<boolean> {
const agentExePath = path.join(agentDir, "agent.exe");
const downloadPath = await tc.downloadTool(
`https://github.com/step-security/agent-releases/releases/download/v1.0.2-win/harden-runner-agent-windows_1.0.2_windows_amd64.tar.gz`,
`https://github.com/step-security/agent-releases/releases/download/v1.0.7-win/harden-runner-agent-windows_1.0.7_windows_amd64.tar.gz`,
undefined,
auth
);
+231 -231
View File
@@ -1,12 +1,46 @@
import nock from "nock";
import { fetchPolicy, mergeConfigs } from "./policy-utils";
import { fetchPolicy, fetchPolicyFromStore, mergeConfigs } from "./policy-utils";
import { Configuration, PolicyResponse } from "./interfaces";
import { STEPSECURITY_API_URL } from "./configs";
const ORIGINAL_FETCH = globalThis.fetch;
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
});
type FetchImpl = (
input: RequestInfo | URL,
init?: RequestInit
) => Promise<Response>;
function mockFetch(impl: FetchImpl) {
globalThis.fetch = impl as typeof fetch;
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
// Mock fetch with a sequence of responses or errors. Each call consumes one entry.
function mockFetchSequence(entries: Array<Response | Error>) {
let i = 0;
mockFetch(async () => {
const entry = entries[i++];
if (!entry) throw new Error("fetch called more times than expected");
if (entry instanceof Error) throw entry;
return entry;
});
}
// ==================== fetchPolicy ====================
test("success: fetching policy", async () => {
let owner = "h0x0er";
let policyName = "policy1";
let response = {
const owner = "h0x0er";
const policyName = "policy1";
const response = {
owner: "h0x0er",
policyName: "policy1",
allowed_endpoints: ["github.com:443"],
@@ -15,16 +49,196 @@ test("success: fetching policy", async () => {
disable_sudo: false,
disable_file_monitoring: false,
};
const policyScope = nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/actions/policies/${policyName}`)
.reply(200, response);
let idToken = "xyz";
let policy = await fetchPolicy(owner, policyName, idToken);
console.log(policy);
expect(policy).toStrictEqual(response);
const expectedUrl = `${STEPSECURITY_API_URL}/github/${owner}/actions/policies/${policyName}`;
mockFetch(async (url, init) => {
expect(String(url)).toBe(expectedUrl);
expect((init?.headers as Record<string, string>)["Authorization"]).toBe("Bearer xyz");
expect((init?.headers as Record<string, string>)["Source"]).toBe("github-actions");
return jsonResponse(200, response);
});
const policy = await fetchPolicy(owner, policyName, "xyz");
expect(policy).toEqual(response);
});
test("fetchPolicy throws when idToken is empty", async () => {
await expect(fetchPolicy("owner", "policy1", "")).rejects.toThrow(
"[PolicyFetch]: id-token in empty"
);
});
test("fetchPolicy retries on failure and succeeds", async () => {
const response = {
allowed_endpoints: ["example.com:443"],
egress_policy: "block",
};
mockFetchSequence([
new TypeError("fetch failed"),
jsonResponse(200, response),
]);
const policy = await fetchPolicy("test-owner", "test-policy", "token123");
expect(policy).toEqual(response);
});
test("fetchPolicy throws after all retries exhausted", async () => {
mockFetchSequence([
new TypeError("fetch failed"),
new TypeError("fetch failed"),
new TypeError("fetch failed"),
]);
await expect(
fetchPolicy("test-owner", "test-policy", "token123")
).rejects.toThrow("[Policy Fetch]");
});
test("fetchPolicy preserves statusCode from error", async () => {
// server returns 404 on every retry; the HttpStatusError raised internally
// carries statusCode=404 which the outer error should expose.
mockFetchSequence([
jsonResponse(404, { message: "not found" }),
jsonResponse(404, { message: "not found" }),
jsonResponse(404, { message: "not found" }),
]);
try {
await fetchPolicy("test-owner", "test-policy", "token123");
fail("should have thrown");
} catch (err: any) {
expect(err.message).toContain("[Policy Fetch]");
expect(err.statusCode).toBe(404);
}
});
// ==================== fetchPolicyFromStore ====================
test("success: fetches policy from store", async () => {
const owner = "test-owner";
const repo = "test-repo";
const workflow = "ci.yml";
const runId = "12345";
const correlationId = "abc-def";
const response = {
allowed_endpoints: ["registry.npmjs.org:443", "github.com:443"],
egress_policy: "block",
disable_sudo: true,
disable_file_monitoring: false,
};
mockFetch(async () => jsonResponse(200, response));
const result = await fetchPolicyFromStore(owner, repo, "my-api-key", workflow, runId, correlationId);
expect(result).toEqual(response);
});
test("fetchPolicyFromStore throws when apiKey is empty", async () => {
await expect(
fetchPolicyFromStore("owner", "repo", "", "ci.yml", "123", "abc")
).rejects.toThrow("[PolicyStoreFetch]: api-key is empty");
});
test("fetchPolicyFromStore returns null when policy not found (404)", async () => {
mockFetch(async () => jsonResponse(404, { message: "not found" }));
const result = await fetchPolicyFromStore("test-owner", "test-repo", "my-api-key", "ci.yml", "12345", "abc-def");
expect(result).toBeNull();
});
test("fetchPolicyFromStore returns null when API returns empty policy", async () => {
mockFetch(async () => jsonResponse(200, { allowed_endpoints: [], egress_policy: "", policy_name: "" }));
const result = await fetchPolicyFromStore("test-owner", "nonexistent-repo", "my-api-key", "ci.yml", "12345", "abc-def");
expect(result).toBeNull();
});
test("fetchPolicyFromStore retries on failure and succeeds", async () => {
const response = {
allowed_endpoints: ["example.com:443"],
egress_policy: "audit",
};
mockFetchSequence([
new TypeError("fetch failed"),
jsonResponse(200, response),
]);
const result = await fetchPolicyFromStore("test-owner", "test-repo", "my-api-key", "ci.yml", "12345", "abc-def");
expect(result).toEqual(response);
});
test("fetchPolicyFromStore throws after all retries exhausted", async () => {
mockFetchSequence([
new TypeError("fetch failed"),
new TypeError("fetch failed"),
new TypeError("fetch failed"),
]);
await expect(
fetchPolicyFromStore("test-owner", "test-repo", "my-api-key", "ci.yml", "12345", "abc-def")
).rejects.toThrow("[Policy Store Fetch]");
});
test("fetchPolicyFromStore preserves statusCode from error", async () => {
mockFetchSequence([
jsonResponse(401, { message: "Unauthorized" }),
jsonResponse(401, { message: "Unauthorized" }),
jsonResponse(401, { message: "Unauthorized" }),
]);
try {
await fetchPolicyFromStore("test-owner", "test-repo", "my-api-key", "ci.yml", "12345", "abc-def");
fail("should have thrown");
} catch (err: any) {
expect(err.message).toContain("[Policy Store Fetch]");
expect(err.statusCode).toBe(401);
}
});
test("fetchPolicyFromStore sends correct authorization header", async () => {
const apiKey = "secret-key-123";
mockFetch(async (_url, init) => {
const headers = init?.headers as Record<string, string>;
expect(headers["Authorization"]).toBe(`vm-api-key ${apiKey}`);
expect(headers["Source"]).toBe("github-actions");
return jsonResponse(200, { allowed_endpoints: [], egress_policy: "audit" });
});
const result = await fetchPolicyFromStore("test-owner", "test-repo", apiKey, "ci.yml", "12345", "abc-def");
expect(result).toEqual({ allowed_endpoints: [], egress_policy: "audit" });
});
test("fetchPolicyFromStore returns within ~3s when server is slow (regression test for AggregateError)", async () => {
mockFetch((_url, init) => {
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (signal) {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
signal.addEventListener("abort", () =>
reject(new DOMException("Aborted", "AbortError"))
);
}
});
});
const start = Date.now();
await expect(
fetchPolicyFromStore("test-owner", "test-repo", "my-api-key", "ci.yml", "12345", "abc-def")
).rejects.toThrow("[Policy Store Fetch]");
const elapsed = Date.now() - start;
// 3 retries × (3s timeout + 1s sleep), but last sleep is unnecessary.
// Bounded by 3 * 3s + 2 * 1s = 11s. Test passes if it doesn't hang for minutes.
expect(elapsed).toBeLessThan(13_000);
}, 20_000);
// ==================== mergeConfigs ====================
test("merge configs", async () => {
let localConfig: Configuration = {
repo: "test/repo",
@@ -47,7 +261,7 @@ test("merge configs", async () => {
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
let policyResponse: PolicyResponse = {
const policyResponse: PolicyResponse = {
owner: "h0x0er",
policyName: "policy1",
allowed_endpoints: ["github.com:443", "google.com:443"],
@@ -56,8 +270,7 @@ test("merge configs", async () => {
disable_sudo: false,
disable_file_monitoring: false,
};
let expectedConfiguration: Configuration = {
const expectedConfiguration: Configuration = {
repo: "test/repo",
run_id: "xyx",
correlation_id: "aaaaa",
@@ -83,219 +296,6 @@ test("merge configs", async () => {
expect(localConfig).toStrictEqual(expectedConfiguration);
});
// ==================== additional fetchPolicy tests ====================
test("fetchPolicy throws when idToken is empty", async () => {
await expect(fetchPolicy("owner", "policy1", "")).rejects.toThrow(
"[PolicyFetch]: id-token in empty"
);
});
test("fetchPolicy retries on failure and succeeds", async () => {
const owner = "test-owner";
const policyName = "test-policy";
const response = {
allowed_endpoints: ["example.com:443"],
egress_policy: "block",
};
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/actions/policies/${policyName}`)
.replyWithError("connection timeout");
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/actions/policies/${policyName}`)
.reply(200, response);
const policy = await fetchPolicy(owner, policyName, "token123");
expect(policy).toStrictEqual(response);
});
test("fetchPolicy throws after all retries exhausted", async () => {
const owner = "test-owner";
const policyName = "test-policy";
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/actions/policies/${policyName}`)
.times(3)
.replyWithError("connection timeout");
await expect(
fetchPolicy(owner, policyName, "token123")
).rejects.toThrow("[Policy Fetch]");
});
test("fetchPolicy preserves statusCode from error", async () => {
const owner = "test-owner";
const policyName = "test-policy";
const errorWithStatus = new Error("Not Found");
(errorWithStatus as any).statusCode = 404;
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/actions/policies/${policyName}`)
.times(3)
.replyWithError(errorWithStatus);
try {
await fetchPolicy(owner, policyName, "token123");
fail("should have thrown");
} catch (err) {
expect(err.message).toContain("[Policy Fetch]");
}
});
// ==================== fetchPolicyFromStore ====================
import { fetchPolicyFromStore } from "./policy-utils";
const policyStoreQueryString = (workflow: string, runId: string, correlationId: string) =>
`workflow=${encodeURIComponent(workflow)}&run_id=${encodeURIComponent(runId)}&correlationId=${encodeURIComponent(correlationId)}`;
test("success: fetches policy from store", async () => {
const owner = "test-owner";
const repo = "test-repo";
const workflow = "ci.yml";
const runId = "12345";
const correlationId = "abc-def";
const response = {
allowed_endpoints: ["registry.npmjs.org:443", "github.com:443"],
egress_policy: "block",
disable_sudo: true,
disable_file_monitoring: false,
};
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/${repo}/actions/policies/workflow-policy?${policyStoreQueryString(workflow, runId, correlationId)}`)
.reply(200, response);
const result = await fetchPolicyFromStore(owner, repo, "my-api-key", workflow, runId, correlationId);
expect(result).toStrictEqual(response);
});
test("fetchPolicyFromStore throws when apiKey is empty", async () => {
await expect(
fetchPolicyFromStore("owner", "repo", "", "ci.yml", "123", "abc")
).rejects.toThrow("[PolicyStoreFetch]: api-key is empty");
});
test("fetchPolicyFromStore returns null when policy not found (404)", async () => {
const owner = "test-owner";
const repo = "test-repo";
const workflow = "ci.yml";
const runId = "12345";
const correlationId = "abc-def";
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/${repo}/actions/policies/workflow-policy?${policyStoreQueryString(workflow, runId, correlationId)}`)
.reply(404, { message: "not found" });
const result = await fetchPolicyFromStore(owner, repo, "my-api-key", workflow, runId, correlationId);
expect(result).toBeNull();
});
test("fetchPolicyFromStore returns null when API returns empty policy", async () => {
const owner = "test-owner";
const repo = "nonexistent-repo";
const workflow = "ci.yml";
const runId = "12345";
const correlationId = "abc-def";
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/${repo}/actions/policies/workflow-policy?${policyStoreQueryString(workflow, runId, correlationId)}`)
.reply(200, { allowed_endpoints: [], egress_policy: "", policy_name: "" });
const result = await fetchPolicyFromStore(owner, repo, "my-api-key", workflow, runId, correlationId);
expect(result).toBeNull();
});
test("fetchPolicyFromStore retries on failure and succeeds", async () => {
const owner = "test-owner";
const repo = "test-repo";
const workflow = "ci.yml";
const runId = "12345";
const correlationId = "abc-def";
const response = {
allowed_endpoints: ["example.com:443"],
egress_policy: "audit",
};
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/${repo}/actions/policies/workflow-policy?${policyStoreQueryString(workflow, runId, correlationId)}`)
.replyWithError("timeout");
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/${repo}/actions/policies/workflow-policy?${policyStoreQueryString(workflow, runId, correlationId)}`)
.reply(200, response);
const result = await fetchPolicyFromStore(owner, repo, "my-api-key", workflow, runId, correlationId);
expect(result).toStrictEqual(response);
});
test("fetchPolicyFromStore throws after all retries exhausted", async () => {
const owner = "test-owner";
const repo = "test-repo";
const workflow = "ci.yml";
const runId = "12345";
const correlationId = "abc-def";
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/${repo}/actions/policies/workflow-policy?${policyStoreQueryString(workflow, runId, correlationId)}`)
.times(3)
.replyWithError("connection refused");
await expect(
fetchPolicyFromStore(owner, repo, "my-api-key", workflow, runId, correlationId)
).rejects.toThrow("[Policy Store Fetch]");
});
test("fetchPolicyFromStore preserves statusCode from error", async () => {
const owner = "test-owner";
const repo = "test-repo";
const workflow = "ci.yml";
const runId = "12345";
const correlationId = "abc-def";
const errorWithStatus = new Error("Unauthorized");
(errorWithStatus as any).statusCode = 401;
nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/${repo}/actions/policies/workflow-policy?${policyStoreQueryString(workflow, runId, correlationId)}`)
.times(3)
.replyWithError(errorWithStatus);
try {
await fetchPolicyFromStore(owner, repo, "my-api-key", workflow, runId, correlationId);
fail("should have thrown");
} catch (err) {
expect(err.message).toContain("[Policy Store Fetch]");
}
});
test("fetchPolicyFromStore sends correct authorization header", async () => {
const owner = "test-owner";
const repo = "test-repo";
const apiKey = "secret-key-123";
const workflow = "ci.yml";
const runId = "12345";
const correlationId = "abc-def";
nock(`${STEPSECURITY_API_URL}`, {
reqheaders: {
Authorization: `vm-api-key ${apiKey}`,
Source: "github-actions",
},
})
.get(`/github/${owner}/${repo}/actions/policies/workflow-policy?${policyStoreQueryString(workflow, runId, correlationId)}`)
.reply(200, { allowed_endpoints: [], egress_policy: "audit" });
const result = await fetchPolicyFromStore(owner, repo, apiKey, workflow, runId, correlationId);
expect(result).toStrictEqual({
allowed_endpoints: [],
egress_policy: "audit",
});
});
// ==================== additional mergeConfigs tests ====================
test("mergeConfigs does not override local allowed_endpoints if not empty", () => {
let localConfig: Configuration = {
repo: "test/repo",
@@ -318,7 +318,7 @@ test("mergeConfigs does not override local allowed_endpoints if not empty", () =
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
let policyResponse: PolicyResponse = {
const policyResponse: PolicyResponse = {
allowed_endpoints: ["remote.endpoint:443"],
egress_policy: "block",
};
@@ -350,7 +350,7 @@ test("mergeConfigs overrides disable_sudo_and_containers from remote", () => {
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
let policyResponse: PolicyResponse = {
const policyResponse: PolicyResponse = {
allowed_endpoints: [],
disable_sudo_and_containers: true,
};
@@ -381,7 +381,7 @@ test("mergeConfigs does not override fields when remote values are undefined", (
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
let policyResponse: PolicyResponse = {
const policyResponse: PolicyResponse = {
allowed_endpoints: [],
};
+54 -47
View File
@@ -1,7 +1,14 @@
import { HttpClient } from "@actions/http-client";
import { PolicyResponse, Configuration } from "./interfaces";
import { STEPSECURITY_API_URL } from "./configs";
class HttpStatusError extends Error {
statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
}
export async function fetchPolicy(
owner: string,
policyName: string,
@@ -11,43 +18,35 @@ export async function fetchPolicy(
throw new Error("[PolicyFetch]: id-token in empty");
}
let policyEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/actions/policies/${policyName}`;
const policyEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/actions/policies/${policyName}`;
let httpClient = new HttpClient();
const headers = {
Authorization: `Bearer ${idToken}`,
Source: "github-actions",
};
let headers = {};
headers["Authorization"] = `Bearer ${idToken}`;
headers["Source"] = "github-actions";
let result: PolicyResponse | undefined;
let err: unknown;
let response = undefined;
let err = undefined;
let retry = 0;
while (retry < 3) {
for (let retry = 0; retry < 3; retry++) {
try {
console.log(`Attempt: ${retry + 1}`);
response = await httpClient.getJson<PolicyResponse>(
policyEndpoint,
headers
);
result = await getJsonWithTimeout<PolicyResponse>(policyEndpoint, headers);
break;
} catch (e) {
err = e;
if (retry < 2) await sleep(1000);
}
retry += 1;
await sleep(1000);
}
if (response === undefined && err !== undefined) {
// Preserve the original error's statusCode if it exists
if (result === undefined) {
const error = new Error(`[Policy Fetch] ${err}`);
if (err.statusCode !== undefined) {
(error as any).statusCode = err.statusCode;
if (err && typeof err === "object" && "statusCode" in err) {
(error as any).statusCode = (err as { statusCode: unknown }).statusCode;
}
throw error;
} else {
return response.result;
}
return result;
}
export async function fetchPolicyFromStore(
@@ -62,46 +61,39 @@ export async function fetchPolicyFromStore(
throw new Error("[PolicyStoreFetch]: api-key is empty");
}
let policyEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/${repo}/actions/policies/workflow-policy?workflow=${encodeURIComponent(workflow)}&run_id=${encodeURIComponent(runId)}&correlationId=${encodeURIComponent(correlationId)}`;
const policyEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/${repo}/actions/policies/workflow-policy?workflow=${encodeURIComponent(workflow)}&run_id=${encodeURIComponent(runId)}&correlationId=${encodeURIComponent(correlationId)}`;
let httpClient = new HttpClient();
const headers = {
Authorization: `vm-api-key ${apiKey}`,
Source: "github-actions",
};
let headers = {};
headers["Authorization"] = `vm-api-key ${apiKey}`;
headers["Source"] = "github-actions";
let result: PolicyResponse | undefined;
let err: unknown;
let response = undefined;
let err = undefined;
let retry = 0;
while (retry < 3) {
for (let retry = 0; retry < 3; retry++) {
try {
console.log(`Attempt: ${retry + 1}`);
response = await httpClient.getJson<PolicyResponse>(
policyEndpoint,
headers
);
result = await getJsonWithTimeout<PolicyResponse>(policyEndpoint, headers);
break;
} catch (e) {
// 404 means policy not found — don't retry, return null
if (e instanceof HttpStatusError && e.statusCode === 404) {
return null;
}
err = e;
if (retry < 2) await sleep(1000);
}
retry += 1;
await sleep(1000);
}
if (response === undefined && err !== undefined) {
if (result === undefined) {
const error = new Error(`[Policy Store Fetch] ${err}`);
if (err.statusCode !== undefined) {
(error as any).statusCode = err.statusCode;
if (err && typeof err === "object" && "statusCode" in err) {
(error as any).statusCode = (err as { statusCode: unknown }).statusCode;
}
throw error;
}
if (response.statusCode === 404) {
return null;
}
const result = response.result;
if (!result || (!result.egress_policy && (!result.allowed_endpoints || result.allowed_endpoints.length === 0))) {
return null;
}
@@ -109,6 +101,21 @@ export async function fetchPolicyFromStore(
return result;
}
async function getJsonWithTimeout<T>(
url: string,
headers: Record<string, string>
): Promise<T> {
const resp = await fetch(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
});
if (!resp.ok) {
throw new HttpStatusError(resp.status, `HTTP ${resp.status}`);
}
return (await resp.json()) as T;
}
export function mergeConfigs(
localConfig: Configuration,
remoteConfig: PolicyResponse
+60 -28
View File
@@ -1,7 +1,6 @@
import * as core from "@actions/core";
import * as cp from "child_process";
import * as fs from "fs";
import * as httpm from "@actions/http-client";
import * as path from "path";
import { v4 as uuidv4 } from "uuid";
import * as common from "./common";
@@ -47,6 +46,17 @@ interface MonitorResponse {
monitoring_started?: boolean;
}
// Node 22+ terminates the process on unhandled promise rejections by default.
// Third-party libraries used during Pre-step (notably @actions/cache's tar +
// upload streams under concurrent matrix runs) can emit background rejections
// that escape our try/catch, killing Pre-step silently and leaving the runner
// without an agent installed. Log and continue instead.
process.on("unhandledRejection", (reason) => {
const detail =
reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
core.warning(`Unhandled promise rejection during Pre-step: ${detail}`);
});
(async () => {
try {
console.log("[harden-runner] pre-step");
@@ -66,6 +76,11 @@ interface MonitorResponse {
return;
}
if (isGithubHosted() && process.platform === "linux" && !process.env.USER) {
console.log(common.UBUNTU_SLIM_MESSAGE);
return;
}
var correlation_id = uuidv4();
var api_url = STEPSECURITY_API_URL;
var web_url = STEPSECURITY_WEB_URL;
@@ -102,7 +117,10 @@ interface MonitorResponse {
if (confg.use_policy_store) {
console.log(`Fetching policy from policy store`);
if (confg.api_key === "") {
core.setFailed("api-key is required when use-policy-store is set to true");
core.warning(
"api-key is not set while use-policy-store is true. Defaulting to audit mode."
);
confg.egress_policy = "audit";
} else {
try {
const repoName = (process.env["GITHUB_REPOSITORY"] || "").split("/")[1] || "";
@@ -294,15 +312,26 @@ interface MonitorResponse {
const thirdPartyProvider = detectThirdPartyRunnerProvider();
if (thirdPartyProvider) {
const providerLabel = thirdPartyProvider.charAt(0).toUpperCase() + thirdPartyProvider.slice(1);
if (process.platform !== "linux") {
core.info(`Detected ${providerLabel} runner on ${process.platform}. Bravo agent is Linux-only, skipping install.`);
if (process.platform !== "linux" && process.platform !== "darwin") {
core.info(`Detected ${providerLabel} runner on ${process.platform}. HardenRunner is not supported on this third-party provider, skipping install.`);
return;
}
core.info(`Detected ${providerLabel} runner environment. Installing agent-bravo.`);
confg.correlation_id = runnerName || confg.correlation_id;
await callMonitorEndpoint(api_url, confg);
await installAgentForBravo(context.repo.owner, confg);
return;
const bravoConfigStr = JSON.stringify(buildBravoConfig(confg));
switch (process.platform) {
case "darwin": {
const installed = await installMacosAgent(bravoConfigStr);
if (!installed) {
core.warning("macos bravo agent installation failed");
}
return;
}
case "linux":
await installAgentForBravo(context.repo.owner, bravoConfigStr);
return;
}
}
fs.appendFileSync(process.env.GITHUB_STATE, `selfHosted=true${EOL}`, {
@@ -356,22 +385,25 @@ interface MonitorResponse {
return;
}
let _http = new httpm.HttpClient();
let statusCode: number | undefined;
_http.requestOptions = { socketTimeout: 3 * 1000 };
let addSummary = "false";
try {
const monitorRequestData = {
correlation_id: correlation_id,
job: process.env["GITHUB_JOB"],
};
const resp = await _http.postJson<MonitorResponse>(
`${api_url}/github/${process.env["GITHUB_REPOSITORY"]}/actions/runs/${process.env["GITHUB_RUN_ID"]}/monitor`,
monitorRequestData
);
const url = `${api_url}/github/${process.env["GITHUB_REPOSITORY"]}/actions/runs/${process.env["GITHUB_RUN_ID"]}/monitor`;
const resp = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(monitorRequestData),
signal: AbortSignal.timeout(3000),
});
const responseData = resp.result;
statusCode = resp.statusCode; // adding error code to check whether agent is getting installed or not.
statusCode = resp.status;
const responseData = resp.ok
? ((await resp.json()) as MonitorResponse)
: undefined;
fs.appendFileSync(
process.env.GITHUB_STATE,
`monitorStatusCode=${statusCode}${EOL}`,
@@ -487,8 +519,6 @@ export function sleep(ms: number) {
}
async function callMonitorEndpoint(api_url: string, confg: Configuration) {
const _http = new httpm.HttpClient();
_http.requestOptions = { socketTimeout: 3 * 1000 };
let statusCode: number | undefined;
let addSummary = "false";
try {
@@ -496,15 +526,19 @@ async function callMonitorEndpoint(api_url: string, confg: Configuration) {
correlation_id: confg.correlation_id,
job: process.env["GITHUB_JOB"],
};
const resp = await _http.postJson<MonitorResponse>(
`${api_url}/github/${process.env["GITHUB_REPOSITORY"]}/actions/runs/${process.env["GITHUB_RUN_ID"]}/monitor`,
monitorRequestData
);
statusCode = resp.statusCode;
if (resp.statusCode === 200 && resp.result) {
console.log(`Runner IP Address: ${resp.result.runner_ip_address}`);
confg.one_time_key = resp.result.one_time_key;
addSummary = resp.result.monitoring_started ? "true" : "false";
const url = `${api_url}/github/${process.env["GITHUB_REPOSITORY"]}/actions/runs/${process.env["GITHUB_RUN_ID"]}/monitor`;
const resp = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(monitorRequestData),
signal: AbortSignal.timeout(3000),
});
statusCode = resp.status;
if (resp.ok) {
const result = (await resp.json()) as MonitorResponse;
console.log(`Runner IP Address: ${result.runner_ip_address}`);
confg.one_time_key = result.one_time_key;
addSummary = result.monitoring_started ? "true" : "false";
}
} catch (e) {
console.log(`error in connecting to ${api_url}: ${e}`);
@@ -573,7 +607,7 @@ export async function installAgentForSelfHosted(owner: string, confg: Configurat
}
}
export async function installAgentForBravo(owner: string, confg: Configuration) {
export async function installAgentForBravo(owner: string, bravoConfigStr: string) {
try {
console.log("Installing Harden Runner bravo agent for third-party runner");
@@ -584,8 +618,6 @@ export async function installAgentForBravo(owner: string, confg: Configuration)
return;
}
const bravoConfigStr = JSON.stringify(buildBravoConfig(confg));
cp.execSync("sudo mkdir -p /home/agent");
chownForFolder(process.env.USER, "/home/agent");
+64 -16
View File
@@ -1,29 +1,77 @@
import nock from "nock";
import { STEPSECURITY_API_URL } from "./configs";
import { isTLSEnabled } from "./tls-inspect";
const ORIGINAL_FETCH = globalThis.fetch;
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
});
function mockFetch(impl: typeof fetch) {
globalThis.fetch = impl as typeof fetch;
}
test("tls-inspect enabled", async () => {
let owner = "h0x0er";
let expected = true;
const owner = "h0x0er";
const expectedUrl = `${STEPSECURITY_API_URL}/github/${owner}/actions/tls-inspection-status`;
const resp = nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/actions/tls-inspection-status`)
.reply(200, "");
mockFetch(async (url, _init) => {
expect(String(url)).toBe(expectedUrl);
return new Response("", { status: 200 });
});
let got = await isTLSEnabled(owner);
expect(got).toEqual(expected);
const got = await isTLSEnabled(owner);
expect(got).toBe(true);
});
test("tls-inspect not enabled", async () => {
let owner = "step-security";
let expected = false;
const owner = "step-security";
const resp = nock(`${STEPSECURITY_API_URL}`)
.get(`/github/${owner}/actions/tls-inspection-status`)
.reply(401, "");
mockFetch(async () => new Response("unauthorized", { status: 401 }));
let got = await isTLSEnabled(owner);
const got = await isTLSEnabled(owner);
expect(got).toBe(false);
});
expect(got).toEqual(expected);
test("isTLSEnabled returns true within ~3s when server is slow (regression test for AggregateError)", async () => {
const owner = "slow-org";
mockFetch((_url, init) => {
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (signal) {
if (signal.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
signal.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
}
});
});
const start = Date.now();
const result = await isTLSEnabled(owner);
const elapsed = Date.now() - start;
expect(result).toBe(true);
expect(elapsed).toBeLessThan(3500);
}, 10_000);
test("isTLSEnabled returns true on connection error without hanging", async () => {
const owner = "broken-org";
mockFetch(async () => {
const err = new TypeError("fetch failed");
(err as Error & { cause?: unknown }).cause = Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" });
throw err;
});
const start = Date.now();
const result = await isTLSEnabled(owner);
const elapsed = Date.now() - start;
expect(result).toBe(true);
expect(elapsed).toBeLessThan(3500);
});
+10 -13
View File
@@ -1,26 +1,23 @@
import { HttpClient } from "@actions/http-client";
import { STEPSECURITY_API_URL } from "./configs";
import * as core from "@actions/core";
export async function isTLSEnabled(owner: string): Promise<boolean> {
let tlsStatusEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/actions/tls-inspection-status`;
let httpClient = new HttpClient();
httpClient.requestOptions = { socketTimeout: 3 * 1000 };
const tlsStatusEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/actions/tls-inspection-status`;
core.info(`[!] Checking TLS_STATUS: ${owner}`);
let isEnabled = false;
try {
let resp = await httpClient.get(tlsStatusEndpoint);
if (resp.message.statusCode === 200) {
isEnabled = true;
const resp = await fetch(tlsStatusEndpoint, {
signal: AbortSignal.timeout(3000),
});
if (resp.status === 200) {
core.info(`[!] TLS_ENABLED: ${owner}`);
} else {
core.info(`[!] TLS_NOT_ENABLED: ${owner}`);
return true;
}
core.info(`[!] TLS_NOT_ENABLED: ${owner}`);
return false;
} catch (e) {
core.info(`[!] Unable to check TLS_STATUS`);
core.info(`[!] Unable to check TLS_STATUS. Defaulting to TLS enabled.`);
return true;
}
return isEnabled;
}
export function isGithubHosted() {
+6
View File
@@ -98,6 +98,7 @@ describe("detectThirdPartyRunnerProvider", () => {
process.env = { ...originalEnv };
delete process.env.DEPOT_RUNNER;
delete process.env.NAMESPACE_GITHUB_RUNTIME;
delete process.env.BITRISE_IO;
delete process.env.RUNNER_NAME;
});
@@ -120,6 +121,11 @@ describe("detectThirdPartyRunnerProvider", () => {
expect(detectThirdPartyRunnerProvider()).toBe("namespace");
});
test("returns bitrise when BITRISE_IO is set", () => {
process.env.BITRISE_IO = "true";
expect(detectThirdPartyRunnerProvider()).toBe("bitrise");
});
test("returns warp for RUNNER_NAME prefix warp-", () => {
process.env.RUNNER_NAME = "warp-4x-x64-abc";
expect(detectThirdPartyRunnerProvider()).toBe("warp");
+2 -1
View File
@@ -40,11 +40,12 @@ export function shouldDeployAgentOnSelfHosted(
return deployOnSelfHostedVm && !isContainer && !agentAlreadyInstalled;
}
export type ThirdPartyRunnerProvider = "depot" | "namespace" | "warp" | "blacksmith";
export type ThirdPartyRunnerProvider = "depot" | "namespace" | "warp" | "blacksmith" | "bitrise";
export function detectThirdPartyRunnerProvider(): ThirdPartyRunnerProvider | null {
if (process.env["DEPOT_RUNNER"] === "1") return "depot";
if (process.env["NAMESPACE_GITHUB_RUNTIME"]) return "namespace";
if (process.env["BITRISE_IO"]) return "bitrise";
const runnerName = process.env["RUNNER_NAME"] ?? "";
if (runnerName.startsWith("warp-")) return "warp";
if (runnerName.startsWith("blacksmith-")) return "blacksmith";