mirror of https://github.com/garrytan/gstack.git
Merge b43ac36621 into a3259400a3
This commit is contained in:
commit
dc3462ffa1
|
|
@ -75,7 +75,14 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
|||
# # Set to true once the privacy gate has asked the user.
|
||||
# # Flip back to false to be re-prompted.
|
||||
#
|
||||
# ─── Plan-tune hooks ─────────────────────────────────────────────────
|
||||
# ─── Redaction + Plan-tune hooks ───────────────────────────────────────
|
||||
# redact_prepush_hook: prompt # prompt | yes | no — ask to install the local
|
||||
# # gstack-redact pre-push guard on a real TTY,
|
||||
# # skip otherwise. Override per-run with
|
||||
# # ./setup --redact-prepush-hook /
|
||||
# # --no-redact-prepush-hook, or env
|
||||
# # GSTACK_REDACT_PREPUSH_HOOK.
|
||||
#
|
||||
# plan_tune_hooks: prompt # Controls whether ./setup installs the plan-tune
|
||||
# # Claude Code hooks (PostToolUse capture +
|
||||
# # PreToolUse preference enforcement).
|
||||
|
|
@ -132,7 +139,7 @@ lookup_default() {
|
|||
plan_tune_hooks) echo "prompt" ;; # prompt | yes | no — controls ./setup plan-tune hook install
|
||||
|
||||
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
|
||||
redact_prepush_hook) echo "false" ;;
|
||||
redact_prepush_hook) echo "prompt" ;;
|
||||
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
|
||||
# brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
|
||||
# writes 'personal' for local engines,
|
||||
|
|
@ -306,9 +313,16 @@ case "${1:-}" in
|
|||
echo "Warning: redact_repo_visibility '$VALUE' not recognized. Valid values: public, private, unknown. Using unknown." >&2
|
||||
VALUE="unknown"
|
||||
fi
|
||||
if [ "$KEY" = "redact_prepush_hook" ] && [ "$VALUE" != "true" ] && [ "$VALUE" != "false" ]; then
|
||||
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: true, false. Using false." >&2
|
||||
VALUE="false"
|
||||
if [ "$KEY" = "redact_prepush_hook" ]; then
|
||||
case "$VALUE" in
|
||||
true|yes|y|1|on|install) VALUE="yes" ;;
|
||||
false|no|n|0|off|skip) VALUE="no" ;;
|
||||
prompt) VALUE="prompt" ;;
|
||||
*)
|
||||
echo "Warning: redact_prepush_hook '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
|
||||
VALUE="prompt"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [ "$KEY" = "plan_tune_hooks" ] && [ "$VALUE" != "prompt" ] && [ "$VALUE" != "yes" ] && [ "$VALUE" != "no" ]; then
|
||||
echo "Warning: plan_tune_hooks '$VALUE' not recognized. Valid values: prompt, yes, no. Using prompt." >&2
|
||||
|
|
@ -350,7 +364,8 @@ case "${1:-}" in
|
|||
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
||||
skill_prefix checkpoint_mode checkpoint_push explain_level \
|
||||
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
||||
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do
|
||||
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \
|
||||
redact_prepush_hook; do
|
||||
VALUE=$(grep -E "^${KEY}:" "$CONFIG_FILE" 2>/dev/null | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
|
||||
SOURCE="default"
|
||||
if [ -n "$VALUE" ]; then
|
||||
|
|
@ -366,7 +381,8 @@ case "${1:-}" in
|
|||
for KEY in proactive routing_declined telemetry auto_upgrade update_check \
|
||||
skill_prefix checkpoint_mode checkpoint_push explain_level \
|
||||
codex_reviews gstack_contributor skip_eng_review workspace_root \
|
||||
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks; do
|
||||
artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \
|
||||
redact_prepush_hook; do
|
||||
printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")"
|
||||
done
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -35,14 +35,26 @@ const ZERO = /^0+$/;
|
|||
// The canonical empty-tree object; diffing against it yields all content as added.
|
||||
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||
|
||||
class GitScanError extends Error {
|
||||
constructor(
|
||||
readonly args: string[],
|
||||
readonly status: number | null,
|
||||
readonly stderr: string,
|
||||
) {
|
||||
super(`git ${args.join(" ")} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive git for legitimately-fallible PROBES (symbolic-ref, rev-parse,
|
||||
* merge-base) where a non-zero exit is normal control flow. The DIFF call
|
||||
* must NOT use this — see gitStrict (#1946 fail-closed).
|
||||
*/
|
||||
function git(args: string[]): string {
|
||||
function git(args: string[], opts: { allowFailure?: boolean } = {}): string {
|
||||
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||
return r.status === 0 ? (r.stdout ?? "") : "";
|
||||
if (r.status === 0) return r.stdout ?? "";
|
||||
if (opts.allowFailure) return "";
|
||||
throw new GitScanError(args, r.status, r.stderr ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -72,10 +84,10 @@ function objectExists(sha: string): boolean {
|
|||
|
||||
function defaultRemoteBranch(): string {
|
||||
// origin/HEAD → origin/main, fall back to main/master.
|
||||
const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).trim();
|
||||
const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true }).trim();
|
||||
if (sym) return sym.replace("refs/remotes/", "");
|
||||
for (const b of ["origin/main", "origin/master"]) {
|
||||
if (git(["rev-parse", "--verify", b]).trim()) return b;
|
||||
if (git(["rev-parse", "--verify", b], { allowFailure: true }).trim()) return b;
|
||||
}
|
||||
return "origin/main";
|
||||
}
|
||||
|
|
@ -87,14 +99,14 @@ function addedLinesFor(localSha: string, remoteSha: string): string {
|
|||
// New branch: prefer what's unique to localSha vs the remote default branch.
|
||||
// With no merge-base (e.g. no remote yet), diff against the empty tree so ALL
|
||||
// branch content is scanned as added — fail-safe (scans more, never less).
|
||||
const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim();
|
||||
const base = git(["merge-base", localSha, defaultRemoteBranch()], { allowFailure: true }).trim();
|
||||
range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`;
|
||||
} else if (!objectExists(remoteSha)) {
|
||||
// Remote tip object absent locally (shallow clone, force-push without a
|
||||
// prior fetch, CI checkout): remote..local can't resolve. Fall back to
|
||||
// the merge-base/empty-tree path — scans MORE, never less — instead of
|
||||
// hard-blocking a legitimate push (adversarial review finding 8).
|
||||
const base = git(["merge-base", localSha, defaultRemoteBranch()]).trim();
|
||||
const base = git(["merge-base", localSha, defaultRemoteBranch()], { allowFailure: true }).trim();
|
||||
range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`;
|
||||
} else {
|
||||
// Existing branch (incl. force-push): net new content remote..local.
|
||||
|
|
@ -154,10 +166,16 @@ function main() {
|
|||
// Fail CLOSED (#1946): if we can't compute the pushed diff we can't
|
||||
// scan it, and unscanned-but-allowed is the failure mode this hook
|
||||
// exists to prevent.
|
||||
const detail =
|
||||
err instanceof GitScanError
|
||||
? `git ${err.args.join(" ")} exited ${err.status ?? "unknown"}${err.stderr.trim() ? `: ${err.stderr.trim()}` : ""}`
|
||||
: err instanceof Error
|
||||
? err.message.split("\n")[0]
|
||||
: String(err);
|
||||
process.stderr.write(
|
||||
"\n⛔ gstack-redact-prepush BLOCKED the push — could not compute the pushed diff, " +
|
||||
"so it cannot be scanned for credentials.\n" +
|
||||
` (${err instanceof Error ? err.message.split("\n")[0] : String(err)})\n` +
|
||||
"\n⛔ gstack-redact-prepush BLOCKED the push — could not scan pushed diff safely: " +
|
||||
"could not compute the pushed diff, so it cannot be scanned for credentials.\n" +
|
||||
` (${detail.slice(0, 500)})\n` +
|
||||
"Bypass if you're sure: GSTACK_REDACT_PREPUSH=skip git push (or git push --no-verify)\n",
|
||||
);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -108,6 +108,10 @@ export function shannonEntropy(s: string): number {
|
|||
return h;
|
||||
}
|
||||
|
||||
function looksHighEntropySecret(span: string): boolean {
|
||||
return span.length >= 20 && /[A-Za-z]/.test(span) && /\d/.test(span) && shannonEntropy(span) >= 3.0;
|
||||
}
|
||||
|
||||
/** True when an IPv4 string is a public address (not RFC1918/loopback/etc). */
|
||||
export function isPublicIPv4(ip: string): boolean {
|
||||
const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
|
|
@ -243,14 +247,14 @@ export const PATTERNS: RedactPattern[] = [
|
|||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "npm granular access token",
|
||||
regex: /\b(npm_[A-Za-z0-9]{36})\b/,
|
||||
regex: /\b(npm_[A-Za-z0-9_\-]{30,})\b/,
|
||||
},
|
||||
{
|
||||
id: "digitalocean.token",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "DigitalOcean personal access token",
|
||||
regex: /\b(dop_v1_[a-f0-9]{64})\b/,
|
||||
regex: /\b(dop_v1_[A-Za-z0-9]{64,})\b/,
|
||||
},
|
||||
{
|
||||
id: "gcp.service_account",
|
||||
|
|
@ -318,6 +322,13 @@ export const PATTERNS: RedactPattern[] = [
|
|||
description: "Discord webhook URL",
|
||||
regex: /(https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]{17,20}\/[A-Za-z0-9_\-]{60,})/,
|
||||
},
|
||||
{
|
||||
id: "gcp.service_account_json",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "Google Cloud service-account JSON with embedded private key",
|
||||
regex: /(\{[\s\S]{0,20000}"type"\s*:\s*"service_account"[\s\S]{0,20000}"private_key"\s*:\s*"-----BEGIN PRIVATE KEY-----[\s\S]{0,20000}\})/,
|
||||
},
|
||||
{
|
||||
id: "twilio.auth_token",
|
||||
tier: "HIGH",
|
||||
|
|
@ -387,7 +398,7 @@ export const PATTERNS: RedactPattern[] = [
|
|||
tier: "MEDIUM",
|
||||
category: "secret",
|
||||
description: "Env-style SECRET assignment with high-entropy value",
|
||||
regex: /^[ \t]*(?:export[ \t]+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)[ \t]*=[ \t]*['"]?([^\s'"]{8,})['"]?/,
|
||||
regex: /^[ \t]*(?:export[ \t]+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|DSN|AUTH|COOKIE|SESSION|PRIVATE)[A-Z0-9_]*[ \t]*(?:=|:)[ \t]*['"]?([^\s'"]{8,})['"]?/i,
|
||||
// Only fire on high-entropy values — kills `FOO_KEY=changeme` FPs.
|
||||
validate: (span) =>
|
||||
!isPlaceholderSpan(span) &&
|
||||
|
|
|
|||
5
setup
5
setup
|
|
@ -83,6 +83,7 @@ SKILL_PREFIX_FLAG=0
|
|||
TEAM_MODE=0
|
||||
NO_TEAM_MODE=0
|
||||
PLAN_TUNE_HOOKS_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit
|
||||
REDACT_PREPUSH_HOOK_MODE="" # "" = resolve from env/config/prompt; "yes"/"no" = explicit
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--host) [ -z "$2" ] && echo "Missing value for --host (expected claude, codex, kiro, factory, opencode, openclaw, hermes, gbrain, or auto)" >&2 && exit 1; HOST="$2"; shift 2 ;;
|
||||
|
|
@ -95,6 +96,9 @@ while [ $# -gt 0 ]; do
|
|||
--plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="yes"; shift ;;
|
||||
--no-plan-tune-hooks) PLAN_TUNE_HOOKS_MODE="no"; shift ;;
|
||||
--plan-tune-hooks=*) PLAN_TUNE_HOOKS_MODE="${1#--plan-tune-hooks=}"; shift ;;
|
||||
--redact-prepush-hook) REDACT_PREPUSH_HOOK_MODE="yes"; shift ;;
|
||||
--no-redact-prepush-hook) REDACT_PREPUSH_HOOK_MODE="no"; shift ;;
|
||||
--redact-prepush-hook=*) REDACT_PREPUSH_HOOK_MODE="${1#--redact-prepush-hook=}"; shift ;;
|
||||
-q|--quiet) QUIET=1; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
|
|
@ -1508,6 +1512,7 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
|
|||
fi
|
||||
fi
|
||||
|
||||
|
||||
# Also tear down plan-tune hooks on --no-team (matches the existing pattern).
|
||||
if [ "$NO_TEAM_MODE" -eq 1 ] && [ -x "$SETTINGS_HOOK" ]; then
|
||||
"$SETTINGS_HOOK" remove-source --source plan-tune-cathedral 2>/dev/null || true
|
||||
|
|
|
|||
|
|
@ -31,8 +31,8 @@ describe("redact config keys", () => {
|
|||
test("redact_repo_visibility default is empty (falls through to detection)", () => {
|
||||
expect(cfg(["get", "redact_repo_visibility"]).out).toBe("");
|
||||
});
|
||||
test("redact_prepush_hook default is false", () => {
|
||||
expect(cfg(["get", "redact_prepush_hook"]).out).toBe("false");
|
||||
test("redact_prepush_hook default is prompt", () => {
|
||||
expect(cfg(["get", "redact_prepush_hook"]).out).toBe("prompt");
|
||||
});
|
||||
test("set + get round-trips a valid visibility", () => {
|
||||
cfg(["set", "redact_repo_visibility", "private"]);
|
||||
|
|
@ -43,9 +43,13 @@ describe("redact config keys", () => {
|
|||
expect(r.err).toContain("not recognized");
|
||||
expect(cfg(["get", "redact_repo_visibility"]).out).toBe("unknown");
|
||||
});
|
||||
test("invalid prepush flag is rejected to false", () => {
|
||||
test("invalid prepush flag is rejected to prompt", () => {
|
||||
cfg(["set", "redact_prepush_hook", "maybe"]);
|
||||
expect(cfg(["get", "redact_prepush_hook"]).out).toBe("false");
|
||||
expect(cfg(["get", "redact_prepush_hook"]).out).toBe("prompt");
|
||||
});
|
||||
test("redact_prepush_hook appears in list/defaults", () => {
|
||||
expect(cfg(["list"]).out).toContain("redact_prepush_hook:");
|
||||
expect(cfg(["defaults"]).out).toContain("redact_prepush_hook:");
|
||||
});
|
||||
test("no block_private key (HIGH blocks both visibilities unconditionally)", () => {
|
||||
// The default for an unknown key is empty string — there is no such key.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,19 @@ describe("HIGH credential patterns", () => {
|
|||
["slack.token", "xox" + "b-1234567890-abcdefghijklmnop"],
|
||||
["slack.webhook", "https://hooks.slack.com/services/T00000000/B11111111/" + "a".repeat(24)],
|
||||
["discord.webhook", "https://discord.com/api/webhooks/123456789012345678/" + "a".repeat(60)],
|
||||
["gitlab.token", "glpat-" + "a".repeat(20)],
|
||||
["huggingface.token", "hf_" + "a".repeat(34)],
|
||||
["npm.token", "npm_" + "a".repeat(36)],
|
||||
["digitalocean.token", "dop_v1_" + "a".repeat(64)],
|
||||
[
|
||||
"gcp.service_account_json",
|
||||
`{
|
||||
"type": "service_account",
|
||||
"private_key_id": "${"a".repeat(40)}",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\\n${"A".repeat(64)}\\n-----END PRIVATE KEY-----\\n",
|
||||
"client_email": "svc@project.iam.gserviceaccount.com"
|
||||
}`,
|
||||
],
|
||||
["pem.private_key", "-----BEGIN RSA PRIVATE KEY-----"],
|
||||
// #1946 coverage-gap additions
|
||||
["gitlab.token", "remote: glpat-" + "Ab12Cd34Ef56Gh78Ij90"],
|
||||
|
|
@ -99,6 +112,16 @@ describe("HIGH credential patterns", () => {
|
|||
expect(ids(`random ${tok} here`)).not.toContain("twilio.auth_token");
|
||||
});
|
||||
|
||||
test("generic Bearer token needs authorization context and enough entropy", () => {
|
||||
const token = "Ab3_xY9.KlmN0pQr-StUvWxYz456789";
|
||||
const lowercaseToken = "abc123def456abc123def456abc123def456";
|
||||
expect(ids("Authorization: Bearer " + token)).toContain("auth.bearer");
|
||||
expect(ids("Bearer " + token)).not.toContain("auth.bearer");
|
||||
expect(ids("Authorization: Bearer " + lowercaseToken)).toContain("auth.bearer");
|
||||
expect(ids("docs say Bearer <token> here")).not.toContain("auth.bearer");
|
||||
expect(ids("Authorization: Bearer " + "a".repeat(32))).not.toContain("auth.bearer");
|
||||
});
|
||||
|
||||
test("db.url_with_password flags real password, skips placeholder/env-var", () => {
|
||||
expect(ids("postgres://user:s3cretP@ss@db.example.com/app")).toContain("db.url_with_password");
|
||||
expect(ids("postgres://user:${DB_PASSWORD}@host/app")).not.toContain("db.url_with_password");
|
||||
|
|
@ -130,7 +153,10 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => {
|
|||
});
|
||||
test("env.kv fires on high-entropy, skips placeholder", () => {
|
||||
expect(ids("API_TOKEN=8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv");
|
||||
expect(ids("api_key: 8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ")).toContain("env.kv");
|
||||
expect(ids('apiToken: "8Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJ"')).toContain("env.kv");
|
||||
expect(ids("API_KEY=changeme")).not.toContain("env.kv");
|
||||
expect(ids("api_key: changeme")).not.toContain("env.kv");
|
||||
expect(ids("API_KEY=${MY_VAR}")).not.toContain("env.kv");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,25 @@ describe("pre-push hook gating", () => {
|
|||
expect(code).toBe(0);
|
||||
expect(stderr).toContain("MEDIUM");
|
||||
});
|
||||
|
||||
test("common token prefixes in pushed diff block", () => {
|
||||
const base = git(["rev-parse", "HEAD"]);
|
||||
const head = commit("tokens.txt", "gitlab glpat-" + "a".repeat(20) + "\n", "add gitlab token");
|
||||
const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain("gitlab.token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fail-closed git failures", () => {
|
||||
test("invalid local sha blocks instead of silently allowing an unscanned push", () => {
|
||||
const base = git(["rev-parse", "HEAD"]);
|
||||
const { code, stderr } = runHook(
|
||||
`refs/heads/main deadbeefdeadbeefdeadbeefdeadbeefdeadbeef refs/heads/main ${base}\n`,
|
||||
);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain("could not scan pushed diff safely");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff direction + special refs", () => {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,29 @@ describe('setup: plan-tune hooks are non-interactive-safe', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('setup: redaction pre-push hook install is consent-based and non-interactive-safe', () => {
|
||||
test('exposes --redact-prepush-hook / --no-redact-prepush-hook / =value flags', () => {
|
||||
expect(setupSrc).toContain('--redact-prepush-hook)');
|
||||
expect(setupSrc).toContain('--no-redact-prepush-hook)');
|
||||
expect(setupSrc).toContain('--redact-prepush-hook=*)');
|
||||
});
|
||||
|
||||
test('redaction hook decision falls through env then saved config', () => {
|
||||
expect(setupSrc).toContain('GSTACK_REDACT_PREPUSH_HOOK');
|
||||
expect(setupSrc).toContain('get redact_prepush_hook');
|
||||
});
|
||||
|
||||
test('redaction hook prompt is time-bounded and TTY-gated', () => {
|
||||
expect(setupSrc).toMatch(/read -t (?:\d+|"?\$\{?\w+\}?"?) -r REDACT_PREPUSH_INSTALL_REPLY <\/dev\/tty/);
|
||||
expect(setupSrc).toMatch(/\[ "\$QUIET" -ne 1 \] && \[ -t 0 \] && \[ -t 1 \]/);
|
||||
});
|
||||
|
||||
test('redaction hook git check and install target the source gstack repo', () => {
|
||||
expect(setupSrc).toContain('( cd "$SOURCE_GSTACK_DIR" && git rev-parse --git-dir');
|
||||
expect(setupSrc).toContain('( cd "$SOURCE_GSTACK_DIR" && bun "$REDACT_BIN" install-prepush-hook )');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev-setup: never silently mutates global settings.json', () => {
|
||||
const DEV_SETUP = path.join(ROOT, 'bin', 'dev-setup');
|
||||
const devSetupSrc = fs.readFileSync(DEV_SETUP, 'utf-8');
|
||||
|
|
|
|||
Loading…
Reference in New Issue