mirror of
https://github.com/step-security/harden-runner.git
synced 2026-06-06 06:17:07 +00:00
Add policy fetching from insights api with authorization (#259)
This commit is contained in:
parent
8e0b415352
commit
61c2ffb99a
12 changed files with 329 additions and 8 deletions
|
|
@ -24,6 +24,11 @@ inputs:
|
|||
description: "Disable file monitoring"
|
||||
required: false
|
||||
default: "false"
|
||||
policy:
|
||||
description: "Policy ID to be used from insights website"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
branding:
|
||||
icon: "check-square"
|
||||
color: "green"
|
||||
|
|
|
|||
4
dist/post/index.js
vendored
4
dist/post/index.js
vendored
|
|
@ -61292,8 +61292,8 @@ var cleanup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _
|
|||
core.error(line);
|
||||
});
|
||||
}
|
||||
var disable_sudo = core.getBooleanInput("disable-sudo");
|
||||
if (!disable_sudo) {
|
||||
var disable_sudo = process.env.STATE_disableSudo;
|
||||
if (disable_sudo !== "true") {
|
||||
var journalLog = external_child_process_.execSync("sudo journalctl -u agent.service", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
|
|
|||
2
dist/post/index.js.map
vendored
2
dist/post/index.js.map
vendored
File diff suppressed because one or more lines are too long
72
dist/pre/index.js
vendored
72
dist/pre/index.js
vendored
|
|
@ -69094,6 +69094,59 @@ function isValidEvent() {
|
|||
return RefKey in process.env && Boolean(process.env[RefKey]);
|
||||
}
|
||||
|
||||
;// CONCATENATED MODULE: ./src/policy-utils.ts
|
||||
var policy_utils_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) {
|
||||
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
||||
return new (P || (P = Promise))(function (resolve, reject) {
|
||||
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
||||
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
||||
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
||||
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
||||
});
|
||||
};
|
||||
|
||||
const API_ENDPOINT = "https://agent.api.stepsecurity.io/v1";
|
||||
function fetchPolicy(owner, policyName, idToken) {
|
||||
return policy_utils_awaiter(this, void 0, void 0, function* () {
|
||||
if (idToken === "") {
|
||||
throw new Error("[PolicyFetch]: id-token in empty");
|
||||
}
|
||||
let policyEndpoint = `${API_ENDPOINT}/github/${owner}/actions/policies/${policyName}`;
|
||||
let httpClient = new lib.HttpClient();
|
||||
let headers = {};
|
||||
headers["Authorization"] = `Bearer ${idToken}`;
|
||||
headers["Source"] = "github-actions";
|
||||
let response = yield httpClient.getJson(policyEndpoint, headers);
|
||||
if (response.statusCode !== 200) {
|
||||
// policy doesn't exists
|
||||
switch (response.statusCode) {
|
||||
case 400:
|
||||
throw new Error("[PolicyFetch: policy doesn't exists");
|
||||
case 401:
|
||||
throw new Error("[PolicyFetch]: supplied id-token can't be used for authentication");
|
||||
case 403:
|
||||
throw new Error("[PolicyFetch]: access to policy not allowed");
|
||||
}
|
||||
}
|
||||
return response.result;
|
||||
});
|
||||
}
|
||||
function mergeConfigs(localConfig, remoteConfig) {
|
||||
if (localConfig.allowed_endpoints === "") {
|
||||
localConfig.allowed_endpoints = remoteConfig.allowed_endpoints.join(" ");
|
||||
}
|
||||
if (remoteConfig.disable_sudo !== undefined) {
|
||||
localConfig.disable_sudo = remoteConfig.disable_sudo;
|
||||
}
|
||||
if (remoteConfig.disable_file_monitoring !== undefined) {
|
||||
localConfig.disable_file_monitoring = remoteConfig.disable_file_monitoring;
|
||||
}
|
||||
if (remoteConfig.egress_policy !== undefined) {
|
||||
localConfig.egress_policy = remoteConfig.egress_policy;
|
||||
}
|
||||
return localConfig;
|
||||
}
|
||||
|
||||
// EXTERNAL MODULE: ./node_modules/@actions/cache/lib/internal/cacheHttpClient.js
|
||||
var cacheHttpClient = __nccwpck_require__(8245);
|
||||
// EXTERNAL MODULE: ./node_modules/@actions/cache/lib/internal/cacheUtils.js
|
||||
|
|
@ -69123,6 +69176,7 @@ var setup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _ar
|
|||
|
||||
|
||||
|
||||
|
||||
(() => setup_awaiter(void 0, void 0, void 0, function* () {
|
||||
try {
|
||||
if (process.platform !== "linux") {
|
||||
|
|
@ -69137,7 +69191,7 @@ var setup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _ar
|
|||
var env = "agent";
|
||||
var api_url = `https://${env}.api.stepsecurity.io/v1`;
|
||||
var web_url = "https://app.stepsecurity.io";
|
||||
const confg = {
|
||||
let confg = {
|
||||
repo: process.env["GITHUB_REPOSITORY"],
|
||||
run_id: process.env["GITHUB_RUN_ID"],
|
||||
correlation_id: correlation_id,
|
||||
|
|
@ -69150,6 +69204,22 @@ var setup_awaiter = (undefined && undefined.__awaiter) || function (thisArg, _ar
|
|||
disable_file_monitoring: lib_core.getBooleanInput("disable-file-monitoring"),
|
||||
private: github.context.payload.repository.private,
|
||||
};
|
||||
let policyName = lib_core.getInput("policy");
|
||||
if (policyName !== "") {
|
||||
try {
|
||||
let idToken = yield lib_core.getIDToken();
|
||||
let result = yield fetchPolicy(github.context.repo.owner, policyName, idToken);
|
||||
confg = mergeConfigs(confg, result);
|
||||
}
|
||||
catch (err) {
|
||||
lib_core.info(`[!] ${err}`);
|
||||
lib_core.setFailed(err);
|
||||
}
|
||||
}
|
||||
external_fs_.appendFileSync(process.env.GITHUB_STATE, `disableSudo=${confg.disable_sudo}${external_os_.EOL}`, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
lib_core.info(`[!] Current Configuration: \n${JSON.stringify(confg)}\n`);
|
||||
if (confg.egress_policy !== "audit" && confg.egress_policy !== "block") {
|
||||
lib_core.setFailed("egress-policy must be either audit or block");
|
||||
}
|
||||
|
|
|
|||
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
67
package-lock.json
generated
67
package-lock.json
generated
|
|
@ -30,6 +30,7 @@
|
|||
"eslint-config-google": "^0.14.0",
|
||||
"jest": "^29.3.1",
|
||||
"jest-junit": ">=13.0.0",
|
||||
"nock": "^13.3.0",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.3.5"
|
||||
}
|
||||
|
|
@ -5404,6 +5405,12 @@
|
|||
"integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
|
|
@ -5465,6 +5472,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
|
|
@ -5616,6 +5629,21 @@
|
|||
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/nock": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/nock/-/nock-13.3.0.tgz",
|
||||
"integrity": "sha512-HHqYQ6mBeiMc+N038w8LkMpDCRquCHWeNmN3v6645P3NhN2+qXOBqvPqo7Rt1VyCMzKhJ733wZqw5B7cQVFNPg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"propagate": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
|
|
@ -5945,6 +5973,15 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/propagate": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
|
||||
"integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
|
|
@ -10914,6 +10951,12 @@
|
|||
"integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
|
||||
"dev": true
|
||||
},
|
||||
"json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||
"dev": true
|
||||
},
|
||||
"json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
|
|
@ -10957,6 +11000,12 @@
|
|||
"p-locate": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true
|
||||
},
|
||||
"lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
|
|
@ -11075,6 +11124,18 @@
|
|||
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
|
||||
"dev": true
|
||||
},
|
||||
"nock": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/nock/-/nock-13.3.0.tgz",
|
||||
"integrity": "sha512-HHqYQ6mBeiMc+N038w8LkMpDCRquCHWeNmN3v6645P3NhN2+qXOBqvPqo7Rt1VyCMzKhJ733wZqw5B7cQVFNPg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "^4.1.0",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"propagate": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
|
|
@ -11302,6 +11363,12 @@
|
|||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"propagate": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
|
||||
"integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
|
||||
"dev": true
|
||||
},
|
||||
"psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
"eslint-config-google": "^0.14.0",
|
||||
"jest": "^29.3.1",
|
||||
"jest-junit": ">=13.0.0",
|
||||
"nock": "^13.3.0",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.3.5"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ import path from "path";
|
|||
});
|
||||
}
|
||||
|
||||
var disable_sudo = core.getBooleanInput("disable-sudo");
|
||||
if (!disable_sudo) {
|
||||
var disable_sudo = process.env.STATE_disableSudo;
|
||||
if (disable_sudo !== "true") {
|
||||
var journalLog = cp.execSync("sudo journalctl -u agent.service", {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
|
|
|||
23
src/interfaces.ts
Normal file
23
src/interfaces.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export interface Configuration {
|
||||
repo: string;
|
||||
run_id: string;
|
||||
correlation_id: string;
|
||||
working_directory: string;
|
||||
api_url: string;
|
||||
allowed_endpoints: string;
|
||||
egress_policy: string;
|
||||
disable_telemetry: boolean;
|
||||
disable_sudo: boolean;
|
||||
disable_file_monitoring: boolean;
|
||||
private: string;
|
||||
}
|
||||
|
||||
export interface PolicyResponse {
|
||||
owner?: string;
|
||||
policyName?: string;
|
||||
allowed_endpoints?: string[];
|
||||
disable_sudo?: boolean;
|
||||
disable_file_monitoring?: boolean;
|
||||
disable_telemetry?: boolean;
|
||||
egress_policy?: string;
|
||||
}
|
||||
67
src/policy-utils.test.ts
Normal file
67
src/policy-utils.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import nock from "nock";
|
||||
import { API_ENDPOINT, fetchPolicy, mergeConfigs } from "./policy-utils";
|
||||
import { Configuration, PolicyResponse } from "./interfaces";
|
||||
|
||||
test("success: fetching policy", async () => {
|
||||
let owner = "h0x0er";
|
||||
let policyName = "policy1";
|
||||
let response = {
|
||||
owner: "h0x0er",
|
||||
policyName: "policy1",
|
||||
allowed_endpoints: ["github.com:443"],
|
||||
egress_policy: "audit",
|
||||
disable_telemetry: false,
|
||||
disable_sudo: false,
|
||||
disable_file_monitoring: false,
|
||||
};
|
||||
const policyScope = nock(`${API_ENDPOINT}`)
|
||||
.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);
|
||||
});
|
||||
|
||||
test("merge configs", async () => {
|
||||
let localConfig: Configuration = {
|
||||
repo: "test/repo",
|
||||
run_id: "xyx",
|
||||
correlation_id: "aaaaa",
|
||||
working_directory: "/xyz",
|
||||
api_url: "xyz",
|
||||
allowed_endpoints: "",
|
||||
egress_policy: "audit",
|
||||
disable_telemetry: false,
|
||||
disable_sudo: false,
|
||||
disable_file_monitoring: false,
|
||||
private: "true",
|
||||
};
|
||||
let policyResponse: PolicyResponse = {
|
||||
owner: "h0x0er",
|
||||
policyName: "policy1",
|
||||
allowed_endpoints: ["github.com:443", "google.com:443"],
|
||||
egress_policy: "audit",
|
||||
disable_telemetry: false,
|
||||
disable_sudo: false,
|
||||
disable_file_monitoring: false,
|
||||
};
|
||||
|
||||
let expectedConfiguration: Configuration = {
|
||||
repo: "test/repo",
|
||||
run_id: "xyx",
|
||||
correlation_id: "aaaaa",
|
||||
working_directory: "/xyz",
|
||||
api_url: "xyz",
|
||||
allowed_endpoints: "github.com:443 google.com:443",
|
||||
egress_policy: "audit",
|
||||
disable_telemetry: false,
|
||||
disable_sudo: false,
|
||||
disable_file_monitoring: false,
|
||||
private: "true",
|
||||
};
|
||||
|
||||
localConfig = mergeConfigs(localConfig, policyResponse);
|
||||
expect(localConfig).toStrictEqual(expectedConfiguration);
|
||||
});
|
||||
62
src/policy-utils.ts
Normal file
62
src/policy-utils.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { HttpClient } from "@actions/http-client";
|
||||
import { PolicyResponse, Configuration } from "./interfaces";
|
||||
|
||||
export const API_ENDPOINT = "https://agent.api.stepsecurity.io/v1";
|
||||
|
||||
export async function fetchPolicy(
|
||||
owner: string,
|
||||
policyName: string,
|
||||
idToken: string
|
||||
): Promise<PolicyResponse> {
|
||||
if (idToken === "") {
|
||||
throw new Error("[PolicyFetch]: id-token in empty");
|
||||
}
|
||||
|
||||
let policyEndpoint = `${API_ENDPOINT}/github/${owner}/actions/policies/${policyName}`;
|
||||
|
||||
let httpClient = new HttpClient();
|
||||
|
||||
let headers = {};
|
||||
headers["Authorization"] = `Bearer ${idToken}`;
|
||||
headers["Source"] = "github-actions";
|
||||
|
||||
let response = await httpClient.getJson<PolicyResponse>(
|
||||
policyEndpoint,
|
||||
headers
|
||||
);
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
// policy doesn't exists
|
||||
switch (response.statusCode) {
|
||||
case 400:
|
||||
throw new Error("[PolicyFetch: policy doesn't exists");
|
||||
case 401:
|
||||
throw new Error("[PolicyFetch]: supplied id-token can't be used for authentication");
|
||||
|
||||
case 403:
|
||||
throw new Error("[PolicyFetch]: access to policy not allowed")
|
||||
}
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
export function mergeConfigs(
|
||||
localConfig: Configuration,
|
||||
remoteConfig: PolicyResponse
|
||||
) {
|
||||
if (localConfig.allowed_endpoints === "") {
|
||||
localConfig.allowed_endpoints = remoteConfig.allowed_endpoints.join(" ");
|
||||
}
|
||||
if (remoteConfig.disable_sudo !== undefined) {
|
||||
localConfig.disable_sudo = remoteConfig.disable_sudo;
|
||||
}
|
||||
|
||||
if (remoteConfig.disable_file_monitoring !== undefined) {
|
||||
localConfig.disable_file_monitoring = remoteConfig.disable_file_monitoring;
|
||||
}
|
||||
if (remoteConfig.egress_policy !== undefined) {
|
||||
localConfig.egress_policy = remoteConfig.egress_policy;
|
||||
}
|
||||
|
||||
return localConfig;
|
||||
}
|
||||
28
src/setup.ts
28
src/setup.ts
|
|
@ -17,6 +17,8 @@ import {
|
|||
CompressionMethod,
|
||||
isValidEvent,
|
||||
} from "./cache";
|
||||
import { Configuration, PolicyResponse } from "./interfaces";
|
||||
import { fetchPolicy, mergeConfigs } from "./policy-utils";
|
||||
|
||||
import {getCacheEntry} from "@actions/cache/lib/internal/cacheHttpClient"
|
||||
import * as utils from '@actions/cache/lib/internal/cacheUtils'
|
||||
|
|
@ -37,7 +39,7 @@ import * as utils from '@actions/cache/lib/internal/cacheUtils'
|
|||
var api_url = `https://${env}.api.stepsecurity.io/v1`;
|
||||
var web_url = "https://app.stepsecurity.io";
|
||||
|
||||
const confg = {
|
||||
let confg: Configuration = {
|
||||
repo: process.env["GITHUB_REPOSITORY"],
|
||||
run_id: process.env["GITHUB_RUN_ID"],
|
||||
correlation_id: correlation_id,
|
||||
|
|
@ -51,6 +53,30 @@ import * as utils from '@actions/cache/lib/internal/cacheUtils'
|
|||
private: context.payload.repository.private,
|
||||
};
|
||||
|
||||
let policyName = core.getInput("policy");
|
||||
if (policyName !== "") {
|
||||
try {
|
||||
let idToken: string = await core.getIDToken()
|
||||
let result: PolicyResponse = await fetchPolicy(
|
||||
context.repo.owner,
|
||||
policyName,
|
||||
idToken
|
||||
);
|
||||
confg = mergeConfigs(confg, result);
|
||||
} catch (err) {
|
||||
core.info(`[!] ${err}`);
|
||||
core.setFailed(err);
|
||||
}
|
||||
}
|
||||
fs.appendFileSync(
|
||||
process.env.GITHUB_STATE,
|
||||
`disableSudo=${confg.disable_sudo}${EOL}`,
|
||||
{
|
||||
encoding: "utf8",
|
||||
}
|
||||
);
|
||||
core.info(`[!] Current Configuration: \n${JSON.stringify(confg)}\n`);
|
||||
|
||||
if (confg.egress_policy !== "audit" && confg.egress_policy !== "block") {
|
||||
core.setFailed("egress-policy must be either audit or block");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue