1
0
Fork 0
mirror of synced 2026-06-05 13:04:04 +00:00

Add deploy-on-self-hosted-vm option for agent installation on self-hosted runners

Installs the Harden Runner agent on self-hosted Linux VMs when enabled.
Skipped if running in a container or agent is already installed.
Recommended only for ephemeral runners when baking the agent into the
VM image is not possible. Includes unit tests.
This commit is contained in:
Varun Sharma 2026-04-03 22:46:48 -07:00
commit ac89272860
12 changed files with 251 additions and 98 deletions

View file

@ -40,6 +40,10 @@ inputs:
description: "Set to true to fetch policy from the policy store using the API key. This is the preferred method over the policy input which requires id-token: write permission. Policies can be defined and attached at workflow, repo, org, or cluster (for ARC) level in the policy store. The most granular policy will apply."
required: false
default: "false"
deploy-on-self-hosted-vm:
description: "Set to true to deploy the Harden Runner agent directly on a self-hosted runner VM (Linux only). The recommended approach for self-hosted VMs is to bake the agent into the VM image; see docs.stepsecurity.io. Use this option only if baking is not possible, and only for ephemeral runners."
required: false
default: "false"
branding:
icon: "check-square"

3
dist/index.js vendored
View file

@ -31910,6 +31910,9 @@ function isAgentInstalled(platform) {
return false;
}
}
function shouldDeployAgentOnSelfHosted(deployOnSelfHostedVm, isContainer, agentAlreadyInstalled) {
return deployOnSelfHostedVm && !isContainer && !agentAlreadyInstalled;
}
function utils_getAnnotationLogs(platform) {
switch (platform) {
case "linux":

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

3
dist/post/index.js vendored
View file

@ -31916,6 +31916,9 @@ function isAgentInstalled(platform) {
return false;
}
}
function shouldDeployAgentOnSelfHosted(deployOnSelfHostedVm, isContainer, agentAlreadyInstalled) {
return deployOnSelfHostedVm && !isContainer && !agentAlreadyInstalled;
}
function getAnnotationLogs(platform) {
switch (platform) {
case "linux":

File diff suppressed because one or more lines are too long

173
dist/pre/index.js vendored
View file

@ -84974,6 +84974,7 @@ __nccwpck_require__.r(__webpack_exports__);
// EXPORTS
__nccwpck_require__.d(__webpack_exports__, {
installAgentForSelfHosted: () => (/* binding */ installAgentForSelfHosted),
sleep: () => (/* binding */ setup_sleep)
});
@ -85031,6 +85032,9 @@ function isAgentInstalled(platform) {
return false;
}
}
function shouldDeployAgentOnSelfHosted(deployOnSelfHostedVm, isContainer, agentAlreadyInstalled) {
return deployOnSelfHostedVm && !isContainer && !agentAlreadyInstalled;
}
function utils_getAnnotationLogs(platform) {
switch (platform) {
case "linux":
@ -85262,48 +85266,6 @@ function fetchPolicy(owner, policyName, idToken) {
}
});
}
function fetchPolicyFromStore(owner, repo, apiKey, workflow, runId, correlationId) {
return policy_utils_awaiter(this, void 0, void 0, function* () {
if (apiKey === "") {
throw new Error("[PolicyStoreFetch]: api-key is empty");
}
let policyEndpoint = `${configs_STEPSECURITY_API_URL}/github/${owner}/${repo}/actions/policies/workflow-policy?workflow=${encodeURIComponent(workflow)}&run_id=${encodeURIComponent(runId)}&correlationId=${encodeURIComponent(correlationId)}`;
let httpClient = new lib.HttpClient();
let headers = {};
headers["Authorization"] = `vm-api-key ${apiKey}`;
headers["Source"] = "github-actions";
let response = undefined;
let err = undefined;
let retry = 0;
while (retry < 3) {
try {
console.log(`Attempt: ${retry + 1}`);
response = yield httpClient.getJson(policyEndpoint, headers);
break;
}
catch (e) {
err = e;
}
retry += 1;
yield sleep(1000);
}
if (response === undefined && err !== undefined) {
const error = new Error(`[Policy Store Fetch] ${err}`);
if (err.statusCode !== undefined) {
error.statusCode = err.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;
}
return result;
});
}
function mergeConfigs(localConfig, remoteConfig) {
if (localConfig.allowed_endpoints === "") {
localConfig.allowed_endpoints = remoteConfig.allowed_endpoints.join(" ");
@ -85666,17 +85628,6 @@ var setup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _ar
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __rest = (undefined && undefined.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
@ -85736,47 +85687,10 @@ var __rest = (undefined && undefined.__rest) || function (s, e) {
is_github_hosted: isGithubHosted(),
is_debug: lib_core.isDebug(),
one_time_key: "",
api_key: lib_core.getInput("api-key"),
use_policy_store: lib_core.getBooleanInput("use-policy-store"),
deploy_on_self_hosted_vm: lib_core.getBooleanInput("deploy-on-self-hosted-vm"),
};
if (confg.api_key !== "") {
lib_core.setSecret(confg.api_key);
}
let policyName = lib_core.getInput("policy");
if (confg.use_policy_store) {
console.log(`Fetching policy from policy store`);
if (confg.api_key === "") {
lib_core.setFailed("api-key is required when use-policy-store is set to true");
}
else {
try {
const repoName = (process.env["GITHUB_REPOSITORY"] || "").split("/")[1] || "";
const workflowRef = process.env["GITHUB_WORKFLOW_REF"] || "";
const workflow = workflowRef.replace(/.*\.github\/workflows\//, "").replace(/@.*/, "");
let result = yield fetchPolicyFromStore(github.context.repo.owner, repoName, confg.api_key, workflow, confg.run_id, confg.correlation_id);
if (result !== null) {
lib_core.info(`Policy found: ${result.policy_name || "unnamed"}`);
confg = mergeConfigs(confg, result);
}
else {
lib_core.info("No policy found in policy store. Defaulting to audit mode.");
confg.egress_policy = "audit";
}
}
catch (err) {
lib_core.info(`[!] ${err}`);
if (err.statusCode >= 400 && err.statusCode < 500) {
lib_core.info("Policy not found in policy store. Defaulting to audit mode.");
confg.egress_policy = "audit";
}
else {
lib_core.error(`Unexpected error fetching from policy store: ${err}. Falling back to audit mode.`);
confg.egress_policy = "audit";
}
}
}
}
else if (policyName !== "") {
if (policyName !== "") {
console.log(`Fetching policy from API with name: ${policyName}`);
try {
let idToken = yield lib_core.getIDToken();
@ -85893,6 +85807,23 @@ var __rest = (undefined && undefined.__rest) || function (s, e) {
encoding: "utf8",
});
lib_core.info(SELF_HOSTED_RUNNER_MESSAGE);
if (shouldDeployAgentOnSelfHosted(confg.deploy_on_self_hosted_vm, isDocker(), isAgentInstalled(process.platform))) {
if (process.platform !== "linux") {
lib_core.info("deploy-on-self-hosted-vm is only supported on Linux. Skipping agent deployment.");
}
else {
lib_core.info("deploy-on-self-hosted-vm is enabled. Installing agent on self-hosted runner.");
yield installAgentForSelfHosted(github.context.repo.owner, confg);
}
}
else {
if (confg.deploy_on_self_hosted_vm && isDocker()) {
lib_core.info("Skipping agent deployment: running inside a container.");
}
if (confg.deploy_on_self_hosted_vm && isAgentInstalled(process.platform)) {
lib_core.info("Agent already installed on self-hosted runner, skipping installation.");
}
}
if (confg.egress_policy === "block") {
sendAllowedEndpoints(confg.allowed_endpoints);
yield setup_sleep(5000);
@ -85949,8 +85880,7 @@ var __rest = (undefined && undefined.__rest) || function (s, e) {
console.log(HARDEN_RUNNER_UNAVAILABLE_MESSAGE);
return;
}
const { api_key, use_policy_store } = confg, agentConfig = __rest(confg, ["api_key", "use_policy_store"]);
const configStr = JSON.stringify(agentConfig);
const configStr = JSON.stringify(confg);
// platform specific
let statusFile = "";
let logFile = "";
@ -86015,6 +85945,61 @@ function setup_sleep(ms) {
setTimeout(resolve, ms);
});
}
function installAgentForSelfHosted(owner, confg) {
return setup_awaiter(this, void 0, void 0, function* () {
try {
console.log("Installing Harden Runner agent for self-hosted runner");
let isTLS = yield isTLSEnabled(owner);
if (!isTLS) {
console.log("TLS is not enabled for this organization. Agent installation skipped for self-hosted runner.");
return;
}
const selfHostedConfig = {
customer: owner,
working_directory: confg.working_directory,
api_url: confg.api_url,
allowed_endpoints: confg.allowed_endpoints,
egress_policy: confg.egress_policy,
disable_telemetry: confg.disable_telemetry,
disable_sudo: confg.disable_sudo,
disable_sudo_and_containers: confg.disable_sudo_and_containers,
disable_file_monitoring: confg.disable_file_monitoring,
is_github_hosted: false,
};
const selfHostedConfigStr = JSON.stringify(selfHostedConfig);
external_child_process_.execSync("sudo mkdir -p /home/agent");
chownForFolder(process.env.USER, "/home/agent");
const agentInstalled = yield installAgent(isTLS, selfHostedConfigStr);
if (agentInstalled) {
const statusFile = "/home/agent/agent.status";
const logFile = "/home/agent/agent.log";
let counter = 0;
while (true) {
if (!external_fs_.existsSync(statusFile)) {
counter++;
if (counter > 30) {
console.log("timed out");
if (external_fs_.existsSync(logFile)) {
const content = external_fs_.readFileSync(logFile, "utf-8");
console.log(content);
}
break;
}
yield setup_sleep(300);
}
else {
const content = external_fs_.readFileSync(statusFile, "utf-8");
console.log(content);
break;
}
}
}
}
catch (error) {
console.log(`Failed to install agent for self-hosted runner: ${error.message}`);
}
});
}
})();

File diff suppressed because one or more lines are too long

View file

@ -17,6 +17,7 @@ export interface Configuration {
one_time_key: string;
api_key: string;
use_policy_store: boolean;
deploy_on_self_hosted_vm: boolean;
}
export interface PolicyResponse {

View file

@ -45,6 +45,7 @@ test("merge configs", async () => {
one_time_key: "",
api_key: "",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
let policyResponse: PolicyResponse = {
owner: "h0x0er",
@ -75,6 +76,7 @@ test("merge configs", async () => {
one_time_key: "",
api_key: "",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
localConfig = mergeConfigs(localConfig, policyResponse);
@ -314,6 +316,7 @@ test("mergeConfigs does not override local allowed_endpoints if not empty", () =
one_time_key: "",
api_key: "",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
let policyResponse: PolicyResponse = {
allowed_endpoints: ["remote.endpoint:443"],
@ -345,6 +348,7 @@ test("mergeConfigs overrides disable_sudo_and_containers from remote", () => {
one_time_key: "",
api_key: "",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
let policyResponse: PolicyResponse = {
allowed_endpoints: [],
@ -375,6 +379,7 @@ test("mergeConfigs does not override fields when remote values are undefined", (
one_time_key: "",
api_key: "",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
let policyResponse: PolicyResponse = {
allowed_endpoints: [],

69
src/setup.test.ts Normal file
View file

@ -0,0 +1,69 @@
import { shouldDeployAgentOnSelfHosted, isAgentInstalled, isPlatformSupported, getAnnotationLogs } from "./utils";
describe("shouldDeployAgentOnSelfHosted", () => {
test("returns true when deploy flag is true, not container, agent not installed", () => {
expect(shouldDeployAgentOnSelfHosted(true, false, false)).toBe(true);
});
test("returns false when deploy flag is false", () => {
expect(shouldDeployAgentOnSelfHosted(false, false, false)).toBe(false);
});
test("returns false when running in a container", () => {
expect(shouldDeployAgentOnSelfHosted(true, true, false)).toBe(false);
});
test("returns false when agent is already installed", () => {
expect(shouldDeployAgentOnSelfHosted(true, false, true)).toBe(false);
});
test("returns false when in container and agent installed", () => {
expect(shouldDeployAgentOnSelfHosted(true, true, true)).toBe(false);
});
test("returns false when all conditions are negative", () => {
expect(shouldDeployAgentOnSelfHosted(false, true, true)).toBe(false);
});
});
describe("isAgentInstalled", () => {
test("returns false for linux when status file does not exist", () => {
expect(isAgentInstalled("linux")).toBe(false);
});
test("returns false for win32 when status file does not exist", () => {
expect(isAgentInstalled("win32")).toBe(false);
});
test("returns false for darwin when status file does not exist", () => {
expect(isAgentInstalled("darwin")).toBe(false);
});
test("returns false for unsupported platform", () => {
expect(isAgentInstalled("freebsd" as NodeJS.Platform)).toBe(false);
});
});
describe("isPlatformSupported", () => {
test("returns true for linux", () => {
expect(isPlatformSupported("linux")).toBe(true);
});
test("returns true for win32", () => {
expect(isPlatformSupported("win32")).toBe(true);
});
test("returns true for darwin", () => {
expect(isPlatformSupported("darwin")).toBe(true);
});
test("returns false for unsupported platform", () => {
expect(isPlatformSupported("freebsd" as NodeJS.Platform)).toBe(false);
});
});
describe("getAnnotationLogs", () => {
test("throws for unsupported platform", () => {
expect(() => getAnnotationLogs("freebsd" as NodeJS.Platform)).toThrow("platform not supported");
});
});

View file

@ -37,7 +37,7 @@ import {
installWindowsAgent,
} from "./install-agent";
import { chownForFolder, isAgentInstalled, isPlatformSupported } from "./utils";
import { chownForFolder, isAgentInstalled, isPlatformSupported, shouldDeployAgentOnSelfHosted } from "./utils";
interface MonitorResponse {
runner_ip_address?: string;
@ -89,6 +89,7 @@ interface MonitorResponse {
one_time_key: "",
api_key: core.getInput("api-key"),
use_policy_store: core.getBooleanInput("use-policy-store"),
deploy_on_self_hosted_vm: core.getBooleanInput("deploy-on-self-hosted-vm"),
};
if (confg.api_key !== "") {
@ -294,6 +295,22 @@ interface MonitorResponse {
core.info(common.SELF_HOSTED_RUNNER_MESSAGE);
if (shouldDeployAgentOnSelfHosted(confg.deploy_on_self_hosted_vm, isDocker(), isAgentInstalled(process.platform))) {
if (process.platform !== "linux") {
core.info("deploy-on-self-hosted-vm is only supported on Linux. Skipping agent deployment.");
} else {
core.info("deploy-on-self-hosted-vm is enabled. Installing agent on self-hosted runner.");
await installAgentForSelfHosted(context.repo.owner, confg);
}
} else {
if (confg.deploy_on_self_hosted_vm && isDocker()) {
core.info("Skipping agent deployment: running inside a container.");
}
if (confg.deploy_on_self_hosted_vm && isAgentInstalled(process.platform)) {
core.info("Agent already installed on self-hosted runner, skipping installation.");
}
}
if (confg.egress_policy === "block") {
sendAllowedEndpoints(confg.allowed_endpoints);
await sleep(5000);
@ -449,3 +466,61 @@ export function sleep(ms: number) {
setTimeout(resolve, ms);
});
}
export async function installAgentForSelfHosted(owner: string, confg: Configuration) {
try {
console.log("Installing Harden Runner agent for self-hosted runner");
let isTLS = await isTLSEnabled(owner);
if (!isTLS) {
console.log("TLS is not enabled for this organization. Agent installation skipped for self-hosted runner.");
return;
}
const selfHostedConfig = {
customer: owner,
working_directory: confg.working_directory,
api_url: confg.api_url,
allowed_endpoints: confg.allowed_endpoints,
egress_policy: confg.egress_policy,
disable_telemetry: confg.disable_telemetry,
disable_sudo: confg.disable_sudo,
disable_sudo_and_containers: confg.disable_sudo_and_containers,
disable_file_monitoring: confg.disable_file_monitoring,
is_github_hosted: false,
};
const selfHostedConfigStr = JSON.stringify(selfHostedConfig);
cp.execSync("sudo mkdir -p /home/agent");
chownForFolder(process.env.USER, "/home/agent");
const agentInstalled = await installAgent(isTLS, selfHostedConfigStr);
if (agentInstalled) {
const statusFile = "/home/agent/agent.status";
const logFile = "/home/agent/agent.log";
let counter = 0;
while (true) {
if (!fs.existsSync(statusFile)) {
counter++;
if (counter > 30) {
console.log("timed out");
if (fs.existsSync(logFile)) {
const content = fs.readFileSync(logFile, "utf-8");
console.log(content);
}
break;
}
await sleep(300);
} else {
const content = fs.readFileSync(statusFile, "utf-8");
console.log(content);
break;
}
}
}
} catch (error) {
console.log(`Failed to install agent for self-hosted runner: ${error.message}`);
}
}

View file

@ -32,6 +32,14 @@ export function isAgentInstalled(platform: NodeJS.Platform) {
}
}
export function shouldDeployAgentOnSelfHosted(
deployOnSelfHostedVm: boolean,
isContainer: boolean,
agentAlreadyInstalled: boolean
): boolean {
return deployOnSelfHostedVm && !isContainer && !agentAlreadyInstalled;
}
export function getAnnotationLogs(platform: NodeJS.Platform) {
switch (platform) {
case "linux":