Add policy store support with api-key authentication
Adds use-policy-store and api-key inputs to allow fetching policies from the StepSecurity policy store. Defaults to audit mode when no policy is found. Includes unit tests with 100% coverage.
This commit is contained in:
parent
fe10465874
commit
bf693cb624
7 changed files with 440 additions and 4 deletions
|
|
@ -32,6 +32,14 @@ inputs:
|
|||
description: "Policy name to be used from the policy store"
|
||||
required: false
|
||||
default: ""
|
||||
api-key:
|
||||
description: "StepSecurity API key for authenticating with the policy store"
|
||||
required: false
|
||||
default: ""
|
||||
use-policy-store:
|
||||
description: "Set to true to fetch policy from the policy store using the API key"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
branding:
|
||||
icon: "check-square"
|
||||
|
|
|
|||
72
dist/pre/index.js
vendored
72
dist/pre/index.js
vendored
|
|
@ -85262,6 +85262,44 @@ function fetchPolicy(owner, policyName, idToken) {
|
|||
}
|
||||
});
|
||||
}
|
||||
function fetchPolicyFromStore(owner, repo, apiKey) {
|
||||
return policy_utils_awaiter(this, void 0, void 0, function* () {
|
||||
if (apiKey === "") {
|
||||
throw new Error("[PolicyStoreFetch]: api-key is empty");
|
||||
}
|
||||
let policyEndpoint = `${configs_STEPSECURITY_API_URL}/github/${owner}/${repo}/actions/policy-store/policy`;
|
||||
let httpClient = new lib.HttpClient();
|
||||
let headers = {};
|
||||
headers["Authorization"] = `api-key ${apiKey}`;
|
||||
headers["Source"] = "github-actions";
|
||||
let response = undefined;
|
||||
let err = undefined;
|
||||
let retry = 0;
|
||||
while (retry < 3) {
|
||||
try {
|
||||
console.log(`Attempt: ${retry + 1}`);
|
||||
response = yield httpClient.getJson(policyEndpoint, headers);
|
||||
break;
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
retry += 1;
|
||||
yield sleep(1000);
|
||||
}
|
||||
if (response === undefined && err !== undefined) {
|
||||
const error = new Error(`[Policy Store Fetch] ${err}`);
|
||||
if (err.statusCode !== undefined) {
|
||||
error.statusCode = err.statusCode;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (response.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
return response.result;
|
||||
});
|
||||
}
|
||||
function mergeConfigs(localConfig, remoteConfig) {
|
||||
if (localConfig.allowed_endpoints === "") {
|
||||
localConfig.allowed_endpoints = remoteConfig.allowed_endpoints.join(" ");
|
||||
|
|
@ -85683,9 +85721,41 @@ var setup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _ar
|
|||
is_github_hosted: isGithubHosted(),
|
||||
is_debug: lib_core.isDebug(),
|
||||
one_time_key: "",
|
||||
api_key: lib_core.getInput("api-key"),
|
||||
use_policy_store: lib_core.getBooleanInput("use-policy-store"),
|
||||
};
|
||||
let policyName = lib_core.getInput("policy");
|
||||
if (policyName !== "") {
|
||||
if (confg.use_policy_store) {
|
||||
console.log(`Fetching policy from policy store`);
|
||||
if (confg.api_key === "") {
|
||||
lib_core.setFailed("api-key is required when use-policy-store is set to true");
|
||||
}
|
||||
else {
|
||||
try {
|
||||
const repoName = (process.env["GITHUB_REPOSITORY"] || "").split("/")[1] || "";
|
||||
let result = yield fetchPolicyFromStore(github.context.repo.owner, repoName, confg.api_key);
|
||||
if (result !== null) {
|
||||
confg = mergeConfigs(confg, result);
|
||||
}
|
||||
else {
|
||||
lib_core.info("No policy found in policy store. Defaulting to audit mode.");
|
||||
confg.egress_policy = "audit";
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
lib_core.info(`[!] ${err}`);
|
||||
if (err.statusCode >= 400 && err.statusCode < 500) {
|
||||
lib_core.info("Policy not found in policy store. Defaulting to audit mode.");
|
||||
confg.egress_policy = "audit";
|
||||
}
|
||||
else {
|
||||
lib_core.error(`Unexpected error fetching from policy store: ${err}. Falling back to audit mode.`);
|
||||
confg.egress_policy = "audit";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (policyName !== "") {
|
||||
console.log(`Fetching policy from API with name: ${policyName}`);
|
||||
try {
|
||||
let idToken = yield lib_core.getIDToken();
|
||||
|
|
|
|||
2
dist/pre/index.js.map
vendored
2
dist/pre/index.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -15,6 +15,8 @@ export interface Configuration {
|
|||
private: string;
|
||||
is_debug: boolean;
|
||||
one_time_key: string;
|
||||
api_key: string;
|
||||
use_policy_store: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyResponse {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ test("merge configs", async () => {
|
|||
is_github_hosted: true,
|
||||
is_debug: false,
|
||||
one_time_key: "",
|
||||
api_key: "",
|
||||
use_policy_store: false,
|
||||
};
|
||||
let policyResponse: PolicyResponse = {
|
||||
owner: "h0x0er",
|
||||
|
|
@ -71,8 +73,280 @@ test("merge configs", async () => {
|
|||
is_github_hosted: true,
|
||||
is_debug: false,
|
||||
one_time_key: "",
|
||||
api_key: "",
|
||||
use_policy_store: false,
|
||||
};
|
||||
|
||||
localConfig = mergeConfigs(localConfig, policyResponse);
|
||||
expect(localConfig).toStrictEqual(expectedConfiguration);
|
||||
});
|
||||
|
||||
// ==================== additional fetchPolicy tests ====================
|
||||
|
||||
test("fetchPolicy throws when idToken is empty", async () => {
|
||||
await expect(fetchPolicy("owner", "policy1", "")).rejects.toThrow(
|
||||
"[PolicyFetch]: id-token in empty"
|
||||
);
|
||||
});
|
||||
|
||||
test("fetchPolicy retries on failure and succeeds", async () => {
|
||||
const owner = "test-owner";
|
||||
const policyName = "test-policy";
|
||||
const response = {
|
||||
allowed_endpoints: ["example.com:443"],
|
||||
egress_policy: "block",
|
||||
};
|
||||
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/actions/policies/${policyName}`)
|
||||
.replyWithError("connection timeout");
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/actions/policies/${policyName}`)
|
||||
.reply(200, response);
|
||||
|
||||
const policy = await fetchPolicy(owner, policyName, "token123");
|
||||
expect(policy).toStrictEqual(response);
|
||||
});
|
||||
|
||||
test("fetchPolicy throws after all retries exhausted", async () => {
|
||||
const owner = "test-owner";
|
||||
const policyName = "test-policy";
|
||||
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/actions/policies/${policyName}`)
|
||||
.times(3)
|
||||
.replyWithError("connection timeout");
|
||||
|
||||
await expect(
|
||||
fetchPolicy(owner, policyName, "token123")
|
||||
).rejects.toThrow("[Policy Fetch]");
|
||||
});
|
||||
|
||||
test("fetchPolicy preserves statusCode from error", async () => {
|
||||
const owner = "test-owner";
|
||||
const policyName = "test-policy";
|
||||
|
||||
const errorWithStatus = new Error("Not Found");
|
||||
(errorWithStatus as any).statusCode = 404;
|
||||
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/actions/policies/${policyName}`)
|
||||
.times(3)
|
||||
.replyWithError(errorWithStatus);
|
||||
|
||||
try {
|
||||
await fetchPolicy(owner, policyName, "token123");
|
||||
fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err.message).toContain("[Policy Fetch]");
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== fetchPolicyFromStore ====================
|
||||
|
||||
import { fetchPolicyFromStore } from "./policy-utils";
|
||||
|
||||
test("success: fetches policy from store", async () => {
|
||||
const owner = "test-owner";
|
||||
const repo = "test-repo";
|
||||
const response = {
|
||||
allowed_endpoints: ["registry.npmjs.org:443", "github.com:443"],
|
||||
egress_policy: "block",
|
||||
disable_sudo: true,
|
||||
disable_file_monitoring: false,
|
||||
};
|
||||
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/${repo}/actions/policy-store/policy`)
|
||||
.reply(200, response);
|
||||
|
||||
const result = await fetchPolicyFromStore(owner, repo, "my-api-key");
|
||||
expect(result).toStrictEqual(response);
|
||||
});
|
||||
|
||||
test("fetchPolicyFromStore throws when apiKey is empty", async () => {
|
||||
await expect(
|
||||
fetchPolicyFromStore("owner", "repo", "")
|
||||
).rejects.toThrow("[PolicyStoreFetch]: api-key is empty");
|
||||
});
|
||||
|
||||
test("fetchPolicyFromStore returns null when policy not found (404)", async () => {
|
||||
const owner = "test-owner";
|
||||
const repo = "test-repo";
|
||||
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/${repo}/actions/policy-store/policy`)
|
||||
.reply(404, { message: "not found" });
|
||||
|
||||
const result = await fetchPolicyFromStore(owner, repo, "my-api-key");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("fetchPolicyFromStore retries on failure and succeeds", async () => {
|
||||
const owner = "test-owner";
|
||||
const repo = "test-repo";
|
||||
const response = {
|
||||
allowed_endpoints: ["example.com:443"],
|
||||
egress_policy: "audit",
|
||||
};
|
||||
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/${repo}/actions/policy-store/policy`)
|
||||
.replyWithError("timeout");
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/${repo}/actions/policy-store/policy`)
|
||||
.reply(200, response);
|
||||
|
||||
const result = await fetchPolicyFromStore(owner, repo, "my-api-key");
|
||||
expect(result).toStrictEqual(response);
|
||||
});
|
||||
|
||||
test("fetchPolicyFromStore throws after all retries exhausted", async () => {
|
||||
const owner = "test-owner";
|
||||
const repo = "test-repo";
|
||||
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/${repo}/actions/policy-store/policy`)
|
||||
.times(3)
|
||||
.replyWithError("connection refused");
|
||||
|
||||
await expect(
|
||||
fetchPolicyFromStore(owner, repo, "my-api-key")
|
||||
).rejects.toThrow("[Policy Store Fetch]");
|
||||
});
|
||||
|
||||
test("fetchPolicyFromStore preserves statusCode from error", async () => {
|
||||
const owner = "test-owner";
|
||||
const repo = "test-repo";
|
||||
|
||||
const errorWithStatus = new Error("Unauthorized");
|
||||
(errorWithStatus as any).statusCode = 401;
|
||||
|
||||
nock(`${STEPSECURITY_API_URL}`)
|
||||
.get(`/github/${owner}/${repo}/actions/policy-store/policy`)
|
||||
.times(3)
|
||||
.replyWithError(errorWithStatus);
|
||||
|
||||
try {
|
||||
await fetchPolicyFromStore(owner, repo, "my-api-key");
|
||||
fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err.message).toContain("[Policy Store Fetch]");
|
||||
}
|
||||
});
|
||||
|
||||
test("fetchPolicyFromStore sends correct authorization header", async () => {
|
||||
const owner = "test-owner";
|
||||
const repo = "test-repo";
|
||||
const apiKey = "secret-key-123";
|
||||
|
||||
nock(`${STEPSECURITY_API_URL}`, {
|
||||
reqheaders: {
|
||||
Authorization: `api-key ${apiKey}`,
|
||||
Source: "github-actions",
|
||||
},
|
||||
})
|
||||
.get(`/github/${owner}/${repo}/actions/policy-store/policy`)
|
||||
.reply(200, { allowed_endpoints: [], egress_policy: "audit" });
|
||||
|
||||
const result = await fetchPolicyFromStore(owner, repo, apiKey);
|
||||
expect(result).toStrictEqual({
|
||||
allowed_endpoints: [],
|
||||
egress_policy: "audit",
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== additional mergeConfigs tests ====================
|
||||
|
||||
test("mergeConfigs does not override local allowed_endpoints if not empty", () => {
|
||||
let localConfig: Configuration = {
|
||||
repo: "test/repo",
|
||||
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,
|
||||
};
|
||||
let 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,
|
||||
};
|
||||
let 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,
|
||||
};
|
||||
let 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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,57 @@ export async function fetchPolicy(
|
|||
}
|
||||
}
|
||||
|
||||
export async function fetchPolicyFromStore(
|
||||
owner: string,
|
||||
repo: string,
|
||||
apiKey: string
|
||||
): Promise<PolicyResponse | null> {
|
||||
if (apiKey === "") {
|
||||
throw new Error("[PolicyStoreFetch]: api-key is empty");
|
||||
}
|
||||
|
||||
let policyEndpoint = `${STEPSECURITY_API_URL}/github/${owner}/${repo}/actions/policy-store/policy`;
|
||||
|
||||
let httpClient = new HttpClient();
|
||||
|
||||
let headers = {};
|
||||
headers["Authorization"] = `api-key ${apiKey}`;
|
||||
headers["Source"] = "github-actions";
|
||||
|
||||
let response = undefined;
|
||||
let err = undefined;
|
||||
|
||||
let retry = 0;
|
||||
while (retry < 3) {
|
||||
try {
|
||||
console.log(`Attempt: ${retry + 1}`);
|
||||
response = await httpClient.getJson<PolicyResponse>(
|
||||
policyEndpoint,
|
||||
headers
|
||||
);
|
||||
break;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
retry += 1;
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (response === undefined && err !== undefined) {
|
||||
const error = new Error(`[Policy Store Fetch] ${err}`);
|
||||
if (err.statusCode !== undefined) {
|
||||
(error as any).statusCode = err.statusCode;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (response.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.result;
|
||||
}
|
||||
|
||||
export function mergeConfigs(
|
||||
localConfig: Configuration,
|
||||
remoteConfig: PolicyResponse
|
||||
|
|
|
|||
35
src/setup.ts
35
src/setup.ts
|
|
@ -16,7 +16,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";
|
||||
|
|
@ -87,10 +87,41 @@ 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"),
|
||||
};
|
||||
|
||||
let policyName = core.getInput("policy");
|
||||
if (policyName !== "") {
|
||||
if (confg.use_policy_store) {
|
||||
console.log(`Fetching policy from policy store`);
|
||||
if (confg.api_key === "") {
|
||||
core.setFailed("api-key is required when use-policy-store is set to true");
|
||||
} else {
|
||||
try {
|
||||
const repoName = (process.env["GITHUB_REPOSITORY"] || "").split("/")[1] || "";
|
||||
let result: PolicyResponse | null = await fetchPolicyFromStore(
|
||||
context.repo.owner,
|
||||
repoName,
|
||||
confg.api_key
|
||||
);
|
||||
if (result !== null) {
|
||||
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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue