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
Varun Sharma 8d3c67de8e Release v2.19.0 (#661) 2026-04-20 00:33:22 -07:00
Varun Sharma 6c3c2f2c1c Feature/deploy on self hosted vm (#658) 2026-04-15 00:42:20 -07: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
Varun Sharma f808768d15 Feature/policy store (#656) 2026-04-08 23:47:46 -07:00
Varun Sharma fe10465874 v2.16.1 (#654) 2026-03-30 11:43:08 -07:00
Varun Sharma fa2e9d605c Release v2.16.0 (#646) 2026-03-16 00:18:40 -07:00
Varun Sharma 58077d3c7e Release v2.15.1 (#641) 2026-03-05 13:02:45 -08:00
eromosele-stepsecurity a90bcbc653 Update readme (#637) 2026-02-24 15:17:32 -08:00
Varun Sharma f0a59d8853 Release v2.15.0 (#639) 2026-02-24 15:14:57 -08:00
Varun Sharma 5ef0c079ce Merge pull request #635 from step-security/rc-34
v2.14.2
2026-02-06 17:26:07 -08:00
Varun Sharma eb43c7b3fd update agent 2026-02-06 16:51:21 -08:00
Varun Sharma e3f713f2d8 Merge pull request #631 from step-security/rc-31
Release v2.14.1
2026-01-25 21:01:21 -08:00
Varun Sharma 423acdda6f chore: fix npm audit vulnerabilities 2026-01-25 09:03:51 -08:00
Varun Sharma 0ddb86cf03 update agent 2026-01-23 18:38:30 -08:00
Varun Sharma 20cf305ff2 Merge pull request #622 from step-security/feature/custom-property-skip
Feature/custom property skip
2025-12-09 11:28:11 -08:00
30 changed files with 4166 additions and 1535 deletions
+15 -11
View File
@@ -19,19 +19,22 @@ Corporate laptops and production servers typically have robust security monitori
Traditional security monitoring and EDR solutions are ineffective for CI/CD runners due to their ephemeral nature. These tools also lack the necessary context to correlate events with specific workflow runs in a CI/CD environment.
StepSecurity Harden-Runner addresses this gap by providing security monitoring tailored for CI/CD runners. This approach brings CI/CD runners under the same level of security scrutiny as other critical systems, addressing a significant gap in the software supply chain.
StepSecurity Harden-Runner addresses this gap by providing security monitoring tailored for CI/CD runners, with support for Linux, Windows, and macOS runners. This approach brings CI/CD runners under the same level of security scrutiny as other critical systems, addressing a significant gap in the software supply chain.
### Harden-Runner: Security Incidents Detected
- [Harden-Runner Detected the Compromised axios npm Package Dropping a Remote Access Trojan](https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan) ([backstage/backstage#33693](https://github.com/backstage/backstage/issues/33693), [block/elasticgraph#1103](https://github.com/block/elasticgraph/issues/1103))
- [Harden-Runner Detected the Trivy Compromise with Malicious v0.69.4 Release](https://www.stepsecurity.io/blog/trivy-compromised-a-second-time---malicious-v0-69-4-release) ([k8gb-io/k8gb#2294](https://github.com/k8gb-io/k8gb/issues/2294))
- [Harden-Runner Detected the tj-actions/changed-files compromise](https://www.stepsecurity.io/blog/harden-runner-detection-tj-actions-changed-files-action-is-compromised) ([CVE-2025-30066](https://github.com/advisories/GHSA-mrrh-fwg8-r2c3))
- [Harden-Runner Detected a CI/CD Supply Chain Attack in Google’s Open-Source Project Flank](https://www.stepsecurity.io/case-studies/flank)
- [Harden Runner Detected the Sha1-Hulud Supply Chain Attack in CNCF’s Backstage Repository](https://www.stepsecurity.io/blog/how-harden-runner-detected-the-sha1-hulud-supply-chain-attack-in-cncfs-backstage-repository)
- [Harden-Runner Detected the NX Build System compromise](https://www.stepsecurity.io/blog/supply-chain-security-alert-popular-nx-build-system-package-compromised-with-data-stealing-malware)
- [Harden-Runner Detected a CI/CD Supply Chain Attack in Google’s Open-Source Project Flank](https://www.stepsecurity.io/case-studies/flank)
- [Harden-Runner Detected a CI/CD Supply Chain Attack in Microsoft’s Open-Source Project Azure Karpenter Provider in Real-Time](https://www.stepsecurity.io/case-studies/azure-karpenter-provider)
- [Harden-Runner Detected Anomalous Traffic to api.ipify.org Across Multiple Customers](https://www.stepsecurity.io/blog/harden-runner-detects-anomalous-traffic-to-api-ipify-org-across-multiple-customers)
- [Harden-Runner Detected an Unexpected Microsoft Defender Installation on GitHub-Hosted Ubuntu Runners](https://www.stepsecurity.io/blog/how-stepsecurity-harden-runner-detected-unexpected-microsoft-defender-installation-on-github-hosted-ubuntu-runners)
- [Harden-Runner Flagged an Anomalous Outbound Call, Leading to a Docker Documentation Update](https://www.stepsecurity.io/blog/harden-runner-flags-anomalous-outbound-call-leading-to-docker-documentation-update)
### See It in Action
Harden-Runner secures over **8 million CI/CD workflow runs every week**, protecting thousands of pipelines, including those from popular open-source projects by **Microsoft, Google, and CISA**. See how top projects are using Harden-Runner and explore the insights:
Harden-Runner secures over **25 million CI/CD workflow runs every week**, protecting thousands of pipelines, including those from popular open-source projects by **Microsoft, Google, and CISA**. See how top projects are using Harden-Runner and explore the insights:
➡️ [Who's using Harden-Runner?](https://docs.stepsecurity.io/whos-using-harden-runner)
## Quick Links
@@ -69,7 +72,7 @@ To integrate Harden-Runner, follow these steps:
```yaml
steps:
- name: Harden Runner
uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0
with:
egress-policy: audit
@@ -137,7 +140,7 @@ Explore the full feature set in the [Features Documentation](https://docs.stepse
## Trusted By and Case Studies
Harden-Runner is trusted by over 8000 leading open-source projects and enterprises, including Microsoft, Google, Kubernetes, and more.
Harden-Runner is trusted by over 11,000 leading open-source projects and enterprises, including Microsoft, Google, Kubernetes, and more.
### Trusted by
@@ -148,11 +151,11 @@ Harden-Runner is trusted by over 8000 leading open-source projects and enterpris
### Enterprise Case Studies
- [How Mercari Secures GitHub Actions with StepSecurity](https://www.stepsecurity.io/case-studies/mercari)
- [How Omnissa Secures GitHub Actions with StepSecurity](https://www.stepsecurity.io/case-studies/omnissa)
- [Chainguard Secures GitHub Actions with StepSecurity](https://www.stepsecurity.io/case-studies/chainguard)
- [How Coveo Strengthened GitHub Actions Security with StepSecurity](https://www.stepsecurity.io/case-studies/coveo)
- [Hashgraph Achieves Comprehensive CI/CD Security Without Compromising Development Speed](https://www.stepsecurity.io/case-studies/hashgraph)
- [Chainguard Secures GitHub Actions with StepSecurity](https://www.stepsecurity.io/case-studies/chainguard)
- [Kapiche secures their GitHub Actions software supply chain with Harden-Runner](https://www.stepsecurity.io/case-studies/kapiche)
- [Arcjet Enhances CI/CD Security with Harden-Runner](https://www.stepsecurity.io/case-studies/arcjet)
---
@@ -160,9 +163,10 @@ Harden-Runner is trusted by over 8000 leading open-source projects and enterpris
Harden-Runner is designed to work seamlessly across a variety of runner environments, providing consistent security insights and protections regardless of where your workflows execute. For self-hosted runners, audit mode is deployed directly to the runner infrastructure without requiring any changes to your existing workflows. For more details, refer to the [official documentation](https://docs.stepsecurity.io/harden-runner).
| Environment Type | Compatibility | Audit Mode Deployment | Workflow Changes for Audit Mode |
| Environment Type | Compatibility | Audit Mode Deployment | Workflow Changes for Audit/Block Mode |
|------------------|---------------|--------------------------|-------------------|
| GitHub-hosted runners | ✅ Full support | Add Harden-Runner Action to workflow | Yes |
| GitHub-hosted runners (Linux) | ✅ Full support | Add Harden-Runner Action to workflow | Yes |
| GitHub-hosted runners (Windows, macOS) | ✅ Audit mode only | Add Harden-Runner Action to workflow | Yes |
| Self-hosted VM runners | ✅ Full support | Include agent in runner image | No |
| Self-hosted bare-metal runners | ✅ Full support | Install agent as a service | No |
| Actions Runner Controller (ARC) | ✅ Full support | Deploy as DaemonSet | No |
@@ -176,7 +180,7 @@ Want to know the technical details? Dive into the architecture of Harden-Runner
## Limitations
While Harden-Runner offers powerful features, there are certain limitations based on the environment, such as OS support. See the complete list in [Known Limitations](docs/limitations.md).
While Harden-Runner offers powerful features, there are certain limitations. See the complete list in [Known Limitations](docs/limitations.md).
---
+14 -2
View File
@@ -29,15 +29,27 @@ inputs:
required: false
default: "false"
policy:
description: "Policy name to be used from the policy store"
description: "Policy name to be used from the policy store. Requires id-token: write permission."
required: false
default: ""
api-key:
description: "StepSecurity API key for authenticating with the policy store. Required when use-policy-store is set to true."
required: false
default: ""
use-policy-store:
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"
color: "green"
runs:
using: "node20"
using: "node24"
pre: "dist/pre/index.js"
main: "dist/index.js"
post: "dist/post/index.js"
+87 -22
View File
@@ -31876,8 +31876,71 @@ var __webpack_exports__ = {};
// EXTERNAL MODULE: ./node_modules/@actions/core/lib/core.js
var lib_core = __nccwpck_require__(7484);
// EXTERNAL MODULE: external "child_process"
var external_child_process_ = __nccwpck_require__(5317);
// EXTERNAL MODULE: external "fs"
var external_fs_ = __nccwpck_require__(9896);
;// CONCATENATED MODULE: ./src/utils.ts
function isPlatformSupported(platform) {
switch (platform) {
case "linux":
case "win32":
case "darwin":
return true;
default:
return false;
}
}
function chownForFolder(newOwner, target) {
let cmd = "sudo";
let args = ["chown", "-R", newOwner, target];
cp.execFileSync(cmd, args);
}
function isAgentInstalled(platform) {
switch (platform) {
case "linux":
return fs.existsSync("/home/agent/agent.status");
case "win32":
return fs.existsSync("C:\\agent\\agent.status");
case "darwin":
return fs.existsSync("/opt/step-security/agent.status");
default:
return false;
}
}
function shouldDeployAgentOnSelfHosted(deployOnSelfHostedVm, isContainer, agentAlreadyInstalled) {
return deployOnSelfHostedVm && !isContainer && !agentAlreadyInstalled;
}
function detectThirdPartyRunnerProvider() {
var _a;
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 = (_a = process.env["RUNNER_NAME"]) !== null && _a !== void 0 ? _a : "";
if (runnerName.startsWith("warp-"))
return "warp";
if (runnerName.startsWith("blacksmith-"))
return "blacksmith";
return null;
}
function utils_getAnnotationLogs(platform) {
switch (platform) {
case "linux":
return fs.readFileSync("/home/agent/annotation.log", "utf8");
case "win32":
return fs.readFileSync("C:\\agent\\annotation.log", "utf8");
case "darwin":
return fs.readFileSync("/opt/step-security/annotation.log", "utf8");
default:
throw new Error("platform not supported");
}
}
;// CONCATENATED MODULE: ./src/common.ts
var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
@@ -31919,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;
}
@@ -31930,8 +31993,9 @@ function addSummary() {
}
let needsSubscription = false;
try {
let data = fs.readFileSync("/home/agent/annotation.log", "utf8");
if (data.includes("StepSecurity Harden Runner is disabled")) {
let data = getAnnotationLogs(process.platform);
if (data !== undefined &&
data.includes("StepSecurity Harden Runner is disabled")) {
needsSubscription = true;
}
}
@@ -31960,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;
@@ -31978,11 +32044,13 @@ function addSummary() {
}
const STATUS_HARDEN_RUNNER_UNAVAILABLE = "409";
const CONTAINER_MESSAGE = "This job is running in a container. Such jobs can be monitored by installing Harden Runner in a custom VM image for GitHub-hosted runners.";
const UBUNTU_MESSAGE = "This job is not running in a GitHub Actions Hosted Runner Ubuntu VM. Harden Runner is only supported on Ubuntu VM. This job will not be monitored.";
const UNSUPPORTED_RUNNER_MESSAGE = "This job is not running in a GitHub Actions Hosted Runner. Harden Runner is only supported on GitHub-hosted runners (Ubuntu, Windows, and macOS). This job will not be monitored.";
const SELF_HOSTED_RUNNER_MESSAGE = "This job is running on a self-hosted runner.";
const HARDEN_RUNNER_UNAVAILABLE_MESSAGE = "Sorry, we are currently experiencing issues with the Harden Runner installation process. It is currently unavailable.";
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");
@@ -32020,10 +32088,9 @@ function isDocker() {
;// CONCATENATED MODULE: ./src/configs.ts
const STEPSECURITY_ENV = "agent"; // agent or int
const configs_STEPSECURITY_API_URL = (/* unused pure expression or super */ null && (`https://${STEPSECURITY_ENV}.api.stepsecurity.io/v1`));
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); }); }
@@ -32036,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() {
@@ -32083,6 +32147,7 @@ var src_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _argu
(() => src_awaiter(void 0, void 0, void 0, function* () {
var _a, _b;
console.log("[harden-runner] main-step");
@@ -32091,8 +32156,8 @@ var src_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _argu
console.log("Skipping harden-runner: custom property 'skip-harden-runner' is set to 'true'");
return;
}
if (process.platform !== "linux") {
console.log(UBUNTU_MESSAGE);
if (!isPlatformSupported(process.platform)) {
console.log(UNSUPPORTED_RUNNER_MESSAGE);
return;
}
if (isGithubHosted() && isDocker()) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+369 -79
View File
@@ -31874,17 +31874,79 @@ var __webpack_exports__ = {};
(() => {
"use strict";
// EXTERNAL MODULE: external "fs"
var external_fs_ = __nccwpck_require__(9896);
// EXTERNAL MODULE: external "child_process"
var external_child_process_ = __nccwpck_require__(5317);
// 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`;
const STEPSECURITY_TELEMETRY_URL = "https://prod.app-api.stepsecurity.io/v1";
const STEPSECURITY_WEB_URL = "https://app.stepsecurity.io";
// EXTERNAL MODULE: external "child_process"
var external_child_process_ = __nccwpck_require__(5317);
;// CONCATENATED MODULE: ./src/utils.ts
function isPlatformSupported(platform) {
switch (platform) {
case "linux":
case "win32":
case "darwin":
return true;
default:
return false;
}
}
function chownForFolder(newOwner, target) {
let cmd = "sudo";
let args = ["chown", "-R", newOwner, target];
cp.execFileSync(cmd, args);
}
function isAgentInstalled(platform) {
switch (platform) {
case "linux":
return external_fs_.existsSync("/home/agent/agent.status");
case "win32":
return external_fs_.existsSync("C:\\agent\\agent.status");
case "darwin":
return external_fs_.existsSync("/opt/step-security/agent.status");
default:
return false;
}
}
function shouldDeployAgentOnSelfHosted(deployOnSelfHostedVm, isContainer, agentAlreadyInstalled) {
return deployOnSelfHostedVm && !isContainer && !agentAlreadyInstalled;
}
function detectThirdPartyRunnerProvider() {
var _a;
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 = (_a = process.env["RUNNER_NAME"]) !== null && _a !== void 0 ? _a : "";
if (runnerName.startsWith("warp-"))
return "warp";
if (runnerName.startsWith("blacksmith-"))
return "blacksmith";
return null;
}
function getAnnotationLogs(platform) {
switch (platform) {
case "linux":
return external_fs_.readFileSync("/home/agent/annotation.log", "utf8");
case "win32":
return external_fs_.readFileSync("C:\\agent\\annotation.log", "utf8");
case "darwin":
return external_fs_.readFileSync("/opt/step-security/annotation.log", "utf8");
default:
throw new Error("platform not supported");
}
}
;// CONCATENATED MODULE: ./src/common.ts
var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
@@ -31926,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;
}
@@ -31937,8 +31999,9 @@ function addSummary() {
}
let needsSubscription = false;
try {
let data = external_fs_.readFileSync("/home/agent/annotation.log", "utf8");
if (data.includes("StepSecurity Harden Runner is disabled")) {
let data = getAnnotationLogs(process.platform);
if (data !== undefined &&
data.includes("StepSecurity Harden Runner is disabled")) {
needsSubscription = true;
}
}
@@ -31967,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;
@@ -31985,12 +32050,16 @@ function addSummary() {
}
const STATUS_HARDEN_RUNNER_UNAVAILABLE = "409";
const CONTAINER_MESSAGE = "This job is running in a container. Such jobs can be monitored by installing Harden Runner in a custom VM image for GitHub-hosted runners.";
const UBUNTU_MESSAGE = "This job is not running in a GitHub Actions Hosted Runner Ubuntu VM. Harden Runner is only supported on Ubuntu VM. This job will not be monitored.";
const UNSUPPORTED_RUNNER_MESSAGE = "This job is not running in a GitHub Actions Hosted Runner. Harden Runner is only supported on GitHub-hosted runners (Ubuntu, Windows, and macOS). This job will not be monitored.";
const SELF_HOSTED_RUNNER_MESSAGE = "This job is running on a self-hosted runner.";
const HARDEN_RUNNER_UNAVAILABLE_MESSAGE = "Sorry, we are currently experiencing issues with the Harden Runner installation process. It is currently unavailable.";
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);
;// CONCATENATED MODULE: external "node:fs"
const external_node_fs_namespaceObject = require("node:fs");
;// CONCATENATED MODULE: ./node_modules/is-docker/index.js
@@ -32070,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); }); }
@@ -32084,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() {
@@ -32132,6 +32196,15 @@ 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");
@@ -32140,84 +32213,49 @@ var cleanup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _
console.log("Skipping harden-runner: custom property 'skip-harden-runner' is set to 'true'");
return;
}
if (process.platform !== "linux") {
console.log(UBUNTU_MESSAGE);
if (!isPlatformSupported(process.platform)) {
console.log(UNSUPPORTED_RUNNER_MESSAGE);
return;
}
if (isGithubHosted() && isDocker()) {
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;
}
const thirdPartyProvider = detectThirdPartyRunnerProvider();
if (process.env.STATE_selfHosted === "true") {
return;
}
if (process.env.STATE_customVMImage === "true") {
return;
}
if (process.env.STATE_isTLS === "false" && process.arch === "arm64") {
return;
}
if (String(process.env.STATE_monitorStatusCode) ===
STATUS_HARDEN_RUNNER_UNAVAILABLE) {
console.log(HARDEN_RUNNER_UNAVAILABLE_MESSAGE);
return;
}
if (isGithubHosted() && external_fs_.existsSync("/home/agent/post_event.json")) {
console.log("Post step already executed, skipping");
return;
}
external_fs_.writeFileSync("/home/agent/post_event.json", JSON.stringify({ event: "post" }));
const doneFile = "/home/agent/done.json";
let counter = 0;
while (true) {
if (!external_fs_.existsSync(doneFile)) {
counter++;
if (counter > 10) {
console.log("timed out");
break;
switch (process.platform) {
case "linux":
if (thirdPartyProvider) {
yield handleAgentBravoCleanup();
}
else {
yield handleLinuxCleanup();
}
yield sleep(1000);
} // The file *does* exist
else {
break;
}
}
const log = "/home/agent/agent.log";
if (external_fs_.existsSync(log)) {
console.log("log:");
var content = external_fs_.readFileSync(log, "utf-8");
console.log(content);
}
const daemonLog = "/home/agent/daemon.log";
if (external_fs_.existsSync(daemonLog)) {
console.log("daemonLog:");
var content = external_fs_.readFileSync(daemonLog, "utf-8");
console.log(content);
}
var status = "/home/agent/agent.status";
if (external_fs_.existsSync(status)) {
console.log("status:");
var content = external_fs_.readFileSync(status, "utf-8");
console.log(content);
}
var disable_sudo = process.env.STATE_disableSudo;
var disable_sudo_and_containers = process.env.STATE_disableSudoAndContainers;
if (disable_sudo !== "true" && disable_sudo_and_containers !== "true") {
try {
var journalLog = external_child_process_.execSync("sudo journalctl -u agent.service --lines=1000", {
encoding: "utf8",
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
});
console.log("agent.service log:");
console.log(journalLog);
}
catch (error) {
console.log("Warning: Could not fetch service logs:", error.message);
}
case "win32":
yield handleWindowsCleanup();
break;
case "darwin":
yield handleMacosCleanup();
break;
}
try {
yield addSummary();
@@ -32226,6 +32264,258 @@ var cleanup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _
console.log(exception);
}
}))();
function handleAgentBravoCleanup() {
return cleanup_awaiter(this, void 0, void 0, function* () {
external_child_process_.execFileSync("/usr/bin/echo", ["step_policy_jobend"]);
const doneFile = "/home/agent/done.json";
let counter = 0;
while (true) {
if (!external_fs_.existsSync(doneFile)) {
counter++;
if (counter > 10) {
console.log("timed out");
break;
}
yield sleep(1000);
}
else {
console.log(external_fs_.readFileSync(doneFile, "utf-8"));
break;
}
}
const log = "/home/agent/agent.log";
if (external_fs_.existsSync(log)) {
console.log("log:");
console.log(external_fs_.readFileSync(log, "utf-8"));
}
const status = "/home/agent/agent.status";
if (external_fs_.existsSync(status)) {
console.log("status:");
console.log(external_fs_.readFileSync(status, "utf-8"));
}
});
}
function handleLinuxCleanup() {
return cleanup_awaiter(this, void 0, void 0, function* () {
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;
}
external_fs_.writeFileSync("/home/agent/post_event.json", JSON.stringify({ event: "post" }));
const doneFile = "/home/agent/done.json";
let counter = 0;
while (true) {
if (!external_fs_.existsSync(doneFile)) {
counter++;
if (counter > 10) {
console.log("timed out");
break;
}
yield sleep(1000);
}
else {
// The file *does* exist
break;
}
}
const log = "/home/agent/agent.log";
if (external_fs_.existsSync(log)) {
console.log("log:");
var content = external_fs_.readFileSync(log, "utf-8");
console.log(content);
}
const daemonLog = "/home/agent/daemon.log";
if (external_fs_.existsSync(daemonLog)) {
console.log("daemonLog:");
var content = external_fs_.readFileSync(daemonLog, "utf-8");
console.log(content);
}
var status = "/home/agent/agent.status";
if (external_fs_.existsSync(status)) {
console.log("status:");
var content = external_fs_.readFileSync(status, "utf-8");
console.log(content);
}
var disable_sudo = process.env.STATE_disableSudo;
var disable_sudo_and_containers = process.env.STATE_disableSudoAndContainers;
if (disable_sudo !== "true" && disable_sudo_and_containers !== "true") {
try {
var journalLog = external_child_process_.execSync("sudo journalctl -u agent.service --lines=1000", {
encoding: "utf8",
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
});
console.log("agent.service log:");
console.log(journalLog);
}
catch (error) {
console.log("Warning: Could not fetch service logs:", error.message);
}
}
});
}
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;
}
external_fs_.writeFileSync(post_event, JSON.stringify({ event: "post" }));
// if agent is installed; wait for it to create done.json
if (isAgentInstalled(process.platform)) {
let macDone = "/opt/step-security/done.json";
let counter = 0;
while (true) {
if (!external_fs_.existsSync(macDone)) {
counter++;
if (counter > 10) {
console.log("timed out");
break;
}
yield sleep(1000);
}
else {
// The file *does* exist
break;
}
}
}
let macAgentLog = "/opt/step-security/agent.log";
if (external_fs_.existsSync(macAgentLog)) {
console.log("macAgentLog:");
var content = external_fs_.readFileSync(macAgentLog, "utf-8");
console.log(content);
}
else {
console.log("😭 macos agent.log file not found");
}
// Capture system log stream for harden-runner subsystem
try {
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, // 10MB buffer
timeout: 5000, // 5 seconds timeout
});
console.log(logStreamOutput);
}
catch (error) {
console.log("Warning: Could not fetch system log stream:", error.message);
}
});
}
function handleWindowsCleanup() {
return cleanup_awaiter(this, void 0, void 0, function* () {
// 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;
}
if (process.arch === "arm64") {
console.log(ARM64_WINDOWS_RUNNER_MESSAGE);
return;
}
const p = external_child_process_.spawn("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-Command",
"query user; exit $LASTEXITCODE",
], { stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true });
p.unref();
external_fs_.writeFileSync(postEventFile, JSON.stringify({ event: "post" }));
// if agent is installed; wait for it to create done.json
if (isAgentInstalled(process.platform)) {
const doneFile = external_path_.join(agentDir, "done.json");
let counter = 0;
while (true) {
if (!external_fs_.existsSync(doneFile)) {
counter++;
if (counter > 10) {
console.log("timed out");
break;
}
yield sleep(1000);
}
else {
break;
}
}
}
console.log("stopping windows agent process...");
const pidFile = external_path_.join(agentDir, "agent.pid");
try {
if (!external_fs_.existsSync(pidFile)) {
console.log("PID file not found. Agent may not be running.");
return;
}
const pid = parseInt(external_fs_.readFileSync(pidFile, "utf8").trim());
console.log(`agent PID from file: ${pid}`);
try {
process.kill(pid, 0); // signal 0 just checks if process exists
}
catch (_a) {
console.log("agent process not running.");
external_fs_.unlinkSync(pidFile);
return;
}
console.log(`stopping agent process (PID: ${pid})...`);
process.kill(pid, "SIGINT");
let gracefulShutdown = false;
for (let i = 0; i < 10; i++) {
yield sleep(1000);
try {
process.kill(pid, 0); // check if still exists
}
catch (_b) {
gracefulShutdown = true;
console.log("agent process stopped gracefully");
break;
}
}
if (!gracefulShutdown) {
console.log("graceful shutdown timeout (10s), forcing termination...");
process.kill(pid, "SIGKILL");
console.log("agent process terminated forcefully");
}
if (external_fs_.existsSync(pidFile)) {
external_fs_.unlinkSync(pidFile);
console.log("PID file cleaned up");
}
}
catch (error) {
console.log("warning: error stopping agent process:", error.message);
}
const log = external_path_.join(agentDir, "agent.log");
if (external_fs_.existsSync(log)) {
console.log("agent log:");
const content = external_fs_.readFileSync(log, "utf-8");
console.log(content);
}
});
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
+1 -1
View File
File diff suppressed because one or more lines are too long
+922 -420
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
+1 -2
View File
@@ -5,8 +5,7 @@
For GitHub-hosted runners, Harden-Runner GitHub Action downloads and installs the StepSecurity Agent.
- The code to monitor file, process, and network activity is in the Agent.
- The community tier agent is open-source and can be found [here](https://github.com/step-security/agent). The enterprise tier agent is closed-source. Both agents are written in Go.
- The agent's build is reproducible. You can view the steps to reproduce the build [here](http://app.stepsecurity.io/github/step-security/agent/releases/latest)
- The community tier agent for Linux is open-source and can be found [here](https://github.com/step-security/agent). The enterprise agent for Linux and agents for Windows and macOS are closed-source.
### Self-Hosted Actions Runner Controller (ARC) Runners
-6
View File
@@ -1,14 +1,8 @@
## Limitations
### GitHub-Hosted Runners
* Only Ubuntu VM is supported. Windows and MacOS GitHub-hosted runners are not supported. There is a discussion about that [here](https://github.com/step-security/harden-runner/discussions/121).
* Harden-Runner is not supported when [job is run in a container](https://docs.github.com/en/actions/using-jobs/running-jobs-in-a-container) with built-in labels such as `ubuntu-latest`, as it needs sudo access on the Ubuntu VM to run. The limitation is if the entire job is run in a container. However, such jobs can be monitored when using custom VM images with GitHub-hosted runners. This is also not a limitation for Self-Hosted runners.
### Self-Hosted Actions Runner Controller (ARC) Runners
* Since ARC Harden Runner uses eBPF, only Linux jobs are supported. Windows and MacOS jobs are not supported.
### Self-Hosted VM (e.g. on EC2) and Bare-metal Runners
* Only Linux jobs are supported. Windows and MacOS jobs are not supported.
+5 -1
View File
@@ -8,4 +8,8 @@ reason = "Untrusted headers are not processed"
[[IgnoredVulns]]
id = "GHSA-xx4v-prfh-6cgc"
reason = "Untrusted headers are not processed"
reason = "Untrusted headers are not processed"
[[IgnoredVulns]]
id = "GHSA-g9mf-h72j-4rw9"
reason = "undici fetch() is only used to call GitHub API; exploitation requires a malicious server"
+985 -850
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"
}
}
+81
View File
@@ -0,0 +1,81 @@
import { buildBravoConfig } from "./bravo-config";
import { Configuration } from "./interfaces";
const base: Configuration = {
repo: "org/repo",
run_id: "123",
correlation_id: "depot-abc",
working_directory: "/w",
api_url: "https://int.api.stepsecurity.io/v1",
telemetry_url: "https://int.app-api.stepsecurity.io/v1",
allowed_endpoints: "github.com:443",
egress_policy: "audit",
disable_telemetry: false,
disable_sudo: false,
disable_sudo_and_containers: false,
disable_file_monitoring: false,
is_github_hosted: false,
private: "true" as unknown as string,
is_debug: false,
one_time_key: "otk-xyz",
api_key: "tenant-key",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
describe("buildBravoConfig", () => {
test("forces is_github_hosted=true so agent honors passed correlation_id", () => {
expect(buildBravoConfig(base).is_github_hosted).toBe(true);
});
test("omits api_key (agent authenticates via one_time_key, not vm-api-key)", () => {
expect(buildBravoConfig(base)).not.toHaveProperty("api_key");
});
test("omits customer (server infers tenant from repo)", () => {
expect(buildBravoConfig(base)).not.toHaveProperty("customer");
});
test("omits use_policy_store (action-side concern, not agent)", () => {
expect(buildBravoConfig(base)).not.toHaveProperty("use_policy_store");
});
test("forwards telemetry_url so network events hit configured env", () => {
expect(buildBravoConfig(base).telemetry_url).toBe(base.telemetry_url);
});
test("forwards one_time_key so agent can auth to presigned URL endpoint", () => {
expect(buildBravoConfig(base).one_time_key).toBe("otk-xyz");
});
test("forwards repo, run_id, correlation_id so server can attribute events", () => {
const cfg = buildBravoConfig(base);
expect(cfg.repo).toBe("org/repo");
expect(cfg.run_id).toBe("123");
expect(cfg.correlation_id).toBe("depot-abc");
});
test("forwards private flag", () => {
expect(buildBravoConfig(base).private).toBe(base.private);
});
test("forwards egress_policy and allowed_endpoints", () => {
const cfg = buildBravoConfig(base);
expect(cfg.egress_policy).toBe("audit");
expect(cfg.allowed_endpoints).toBe("github.com:443");
});
test("forwards disable_* flags", () => {
const cfg = buildBravoConfig({
...base,
disable_telemetry: true,
disable_sudo: true,
disable_sudo_and_containers: true,
disable_file_monitoring: true,
});
expect(cfg.disable_telemetry).toBe(true);
expect(cfg.disable_sudo).toBe(true);
expect(cfg.disable_sudo_and_containers).toBe(true);
expect(cfg.disable_file_monitoring).toBe(true);
});
});
+21
View File
@@ -0,0 +1,21 @@
import { Configuration } from "./interfaces";
export function buildBravoConfig(confg: Configuration) {
return {
repo: confg.repo,
run_id: confg.run_id,
correlation_id: confg.correlation_id,
working_directory: confg.working_directory,
api_url: confg.api_url,
telemetry_url: confg.telemetry_url,
one_time_key: confg.one_time_key,
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,
private: confg.private,
is_github_hosted: true,
};
}
+98
View File
@@ -0,0 +1,98 @@
import * as fs from "fs";
import * as crypto from "crypto";
import * as core from "@actions/core";
import { verifyChecksum, CHECKSUMS } from "./checksum";
jest.mock("fs", () => ({
...jest.requireActual("fs"),
readFileSync: jest.fn(),
}));
jest.mock("crypto", () => ({
...jest.requireActual("crypto"),
createHash: jest.fn(),
}));
jest.mock("@actions/core");
const mockReadFile = fs.readFileSync as jest.MockedFunction<typeof fs.readFileSync>;
const mockSetFailed = core.setFailed as jest.MockedFunction<typeof core.setFailed>;
const mockCreateHash = crypto.createHash as jest.MockedFunction<typeof crypto.createHash>;
function stubHash(hash: string) {
mockCreateHash.mockReturnValue({
update: jest.fn().mockReturnThis(),
digest: jest.fn().mockReturnValue(hash),
} as unknown as crypto.Hash);
}
const WRONG_HASH = "0".repeat(64);
describe("verifyChecksum", () => {
beforeEach(() => {
jest.clearAllMocks();
mockReadFile.mockReturnValue(Buffer.from("test-payload"));
});
describe("agentType=bravo", () => {
test("passes with matching bravo amd64 checksum", () => {
stubHash(CHECKSUMS.bravo.amd64);
expect(verifyChecksum("/tmp/f", true, "amd64", "linux", "bravo")).toBe(true);
expect(mockSetFailed).not.toHaveBeenCalled();
});
test("passes with matching bravo arm64 checksum", () => {
stubHash(CHECKSUMS.bravo.arm64);
expect(verifyChecksum("/tmp/f", true, "arm64", "linux", "bravo")).toBe(true);
});
test("uses bravo checksum even when isTLS=false", () => {
stubHash(CHECKSUMS.bravo.amd64);
expect(verifyChecksum("/tmp/f", false, "amd64", "linux", "bravo")).toBe(true);
});
test("fails on mismatched bravo checksum", () => {
stubHash(WRONG_HASH);
expect(verifyChecksum("/tmp/f", true, "amd64", "linux", "bravo")).toBe(false);
expect(mockSetFailed).toHaveBeenCalled();
});
});
describe("agentType default (omitted)", () => {
test("uses TLS checksum when isTLS=true", () => {
stubHash(CHECKSUMS.tls.amd64);
expect(verifyChecksum("/tmp/f", true, "amd64", "linux")).toBe(true);
});
test("uses non_tls checksum when isTLS=false", () => {
stubHash(CHECKSUMS.non_tls.amd64);
expect(verifyChecksum("/tmp/f", false, "amd64", "linux")).toBe(true);
});
test("TLS mismatch fails", () => {
stubHash(CHECKSUMS.bravo.amd64);
expect(verifyChecksum("/tmp/f", true, "amd64", "linux")).toBe(false);
expect(mockSetFailed).toHaveBeenCalled();
});
});
describe("darwin", () => {
test("passes with matching darwin checksum", () => {
stubHash(CHECKSUMS.darwin);
expect(verifyChecksum("/tmp/f", false, "", "darwin")).toBe(true);
});
});
describe("win32", () => {
test("passes with matching windows amd64 checksum", () => {
stubHash(CHECKSUMS.windows.amd64);
expect(verifyChecksum("/tmp/f", false, "amd64", "win32")).toBe(true);
});
});
describe("unsupported platform", () => {
test("returns false without calling setFailed", () => {
stubHash(CHECKSUMS.bravo.amd64);
expect(verifyChecksum("/tmp/f", true, "amd64", "freebsd")).toBe(false);
expect(mockSetFailed).not.toHaveBeenCalled();
});
});
});
+39 -11
View File
@@ -2,20 +2,31 @@ import * as core from "@actions/core";
import * as crypto from "crypto";
import * as fs from "fs";
const CHECKSUMS = {
export const CHECKSUMS = {
tls: {
amd64: "2a5be06ab620340f6957ddd180450caab414671c8b7da1996d8e2755c1cd49fa", // v1.7.9
arm64: "e9d0ccf3e4e62ba15ff208ef6e0afa0ad5e323c3dfc50527342f436a85e65a55",
amd64: "a54c4305b5665ba54bfdc46eb32aee1758699b994550ca3658d698367cc96a3f", // v1.8.12
arm64: "3f401d508e1427b9ba205681d5abecde3b4a0f0a18542d198b6dc14cadd93ab5", // v1.8.12
},
non_tls: {
amd64: "336093af8ebe969567b66fd035af3bd4f7e1c723ce680d6b4b5b2a1f79bc329e", // v0.14.2
amd64: "4b14d8a3a5fbcef95af55e0c54d3bee6f44da802878c10289a4ca0b79b6d0237", // v0.16.2
},
bravo: {
amd64: "c986d0a19637325c9a8d4a331a6c4ed047e4cddb798f56b641d0e66f8bf9b1b2", // v1.8.12
arm64: "5c3df17f82e317c8b288dfbbcee449c2a24a80deeeb3416dccabd0a61982c676", // v1.8.12
},
darwin: "2990f0390d2760fa6262a3830060b6db1233f16a1410ffe1ed2bf13dfda80c38", // v0.0.6
windows: {
amd64: "5e3604d08aba65d7bdd1d0684826d5894ffb0c6f56b914c6ecb35c3271e04483", // v1.0.7
},
};
// verifyChecksum returns true if checksum is valid
export function verifyChecksum(
downloadPath: string,
isTLS: boolean,
variant: string
variant: string,
platform: string,
agentType: "default" | "bravo" = "default"
) {
const fileBuffer: Buffer = fs.readFileSync(downloadPath);
const checksum: string = crypto
@@ -25,17 +36,34 @@ export function verifyChecksum(
let expectedChecksum: string = "";
if (isTLS) {
expectedChecksum = CHECKSUMS["tls"][variant];
} else {
expectedChecksum = CHECKSUMS["non_tls"][variant];
switch (platform) {
case "linux":
if (agentType === "bravo") {
expectedChecksum = CHECKSUMS["bravo"][variant];
} else {
expectedChecksum = isTLS
? CHECKSUMS["tls"][variant]
: CHECKSUMS["non_tls"][variant];
}
break;
case "darwin":
expectedChecksum = CHECKSUMS["darwin"];
break;
case "win32":
expectedChecksum = CHECKSUMS["windows"][variant];
break;
default:
console.log(`Unsupported platform: ${platform}`);
return false;
}
if (checksum !== expectedChecksum) {
core.setFailed(
`Checksum verification failed, expected ${expectedChecksum} instead got ${checksum}`
`❌ Checksum verification failed, expected ${expectedChecksum} instead got ${checksum}`
);
return false;
}
core.debug("Checksum verification passed.");
core.info(`✅ Checksum verification passed. checksum=${checksum}`);
return true;
}
+268 -13
View File
@@ -1,10 +1,21 @@
import * as core from "@actions/core";
import * as fs from "fs";
import * as cp from "child_process";
import * as common from "./common";
import * as cp from "child_process";
import * as path from "path";
import isDocker from "is-docker";
import { isARCRunner } from "./arc-runner";
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");
@@ -14,8 +25,8 @@ import { context } from "@actions/github";
return;
}
if (process.platform !== "linux") {
console.log(common.UBUNTU_MESSAGE);
if (!isPlatformSupported(process.platform)) {
console.log(common.UNSUPPORTED_RUNNER_MESSAGE);
return;
}
if (isGithubHosted() && isDocker()) {
@@ -23,11 +34,18 @@ import { context } from "@actions/github";
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;
}
const thirdPartyProvider = detectThirdPartyRunnerProvider();
if (process.env.STATE_selfHosted === "true") {
return;
}
@@ -36,10 +54,6 @@ import { context } from "@actions/github";
return;
}
if (process.env.STATE_isTLS === "false" && process.arch === "arm64") {
return;
}
if (
String(process.env.STATE_monitorStatusCode) ===
common.STATUS_HARDEN_RUNNER_UNAVAILABLE
@@ -48,6 +62,75 @@ import { context } from "@actions/github";
return;
}
switch (process.platform) {
case "linux":
if (thirdPartyProvider) {
await handleAgentBravoCleanup();
} else {
await handleLinuxCleanup();
}
break;
case "win32":
await handleWindowsCleanup();
break;
case "darwin":
await handleMacosCleanup();
break;
}
try {
await common.addSummary();
} catch (exception) {
console.log(exception);
}
})();
async function handleAgentBravoCleanup() {
cp.execFileSync("/usr/bin/echo", ["step_policy_jobend"]);
const doneFile = "/home/agent/done.json";
let counter = 0;
while (true) {
if (!fs.existsSync(doneFile)) {
counter++;
if (counter > 10) {
console.log("timed out");
break;
}
await sleep(1000);
} else {
console.log(fs.readFileSync(doneFile, "utf-8"));
break;
}
}
const log = "/home/agent/agent.log";
if (fs.existsSync(log)) {
console.log("log:");
console.log(fs.readFileSync(log, "utf-8"));
}
const status = "/home/agent/agent.status";
if (fs.existsSync(status)) {
console.log("status:");
console.log(fs.readFileSync(status, "utf-8"));
}
}
async 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 (!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;
@@ -69,8 +152,8 @@ import { context } from "@actions/github";
break;
}
await sleep(1000);
} // The file *does* exist
else {
} else {
// The file *does* exist
break;
}
}
@@ -114,13 +197,185 @@ import { context } from "@actions/github";
console.log("Warning: Could not fetch service logs:", error.message);
}
}
}
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;
}
fs.writeFileSync(post_event, JSON.stringify({ event: "post" }));
// if agent is installed; wait for it to create done.json
if (isAgentInstalled(process.platform)) {
let macDone = "/opt/step-security/done.json";
let counter = 0;
while (true) {
if (!fs.existsSync(macDone)) {
counter++;
if (counter > 10) {
console.log("timed out");
break;
}
await sleep(1000);
} else {
// The file *does* exist
break;
}
}
}
let macAgentLog = "/opt/step-security/agent.log";
if (fs.existsSync(macAgentLog)) {
console.log("macAgentLog:");
var content = fs.readFileSync(macAgentLog, "utf-8");
console.log(content);
} else {
console.log("😭 macos agent.log file not found");
}
// Capture system log stream for harden-runner subsystem
try {
console.log("\nSystem log stream for io.stepsecurity.harden-runner:");
const logStreamOutput = cp.execSync(
"log show --predicate 'subsystem == \"io.stepsecurity.harden-runner\"' --info --last 10m",
{
encoding: "utf8",
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
timeout: 5000, // 5 seconds timeout
}
);
console.log(logStreamOutput);
} catch (error) {
console.log("Warning: Could not fetch system log stream:", error.message);
}
}
async function handleWindowsCleanup() {
// windows cleanup
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;
}
if (process.arch === "arm64") {
console.log(common.ARM64_WINDOWS_RUNNER_MESSAGE);
return;
}
const p = cp.spawn(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
"query user; exit $LASTEXITCODE",
],
{ stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true }
);
p.unref();
fs.writeFileSync(postEventFile, JSON.stringify({ event: "post" }));
// if agent is installed; wait for it to create done.json
if (isAgentInstalled(process.platform)) {
const doneFile = path.join(agentDir, "done.json");
let counter = 0;
while (true) {
if (!fs.existsSync(doneFile)) {
counter++;
if (counter > 10) {
console.log("timed out");
break;
}
await sleep(1000);
} else {
break;
}
}
}
console.log("stopping windows agent process...");
const pidFile = path.join(agentDir, "agent.pid");
try {
await common.addSummary();
} catch (exception) {
console.log(exception);
if (!fs.existsSync(pidFile)) {
console.log("PID file not found. Agent may not be running.");
return;
}
const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim());
console.log(`agent PID from file: ${pid}`);
try {
process.kill(pid, 0); // signal 0 just checks if process exists
} catch {
console.log("agent process not running.");
fs.unlinkSync(pidFile);
return;
}
console.log(`stopping agent process (PID: ${pid})...`);
process.kill(pid, "SIGINT");
let gracefulShutdown = false;
for (let i = 0; i < 10; i++) {
await sleep(1000);
try {
process.kill(pid, 0); // check if still exists
} catch {
gracefulShutdown = true;
console.log("agent process stopped gracefully");
break;
}
}
if (!gracefulShutdown) {
console.log("graceful shutdown timeout (10s), forcing termination...");
process.kill(pid, "SIGKILL");
console.log("agent process terminated forcefully");
}
if (fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
console.log("PID file cleaned up");
}
} catch (error) {
console.log("warning: error stopping agent process:", error.message);
}
})();
const log = path.join(agentDir, "agent.log");
if (fs.existsSync(log)) {
console.log("agent log:");
const content = fs.readFileSync(log, "utf-8");
console.log(content);
}
}
function sleep(ms) {
return new Promise((resolve) => {
+21 -10
View File
@@ -1,6 +1,6 @@
import * as core from "@actions/core";
import * as fs from "fs";
import { STEPSECURITY_API_URL, STEPSECURITY_WEB_URL } from "./configs";
import { getAnnotationLogs } from "./utils";
export function printInfo(web_url) {
console.log(
@@ -67,8 +67,11 @@ export async function addSummary() {
let needsSubscription = false;
try {
let data = fs.readFileSync("/home/agent/annotation.log", "utf8");
if (data.includes("StepSecurity Harden Runner is disabled")) {
let data = getAnnotationLogs(process.platform);
if (
data !== undefined &&
data.includes("StepSecurity Harden Runner is disabled")
) {
needsSubscription = true;
}
} catch (err) {
@@ -97,23 +100,25 @@ export async function addSummary() {
// Extract owner and repo from GITHUB_REPOSITORY (format: owner/repo)
const [owner, repo] = process.env["GITHUB_REPOSITORY"]?.split("/") || [];
const run_id = process.env["GITHUB_RUN_ID"];
if (!owner || !repo || !run_id || !correlation_id) {
return;
}
// 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 = 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;
}
const markdownSummary = await response.text();
// Render the markdown summary using core.summary.addRaw
await core.summary.addRaw(markdownSummary).write();
return;
@@ -128,8 +133,8 @@ export const STATUS_HARDEN_RUNNER_UNAVAILABLE = "409";
export const CONTAINER_MESSAGE =
"This job is running in a container. Such jobs can be monitored by installing Harden Runner in a custom VM image for GitHub-hosted runners.";
export const UBUNTU_MESSAGE =
"This job is not running in a GitHub Actions Hosted Runner Ubuntu VM. Harden Runner is only supported on Ubuntu VM. This job will not be monitored.";
export const UNSUPPORTED_RUNNER_MESSAGE =
"This job is not running in a GitHub Actions Hosted Runner. Harden Runner is only supported on GitHub-hosted runners (Ubuntu, Windows, and macOS). This job will not be monitored.";
export const SELF_HOSTED_RUNNER_MESSAGE =
"This job is running on a self-hosted runner.";
@@ -142,3 +147,9 @@ export const ARC_RUNNER_MESSAGE =
export const ARM64_RUNNER_MESSAGE =
"ARM runners are not supported in the Harden-Runner community tier.";
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.";
+3
View File
@@ -2,4 +2,7 @@ export const STEPSECURITY_ENV = "agent"; // agent or int
export const STEPSECURITY_API_URL = `https://${STEPSECURITY_ENV}.api.stepsecurity.io/v1`;
export const STEPSECURITY_TELEMETRY_URL =
"https://prod.app-api.stepsecurity.io/v1";
export const STEPSECURITY_WEB_URL = "https://app.stepsecurity.io";
+4 -2
View File
@@ -4,6 +4,8 @@ import isDocker from "is-docker";
import { STEPSECURITY_WEB_URL } from "./configs";
import { isGithubHosted } from "./tls-inspect";
import { context } from "@actions/github";
import { isPlatformSupported } from "./utils";
(async () => {
console.log("[harden-runner] main-step");
@@ -13,8 +15,8 @@ import { context } from "@actions/github";
return;
}
if (process.platform !== "linux") {
console.log(common.UBUNTU_MESSAGE);
if (!isPlatformSupported(process.platform)) {
console.log(common.UNSUPPORTED_RUNNER_MESSAGE);
return;
}
if (isGithubHosted() && isDocker()) {
+215 -4
View File
@@ -5,7 +5,8 @@ import * as path from "path";
import * as fs from "fs";
import { verifyChecksum } from "./checksum";
import { EOL } from "os";
import { ARM64_RUNNER_MESSAGE } from "./common";
import { ARM64_RUNNER_MESSAGE, ARM64_WINDOWS_RUNNER_MESSAGE } from "./common";
import { chownForFolder } from "./utils";
export async function installAgent(
isTLS: boolean,
@@ -25,7 +26,7 @@ export async function installAgent(
if (isTLS) {
downloadPath = await tc.downloadTool(
`https://github.com/step-security/agent-ebpf/releases/download/v1.7.9/harden-runner_1.7.9_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
);
@@ -35,13 +36,15 @@ export async function installAgent(
return false;
}
downloadPath = await tc.downloadTool(
"https://github.com/step-security/agent/releases/download/v0.14.2/agent_0.14.2_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
);
}
verifyChecksum(downloadPath, isTLS, variant);
if (!verifyChecksum(downloadPath, isTLS, variant, "linux")) {
return false;
}
const extractPath = await tc.extractTar(downloadPath);
@@ -65,3 +68,211 @@ export async function installAgent(
cp.execSync("sudo service agent start", { timeout: 15000 });
return true;
}
export async function installAgentBravo(configStr: string): Promise<boolean> {
// Note: to avoid github rate limiting
const token = core.getInput("token", { required: true });
const auth = `token ${token}`;
const variant = process.arch === "x64" ? "amd64" : "arm64";
const downloadPath = await tc.downloadTool(
`https://github.com/step-security/agent-ebpf/releases/download/v1.8.12/harden-runner-bravo_1.8.12_linux_${variant}.tar.gz`,
undefined,
auth
);
if (!verifyChecksum(downloadPath, true, variant, "linux", "bravo")) {
return false;
}
const extractPath = await tc.extractTar(downloadPath);
cp.execFileSync("cp", [path.join(extractPath, "agent"), "/home/agent/agent"]);
cp.execSync("chmod +x /home/agent/agent");
fs.writeFileSync("/home/agent/agent.json", configStr);
const logStream = fs.openSync("/home/agent/agent.stdout", "a");
const agentProcess = cp.spawn("sudo", ["/home/agent/agent"], {
cwd: "/home/agent",
detached: true,
stdio: ["ignore", logStream, logStream],
});
agentProcess.unref();
const agentStatus = "/home/agent/agent.status";
const deadline = Date.now() + 10000;
while (true) {
if (!fs.existsSync(agentStatus)) {
if (Date.now() >= deadline) {
console.log("timed out waiting for bravo agent");
if (fs.existsSync("/home/agent/agent.stdout")) {
console.log(fs.readFileSync("/home/agent/agent.stdout", "utf-8"));
}
if (fs.existsSync("/home/agent/agent.log")) {
console.log(fs.readFileSync("/home/agent/agent.log", "utf-8"));
}
break;
}
await new Promise((resolve) => setTimeout(resolve, 300));
} else {
console.log(fs.readFileSync(agentStatus, "utf-8"));
break;
}
}
return true;
}
export async function installMacosAgent(configStr: string): Promise<boolean> {
const token = core.getInput("token", { required: true });
const auth = `token ${token}`;
try {
// Create working directory
core.info("Creating /opt/step-security directory...");
cp.execSync("sudo mkdir -p /opt/step-security");
chownForFolder(process.env.USER, "/opt/step-security");
core.info("✓ Successfully created /opt/step-security directory");
// Create agent configuration file
core.info("Creating agent.json");
fs.writeFileSync("/opt/step-security/agent.json", configStr);
core.info(
"✓ Successfully created agent.json at /opt/step-security/agent.json"
);
// Download installer package
const downloadUrl =
"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}`);
// Verify SHA256 checksum
core.info("Verifying SHA256 checksum of downloaded tar file...");
if (!verifyChecksum(downloadPath, false, "", "darwin")) {
return false;
}
// Extract installer package
core.info("Extracting installer...");
const extractPath = await tc.extractTar(downloadPath);
core.info(`✓ Successfully extracted installer to: ${extractPath}`);
// Copy Installer binary to /opt/step-security
const installerSourcePath = path.join(extractPath, "Installer");
const installerBinaryPath = "/opt/step-security/Installer";
core.info(
`Copying Installer from ${installerSourcePath} to /opt/step-security...`
);
cp.execFileSync("cp", [installerSourcePath, installerBinaryPath]);
core.info("✓ Successfully copied Installer to /opt/step-security");
// Verify installer binary exists
if (!fs.existsSync(installerBinaryPath)) {
throw new Error(
"Installer binary not found at /opt/step-security/Installer"
);
}
core.info("✓ Installer binary verified");
// Make installer executable
core.info("Making installer executable...");
cp.execSync(`chmod +x "${installerBinaryPath}"`);
core.info("✓ Installer is now executable");
// Run installer
core.info("Running installer...");
cp.execSync(
`sudo "${installerBinaryPath}" -workdir /opt/step-security >> /opt/step-security/agent.log 2>&1`,
{
shell: "/bin/bash",
timeout: 10000, // 10 second timeout
}
);
core.info("✓ Installer completed successfully");
core.info("✅ macOS agent installation completed successfully");
return true;
} catch (error) {
core.error(`❌ Failed to install macOS agent: ${error}`);
if (error instanceof Error && error.stack) {
core.debug(error.stack);
}
return false;
}
}
export async function installWindowsAgent(configStr: string): Promise<boolean> {
const token = core.getInput("token", { required: true });
const auth = `token ${token}`;
const variant = process.arch === "x64" ? "amd64" : "arm64";
if (variant === "arm64") {
console.log(ARM64_WINDOWS_RUNNER_MESSAGE);
return false;
}
const agentDir = "C:\\agent";
core.info(`Creating agent directory: ${agentDir}`);
if (!fs.existsSync(agentDir)) {
fs.mkdirSync(agentDir, { recursive: true });
}
fs.appendFileSync(process.env.GITHUB_STATE, `agentDir=${agentDir}${EOL}`, {
encoding: "utf8",
});
const agentExePath = path.join(agentDir, "agent.exe");
const downloadPath = await tc.downloadTool(
`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
);
// validate the checksum
if (!verifyChecksum(downloadPath, false, variant, process.platform)) {
return false;
}
const extractPath = await tc.extractTar(downloadPath);
const extractedAgentPath = path.join(extractPath, "agent.exe");
fs.copyFileSync(extractedAgentPath, agentExePath);
core.info(`Copied agent from ${extractedAgentPath} to ${agentExePath}`);
const configPath = path.join(agentDir, "config.json");
fs.writeFileSync(configPath, configStr);
core.info(`Created config file: ${configPath}`);
core.info("Starting Windows Agent...");
try {
const logPath = path.join(agentDir, "agent.log");
const logStream = fs.openSync(logPath, "a");
core.info(`Agent logs will be written to: ${logPath}`);
const agentProcess = cp.spawn(agentExePath, [], {
cwd: agentDir,
detached: true,
stdio: ["ignore", logStream, logStream],
windowsHide: false,
shell: false,
});
const pidFile = path.join(agentDir, "agent.pid");
fs.writeFileSync(pidFile, agentProcess.pid.toString());
core.info(`Agent process started with PID: ${agentProcess.pid}`);
core.info(`PID saved to: ${pidFile}`);
agentProcess.unref();
core.info("Windows Agent process started successfully");
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
core.setFailed(`Failed to start Windows agent process: ${errorMessage}`);
return false;
}
}
+5
View File
@@ -4,6 +4,7 @@ export interface Configuration {
correlation_id: string;
working_directory: string;
api_url: string;
telemetry_url: string;
allowed_endpoints: string;
egress_policy: string;
disable_telemetry: boolean;
@@ -14,6 +15,9 @@ export interface Configuration {
private: string;
is_debug: boolean;
one_time_key: string;
api_key: string;
use_policy_store: boolean;
deploy_on_self_hosted_vm: boolean;
}
export interface PolicyResponse {
@@ -25,4 +29,5 @@ export interface PolicyResponse {
disable_file_monitoring?: boolean;
disable_telemetry?: boolean;
egress_policy?: string;
policy_name?: string;
}
+332 -15
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",
@@ -32,6 +246,7 @@ test("merge configs", async () => {
correlation_id: "aaaaa",
working_directory: "/xyz",
api_url: "xyz",
telemetry_url: "xyz",
allowed_endpoints: "",
egress_policy: "audit",
disable_telemetry: false,
@@ -42,8 +257,11 @@ test("merge configs", async () => {
is_github_hosted: true,
is_debug: false,
one_time_key: "",
api_key: "",
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"],
@@ -52,13 +270,13 @@ 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",
working_directory: "/xyz",
api_url: "xyz",
telemetry_url: "xyz",
allowed_endpoints: "github.com:443 google.com:443",
egress_policy: "audit",
disable_telemetry: false,
@@ -69,8 +287,107 @@ test("merge configs", async () => {
is_github_hosted: true,
is_debug: false,
one_time_key: "",
api_key: "",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
localConfig = mergeConfigs(localConfig, policyResponse);
expect(localConfig).toStrictEqual(expectedConfiguration);
});
test("mergeConfigs does not override local allowed_endpoints if not empty", () => {
let localConfig: Configuration = {
repo: "test/repo",
run_id: "xyx",
correlation_id: "aaaaa",
working_directory: "/xyz",
api_url: "xyz",
telemetry_url: "xyz",
allowed_endpoints: "local.endpoint:443",
egress_policy: "audit",
disable_telemetry: false,
disable_sudo: false,
disable_sudo_and_containers: false,
disable_file_monitoring: false,
private: "true",
is_github_hosted: true,
is_debug: false,
one_time_key: "",
api_key: "",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
const policyResponse: PolicyResponse = {
allowed_endpoints: ["remote.endpoint:443"],
egress_policy: "block",
};
localConfig = mergeConfigs(localConfig, policyResponse);
expect(localConfig.allowed_endpoints).toBe("local.endpoint:443");
expect(localConfig.egress_policy).toBe("block");
});
test("mergeConfigs overrides disable_sudo_and_containers from remote", () => {
let localConfig: Configuration = {
repo: "test/repo",
run_id: "xyx",
correlation_id: "aaaaa",
working_directory: "/xyz",
api_url: "xyz",
telemetry_url: "xyz",
allowed_endpoints: "",
egress_policy: "audit",
disable_telemetry: false,
disable_sudo: false,
disable_sudo_and_containers: false,
disable_file_monitoring: false,
private: "true",
is_github_hosted: true,
is_debug: false,
one_time_key: "",
api_key: "",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
const policyResponse: PolicyResponse = {
allowed_endpoints: [],
disable_sudo_and_containers: true,
};
localConfig = mergeConfigs(localConfig, policyResponse);
expect(localConfig.disable_sudo_and_containers).toBe(true);
});
test("mergeConfigs does not override fields when remote values are undefined", () => {
let localConfig: Configuration = {
repo: "test/repo",
run_id: "xyx",
correlation_id: "aaaaa",
working_directory: "/xyz",
api_url: "xyz",
telemetry_url: "xyz",
allowed_endpoints: "",
egress_policy: "block",
disable_telemetry: false,
disable_sudo: true,
disable_sudo_and_containers: true,
disable_file_monitoring: true,
private: "true",
is_github_hosted: true,
is_debug: false,
one_time_key: "",
api_key: "",
use_policy_store: false,
deploy_on_self_hosted_vm: false,
};
const policyResponse: PolicyResponse = {
allowed_endpoints: [],
};
localConfig = mergeConfigs(localConfig, policyResponse);
expect(localConfig.disable_sudo).toBe(true);
expect(localConfig.disable_sudo_and_containers).toBe(true);
expect(localConfig.disable_file_monitoring).toBe(true);
expect(localConfig.egress_policy).toBe("block");
});
+89 -23
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,102 @@ 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(
owner: string,
repo: string,
apiKey: string,
workflow: string,
runId: string,
correlationId: string
): Promise<PolicyResponse | null> {
if (apiKey === "") {
throw new Error("[PolicyStoreFetch]: api-key is empty");
}
const policyEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/${repo}/actions/policies/workflow-policy?workflow=${encodeURIComponent(workflow)}&run_id=${encodeURIComponent(runId)}&correlationId=${encodeURIComponent(correlationId)}`;
const headers = {
Authorization: `vm-api-key ${apiKey}`,
Source: "github-actions",
};
let result: PolicyResponse | undefined;
let err: unknown;
for (let retry = 0; retry < 3; retry++) {
try {
console.log(`Attempt: ${retry + 1}`);
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);
}
}
if (result === undefined) {
const error = new Error(`[Policy Store Fetch] ${err}`);
if (err && typeof err === "object" && "statusCode" in err) {
(error as any).statusCode = (err as { statusCode: unknown }).statusCode;
}
throw error;
}
if (!result || (!result.egress_policy && (!result.allowed_endpoints || result.allowed_endpoints.length === 0))) {
return null;
}
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(
+282 -29
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";
@@ -16,7 +15,7 @@ import {
isValidEvent,
} from "./cache";
import { Configuration, PolicyResponse } from "./interfaces";
import { fetchPolicy, mergeConfigs } from "./policy-utils";
import { fetchPolicy, fetchPolicyFromStore, mergeConfigs } from "./policy-utils";
import * as cache from "@actions/cache";
import { getCacheEntry } from "@actions/cache/lib/internal/cacheHttpClient";
import * as cacheTwirpClient from "@actions/cache/lib/internal/shared/cacheTwirpClient";
@@ -25,9 +24,21 @@ import { getCacheServiceVersion } from "@actions/cache/lib/internal/config";
import * as utils from "@actions/cache/lib/internal/cacheUtils";
import { isARCRunner, sendAllowedEndpoints } from "./arc-runner";
import { STEPSECURITY_API_URL, STEPSECURITY_WEB_URL } from "./configs";
import {
STEPSECURITY_API_URL,
STEPSECURITY_TELEMETRY_URL,
STEPSECURITY_WEB_URL,
} from "./configs";
import { isGithubHosted, isTLSEnabled } from "./tls-inspect";
import { installAgent } from "./install-agent";
import {
installAgent,
installAgentBravo,
installMacosAgent,
installWindowsAgent,
} from "./install-agent";
import { chownForFolder, detectThirdPartyRunnerProvider, isAgentInstalled, isPlatformSupported, shouldDeployAgentOnSelfHosted } from "./utils";
import { buildBravoConfig } from "./bravo-config";
interface MonitorResponse {
runner_ip_address?: string;
@@ -35,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");
@@ -45,8 +67,8 @@ interface MonitorResponse {
return;
}
if (process.platform !== "linux") {
console.log(common.UBUNTU_MESSAGE);
if (!isPlatformSupported(process.platform)) {
console.log(common.UNSUPPORTED_RUNNER_MESSAGE);
return;
}
if (isGithubHosted() && isDocker()) {
@@ -54,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;
@@ -64,6 +91,7 @@ interface MonitorResponse {
correlation_id: correlation_id,
working_directory: process.env["GITHUB_WORKSPACE"],
api_url: api_url,
telemetry_url: STEPSECURITY_TELEMETRY_URL,
allowed_endpoints: core.getInput("allowed-endpoints"),
egress_policy: core.getInput("egress-policy"),
disable_telemetry: core.getBooleanInput("disable-telemetry"),
@@ -76,10 +104,55 @@ interface MonitorResponse {
is_github_hosted: isGithubHosted(),
is_debug: core.isDebug(),
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 !== "") {
core.setSecret(confg.api_key);
}
let policyName = core.getInput("policy");
if (policyName !== "") {
if (confg.use_policy_store) {
console.log(`Fetching policy from policy store`);
if (confg.api_key === "") {
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] || "";
const workflowRef = process.env["GITHUB_WORKFLOW_REF"] || "";
const workflow = workflowRef.replace(/.*\.github\/workflows\//, "").replace(/@.*/, "");
let result: PolicyResponse | null = await fetchPolicyFromStore(
context.repo.owner,
repoName,
confg.api_key,
workflow,
confg.run_id,
confg.correlation_id
);
if (result !== null) {
core.info(`Policy found: ${result.policy_name || "unnamed"}`);
confg = mergeConfigs(confg, result);
} else {
core.info("No policy found in policy store. Defaulting to audit mode.");
confg.egress_policy = "audit";
}
} catch (err) {
core.info(`[!] ${err}`);
if (err.statusCode >= 400 && err.statusCode < 500) {
core.info("Policy not found in policy store. Defaulting to audit mode.");
confg.egress_policy = "audit";
} else {
core.error(`Unexpected error fetching from policy store: ${err}. Falling back to audit mode.`);
confg.egress_policy = "audit";
}
}
}
} else if (policyName !== "") {
console.log(`Fetching policy from API with name: ${policyName}`);
try {
let idToken: string = await core.getIDToken();
@@ -236,13 +309,57 @@ interface MonitorResponse {
const runnerName = process.env.RUNNER_NAME || "";
core.info(`RUNNER_NAME: ${runnerName}`);
if (!isGithubHosted()) {
const thirdPartyProvider = detectThirdPartyRunnerProvider();
if (thirdPartyProvider) {
const providerLabel = thirdPartyProvider.charAt(0).toUpperCase() + thirdPartyProvider.slice(1);
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);
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}`, {
encoding: "utf8",
});
core.info(common.SELF_HOSTED_RUNNER_MESSAGE);
if (confg.egress_policy === "block") {
const inContainer = isDocker();
const alreadyInstalled = isAgentInstalled(process.platform);
if (shouldDeployAgentOnSelfHosted(confg.deploy_on_self_hosted_vm, inContainer, alreadyInstalled)) {
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 && inContainer) {
core.info("Skipping agent deployment: running inside a container.");
}
if (confg.deploy_on_self_hosted_vm && alreadyInstalled) {
core.info("Agent already installed on self-hosted runner, skipping installation.");
}
}
if (confg.egress_policy === "block" && !confg.deploy_on_self_hosted_vm) {
sendAllowedEndpoints(confg.allowed_endpoints);
await sleep(5000);
}
@@ -263,27 +380,30 @@ interface MonitorResponse {
return;
}
if (isGithubHosted() && fs.existsSync("/home/agent/agent.status")) {
if (isGithubHosted() && isAgentInstalled(process.platform)) {
console.log("Agent already installed, skipping installation");
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}`,
@@ -321,18 +441,48 @@ interface MonitorResponse {
return;
}
const confgStr = JSON.stringify(confg);
cp.execSync("sudo mkdir -p /home/agent");
chownForFolder(process.env.USER, "/home/agent");
const { api_key, use_policy_store, ...agentConfig } = confg;
const configStr = JSON.stringify(agentConfig);
let isTLS = await isTLSEnabled(context.repo.owner);
// platform specific
let statusFile = "";
let logFile = "";
let agentInstalled = false;
const agentInstalled = await installAgent(isTLS, confgStr);
switch (process.platform) {
case "linux":
statusFile = "/home/agent/agent.status";
logFile = "/home/agent/agent.log";
cp.execSync("sudo mkdir -p /home/agent");
chownForFolder(process.env.USER, "/home/agent");
let isTLS = await isTLSEnabled(context.repo.owner);
agentInstalled = await installAgent(isTLS, configStr);
break;
case "win32":
core.info("Installing Windows Agent...");
agentInstalled = await installWindowsAgent(configStr);
const agentDir = process.env.STATE_agentDir || "C:\\agent";
statusFile = path.join(agentDir, "agent.status");
logFile = path.join(agentDir, "agent.log");
break;
case "darwin":
const installed = await installMacosAgent(configStr);
if (!installed) {
core.warning("😭 macos agent installation failed");
}
return; // early return
default:
throw new Error(
`Setup failed because of unsupported platform: ${process.platform}`
);
}
if (agentInstalled) {
// Check that the file exists locally
var statusFile = "/home/agent/agent.status";
var logFile = "/home/agent/agent.log";
var counter = 0;
while (true) {
if (!fs.existsSync(statusFile)) {
@@ -368,8 +518,111 @@ export function sleep(ms: number) {
});
}
function chownForFolder(newOwner: string, target: string) {
let cmd = "sudo";
let args = ["chown", "-R", newOwner, target];
cp.execFileSync(cmd, args);
async function callMonitorEndpoint(api_url: string, confg: Configuration) {
let statusCode: number | undefined;
let addSummary = "false";
try {
const monitorRequestData = {
correlation_id: confg.correlation_id,
job: process.env["GITHUB_JOB"],
};
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}`);
}
fs.appendFileSync(process.env.GITHUB_STATE, `monitorStatusCode=${statusCode}${EOL}`, { encoding: "utf8" });
fs.appendFileSync(process.env.GITHUB_STATE, `addSummary=${addSummary}${EOL}`, { encoding: "utf8" });
fs.appendFileSync(process.env.GITHUB_STATE, `correlation_id=${confg.correlation_id}${EOL}`, { encoding: "utf8" });
}
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,
api_key: uuidv4(),
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}`);
}
}
export async function installAgentForBravo(owner: string, bravoConfigStr: string) {
try {
console.log("Installing Harden Runner bravo agent for third-party runner");
let isTLS = await isTLSEnabled(owner);
if (!isTLS) {
console.log("TLS is not enabled for this organization. Bravo agent installation skipped.");
return;
}
cp.execSync("sudo mkdir -p /home/agent");
chownForFolder(process.env.USER, "/home/agent");
await installAgentBravo(bravoConfigStr);
} catch (error) {
console.log(`Failed to install bravo agent: ${error.message}`);
}
}
+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() {
+164
View File
@@ -0,0 +1,164 @@
import { shouldDeployAgentOnSelfHosted, isAgentInstalled, isPlatformSupported, getAnnotationLogs, detectThirdPartyRunnerProvider } from "./utils";
import * as fs from "fs";
jest.mock("fs", () => ({
...jest.requireActual("fs"),
existsSync: jest.fn(),
}));
const mockedExistsSync = fs.existsSync as jest.MockedFunction<typeof fs.existsSync>;
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", () => {
afterEach(() => {
mockedExistsSync.mockReset();
});
test("returns false for linux when status file does not exist", () => {
mockedExistsSync.mockReturnValue(false);
expect(isAgentInstalled("linux")).toBe(false);
expect(mockedExistsSync).toHaveBeenCalledWith("/home/agent/agent.status");
});
test("returns true for linux when status file exists", () => {
mockedExistsSync.mockReturnValue(true);
expect(isAgentInstalled("linux")).toBe(true);
});
test("returns false for win32 when status file does not exist", () => {
mockedExistsSync.mockReturnValue(false);
expect(isAgentInstalled("win32")).toBe(false);
expect(mockedExistsSync).toHaveBeenCalledWith("C:\\agent\\agent.status");
});
test("returns false for darwin when status file does not exist", () => {
mockedExistsSync.mockReturnValue(false);
expect(isAgentInstalled("darwin")).toBe(false);
expect(mockedExistsSync).toHaveBeenCalledWith("/opt/step-security/agent.status");
});
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");
});
});
describe("detectThirdPartyRunnerProvider", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.DEPOT_RUNNER;
delete process.env.NAMESPACE_GITHUB_RUNTIME;
delete process.env.BITRISE_IO;
delete process.env.RUNNER_NAME;
});
afterAll(() => {
process.env = originalEnv;
});
test("returns depot when DEPOT_RUNNER=1", () => {
process.env.DEPOT_RUNNER = "1";
expect(detectThirdPartyRunnerProvider()).toBe("depot");
});
test("returns null when DEPOT_RUNNER=0", () => {
process.env.DEPOT_RUNNER = "0";
expect(detectThirdPartyRunnerProvider()).toBeNull();
});
test("returns namespace when NAMESPACE_GITHUB_RUNTIME is set", () => {
process.env.NAMESPACE_GITHUB_RUNTIME = "something";
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");
});
test("returns blacksmith for RUNNER_NAME prefix blacksmith-", () => {
process.env.RUNNER_NAME = "blacksmith-01kpj-4vcpu";
expect(detectThirdPartyRunnerProvider()).toBe("blacksmith");
});
test("returns null when no env vars match", () => {
expect(detectThirdPartyRunnerProvider()).toBeNull();
});
test("returns null for a non-matching RUNNER_NAME", () => {
process.env.RUNNER_NAME = "GitHub Actions 1";
expect(detectThirdPartyRunnerProvider()).toBeNull();
});
test("depot takes precedence over namespace", () => {
process.env.DEPOT_RUNNER = "1";
process.env.NAMESPACE_GITHUB_RUNTIME = "something";
expect(detectThirdPartyRunnerProvider()).toBe("depot");
});
test("namespace takes precedence over warp runner name prefix", () => {
process.env.NAMESPACE_GITHUB_RUNTIME = "something";
process.env.RUNNER_NAME = "warp-x";
expect(detectThirdPartyRunnerProvider()).toBe("namespace");
});
test("warp takes precedence over blacksmith when both prefixes seen (warp wins on name check order)", () => {
process.env.RUNNER_NAME = "warp-x";
expect(detectThirdPartyRunnerProvider()).toBe("warp");
});
});
+66
View File
@@ -0,0 +1,66 @@
import * as cp from "child_process";
import * as fs from "fs";
export function isPlatformSupported(platform: NodeJS.Platform) {
switch (platform) {
case "linux":
case "win32":
case "darwin":
return true;
default:
return false;
}
}
export function chownForFolder(newOwner: string, target: string) {
let cmd = "sudo";
let args = ["chown", "-R", newOwner, target];
cp.execFileSync(cmd, args);
}
export function isAgentInstalled(platform: NodeJS.Platform) {
switch (platform) {
case "linux":
return fs.existsSync("/home/agent/agent.status");
case "win32":
return fs.existsSync("C:\\agent\\agent.status");
case "darwin":
return fs.existsSync("/opt/step-security/agent.status");
default:
return false;
}
}
export function shouldDeployAgentOnSelfHosted(
deployOnSelfHostedVm: boolean,
isContainer: boolean,
agentAlreadyInstalled: boolean
): boolean {
return deployOnSelfHostedVm && !isContainer && !agentAlreadyInstalled;
}
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";
return null;
}
export function getAnnotationLogs(platform: NodeJS.Platform) {
switch (platform) {
case "linux":
return fs.readFileSync("/home/agent/annotation.log", "utf8");
case "win32":
return fs.readFileSync("C:\\agent\\annotation.log", "utf8");
case "darwin":
return fs.readFileSync("/opt/step-security/annotation.log", "utf8");
default:
throw new Error("platform not supported");
}
}