mirror of https://github.com/garrytan/gstack.git
fix: install redact pre-push guard safely
This commit is contained in:
parent
cab774cced
commit
1cb1020b06
|
|
@ -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).
|
||||
|
|
@ -123,7 +130,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@<hash> — unset on fresh install; setup-gbrain
|
||||
# writes 'personal' for local engines,
|
||||
|
|
@ -294,9 +301,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
|
||||
|
|
@ -331,7 +345,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
|
||||
|
|
@ -347,7 +362,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,17 +35,29 @@ const ZERO = /^0+$/;
|
|||
// The canonical empty-tree object; diffing against it yields all content as added.
|
||||
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||
|
||||
function git(args: string[]): string {
|
||||
class GitScanError extends Error {
|
||||
constructor(
|
||||
readonly args: string[],
|
||||
readonly status: number | null,
|
||||
readonly stderr: string,
|
||||
) {
|
||||
super(`git ${args.join(" ")} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? "");
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
|
@ -57,7 +69,7 @@ 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 {
|
||||
// Existing branch (incl. force-push): net new content remote..local.
|
||||
|
|
@ -108,7 +120,23 @@ function main() {
|
|||
|
||||
for (const [, localSha, , remoteSha] of refs) {
|
||||
if (!localSha || ZERO.test(localSha)) continue; // branch delete → nothing pushed
|
||||
const added = addedLinesFor(localSha, remoteSha || "0");
|
||||
let added = "";
|
||||
try {
|
||||
added = addedLinesFor(localSha, remoteSha || "0");
|
||||
} catch (e) {
|
||||
if (e instanceof GitScanError) {
|
||||
process.stderr.write(
|
||||
"gstack-redact-prepush: could not scan pushed diff safely — blocking fail-closed.\n" +
|
||||
` git ${e.args.join(" ")} exited ${e.status ?? "unknown"}\n`,
|
||||
);
|
||||
if (e.stderr.trim()) process.stderr.write(` ${e.stderr.trim()}\n`);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
"gstack-redact-prepush: could not scan pushed diff safely — blocking fail-closed.\n",
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
if (!added.trim()) continue;
|
||||
// Visibility doesn't change HIGH behavior; pass private so nothing is treated
|
||||
// as public-strict (HIGH blocks regardless either way).
|
||||
|
|
|
|||
|
|
@ -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})$/);
|
||||
|
|
@ -271,6 +275,49 @@ 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: "gitlab.token",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "GitLab personal/project/group access token (glpat…)",
|
||||
regex: /\b(glpat-[A-Za-z0-9_\-]{20,})\b/,
|
||||
},
|
||||
{
|
||||
id: "huggingface.token",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "Hugging Face access token (hf_…)",
|
||||
regex: /\b(hf_[A-Za-z0-9]{30,})\b/,
|
||||
},
|
||||
{
|
||||
id: "npm.token",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "npm access token (npm_…)",
|
||||
regex: /\b(npm_[A-Za-z0-9_\-]{30,})\b/,
|
||||
},
|
||||
{
|
||||
id: "digitalocean.token",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "DigitalOcean API token (dop_v1_…)",
|
||||
regex: /\b(dop_v1_[A-Za-z0-9]{64,})\b/,
|
||||
},
|
||||
{
|
||||
id: "auth.bearer",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "High-entropy Bearer authorization token",
|
||||
regex: /\bBearer[ \t]+([A-Za-z0-9_\-.=]{24,})\b/i,
|
||||
validate: (span) => looksHighEntropySecret(span),
|
||||
},
|
||||
{
|
||||
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",
|
||||
|
|
@ -340,7 +387,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) &&
|
||||
|
|
|
|||
77
setup
77
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
|
||||
|
|
@ -1448,6 +1452,79 @@ if [ "$NO_TEAM_MODE" -ne 1 ] \
|
|||
fi
|
||||
fi
|
||||
|
||||
|
||||
# 11b. gstack-redact pre-push hook install.
|
||||
#
|
||||
# The hook catches accidental HIGH credential pushes before they leave the local
|
||||
# repo. It is consent-based because it mutates .git/hooks/pre-push, but setup now
|
||||
# asks on a real TTY instead of hiding the guard behind a config key nobody sets.
|
||||
REDACT_BIN="$SOURCE_GSTACK_DIR/bin/gstack-redact"
|
||||
REDACT_PREPUSH_INSTALL_MARKER="$HOME/.gstack/.redact-prepush-hook-prompted"
|
||||
|
||||
if [ "$NO_TEAM_MODE" -ne 1 ] && [ -x "$REDACT_BIN" ] && ( cd "$SOURCE_GSTACK_DIR" && git rev-parse --git-dir >/dev/null 2>&1 ); then
|
||||
RP_DECISION="$REDACT_PREPUSH_HOOK_MODE"
|
||||
[ -z "$RP_DECISION" ] && RP_DECISION="${GSTACK_REDACT_PREPUSH_HOOK:-}"
|
||||
[ -z "$RP_DECISION" ] && RP_DECISION="$($GSTACK_CONFIG get redact_prepush_hook 2>/dev/null || true)"
|
||||
RP_DECISION=$(printf '%s' "$RP_DECISION" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
|
||||
case "$RP_DECISION" in
|
||||
y|yes|true|install|on|1) RP_DECISION="yes" ;;
|
||||
n|no|false|skip|off|0) RP_DECISION="no" ;;
|
||||
*) RP_DECISION="prompt" ;;
|
||||
esac
|
||||
|
||||
_install_redact_prepush_hook() {
|
||||
( cd "$SOURCE_GSTACK_DIR" && bun "$REDACT_BIN" install-prepush-hook )
|
||||
}
|
||||
|
||||
if [ "$RP_DECISION" = "yes" ]; then
|
||||
_install_redact_prepush_hook
|
||||
"$GSTACK_CONFIG" set redact_prepush_hook yes 2>/dev/null || true
|
||||
touch "$REDACT_PREPUSH_INSTALL_MARKER"
|
||||
elif [ "$RP_DECISION" = "no" ]; then
|
||||
log ""
|
||||
log "gstack-redact pre-push hook not installed (opted out)."
|
||||
log "Install later with: ./setup --redact-prepush-hook"
|
||||
"$GSTACK_CONFIG" set redact_prepush_hook no 2>/dev/null || true
|
||||
touch "$REDACT_PREPUSH_INSTALL_MARKER"
|
||||
elif [ -f "$REDACT_PREPUSH_INSTALL_MARKER" ]; then
|
||||
:
|
||||
elif [ "$QUIET" -ne 1 ] && [ -t 0 ] && [ -t 1 ]; then
|
||||
_REDACT_PROMPT_TIMEOUT=10
|
||||
log ""
|
||||
log "──────────────────────────────────────────────────────────"
|
||||
log "gstack-redact: install local pre-push secret guard?"
|
||||
log "──────────────────────────────────────────────────────────"
|
||||
log ""
|
||||
log "This scans pushed diff lines for HIGH credentials and blocks accidental leaks."
|
||||
log "Existing pre-push hooks are preserved as pre-push.local and chained."
|
||||
log "Bypass remains explicit: git push --no-verify or GSTACK_REDACT_PREPUSH=skip."
|
||||
log ""
|
||||
printf "Install gstack-redact pre-push hook now? [y/N] (default: N, auto-skips in %ss): " "$_REDACT_PROMPT_TIMEOUT"
|
||||
read -t "$_REDACT_PROMPT_TIMEOUT" -r REDACT_PREPUSH_INSTALL_REPLY </dev/tty 2>/dev/null || REDACT_PREPUSH_INSTALL_REPLY=""
|
||||
case "$REDACT_PREPUSH_INSTALL_REPLY" in
|
||||
y|Y)
|
||||
_install_redact_prepush_hook
|
||||
"$GSTACK_CONFIG" set redact_prepush_hook yes 2>/dev/null || true
|
||||
touch "$REDACT_PREPUSH_INSTALL_MARKER"
|
||||
;;
|
||||
n|N)
|
||||
log ""
|
||||
log "Skipped. Re-run ./setup --redact-prepush-hook to install later."
|
||||
"$GSTACK_CONFIG" set redact_prepush_hook no 2>/dev/null || true
|
||||
touch "$REDACT_PREPUSH_INSTALL_MARKER"
|
||||
;;
|
||||
*)
|
||||
log ""
|
||||
log "No opt-in — skipped for now. Re-run ./setup --redact-prepush-hook to install."
|
||||
;;
|
||||
esac
|
||||
else
|
||||
log ""
|
||||
log "gstack-redact pre-push hook not installed (non-interactive setup)."
|
||||
log "Install with: ./setup --redact-prepush-hook"
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -41,6 +41,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-----"],
|
||||
];
|
||||
for (const [id, text] of cases) {
|
||||
|
|
@ -57,6 +70,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)).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");
|
||||
|
|
@ -88,7 +111,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