mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-06 16:17:56 +02:00
feat(redact): opt-in pre-push hook (accident catcher) + safe installer
bin/gstack-redact-prepush scans the diff being pushed for HIGH credentials and blocks on a hit, for public AND private repos (a pushed secret is compromised regardless of visibility). Correct git pre-push semantics: scans remote..local (what's being pushed), handles new-branch zero-SHA via merge-base or empty-tree fallback, force-push, and branch-delete skip. MEDIUM warns non-blocking; LOW/WARN silent. GSTACK_REDACT_PREPUSH=skip escape valve logs to prepush-skip.jsonl. bin/gstack-redact gains install-prepush-hook / uninstall-prepush-hook subcommands that chain any pre-existing hook (renamed to pre-push.local, stdin forwarded to both, exit code propagated). Guardrail not enforcement: --no-verify and the env skip both bypass; it scans only the pushed delta, not history/binary/LFS. 9 tests in a throwaway git repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,8 @@
|
|||||||
* user can always bypass it (direct gh/git). It catches accidents.
|
* user can always bypass it (direct gh/git). It catches accidents.
|
||||||
*/
|
*/
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
import {
|
import {
|
||||||
scan,
|
scan,
|
||||||
applyRedactions,
|
applyRedactions,
|
||||||
@@ -38,6 +40,71 @@ import {
|
|||||||
|
|
||||||
const MAX_STDIN_BYTES = 16 * 1024 * 1024; // hard ceiling before the engine cap
|
const MAX_STDIN_BYTES = 16 * 1024 * 1024; // hard ceiling before the engine cap
|
||||||
|
|
||||||
|
// ── pre-push hook install/uninstall (chains any existing hook) ────────────────
|
||||||
|
|
||||||
|
const MANAGED_MARKER = "# gstack-redact pre-push (managed)";
|
||||||
|
|
||||||
|
function hooksPath(): string {
|
||||||
|
const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], { encoding: "utf8" });
|
||||||
|
if (r.status !== 0) {
|
||||||
|
process.stderr.write("gstack-redact: not in a git repo\n");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return r.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPrepushHook(): void {
|
||||||
|
const dir = hooksPath();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const hookPath = path.join(dir, "pre-push");
|
||||||
|
const prepushBin = path.join(import.meta.dir, "gstack-redact-prepush");
|
||||||
|
|
||||||
|
// If a non-managed hook exists, preserve it as pre-push.local and chain it.
|
||||||
|
if (fs.existsSync(hookPath)) {
|
||||||
|
const existing = fs.readFileSync(hookPath, "utf8");
|
||||||
|
if (existing.includes(MANAGED_MARKER)) {
|
||||||
|
process.stdout.write("gstack-redact: pre-push hook already installed.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const localPath = path.join(dir, "pre-push.local");
|
||||||
|
fs.renameSync(hookPath, localPath);
|
||||||
|
fs.chmodSync(localPath, 0o755);
|
||||||
|
process.stdout.write("gstack-redact: preserved existing hook as pre-push.local (chained).\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdin is single-consume: capture it once, feed both the chained hook and ours.
|
||||||
|
const wrapper = `#!/usr/bin/env bash
|
||||||
|
${MANAGED_MARKER}
|
||||||
|
set -euo pipefail
|
||||||
|
_input="$(cat)"
|
||||||
|
_local="$(git rev-parse --git-path hooks/pre-push.local)"
|
||||||
|
if [ -x "$_local" ]; then
|
||||||
|
printf '%s' "$_input" | "$_local" "$@" || exit $?
|
||||||
|
fi
|
||||||
|
printf '%s' "$_input" | bun "${prepushBin}" "$@"
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(hookPath, wrapper, { mode: 0o755 });
|
||||||
|
fs.chmodSync(hookPath, 0o755);
|
||||||
|
process.stdout.write(`gstack-redact: installed pre-push hook at ${hookPath}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstallPrepushHook(): void {
|
||||||
|
const dir = hooksPath();
|
||||||
|
const hookPath = path.join(dir, "pre-push");
|
||||||
|
const localPath = path.join(dir, "pre-push.local");
|
||||||
|
if (!fs.existsSync(hookPath) || !fs.readFileSync(hookPath, "utf8").includes(MANAGED_MARKER)) {
|
||||||
|
process.stdout.write("gstack-redact: no managed pre-push hook to remove.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fs.existsSync(localPath)) {
|
||||||
|
fs.renameSync(localPath, hookPath); // restore the chained original
|
||||||
|
process.stdout.write("gstack-redact: removed managed hook, restored pre-push.local.\n");
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(hookPath);
|
||||||
|
process.stdout.write("gstack-redact: removed managed pre-push hook.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function arg(name: string): string | undefined {
|
function arg(name: string): string | undefined {
|
||||||
const i = process.argv.indexOf(name);
|
const i = process.argv.indexOf(name);
|
||||||
return i >= 0 ? process.argv[i + 1] : undefined;
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||||
@@ -115,6 +182,11 @@ function humanTable(findings: Finding[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
|
// Subcommands (positional, not flags).
|
||||||
|
const sub = process.argv[2];
|
||||||
|
if (sub === "install-prepush-hook") return installPrepushHook();
|
||||||
|
if (sub === "uninstall-prepush-hook") return uninstallPrepushHook();
|
||||||
|
|
||||||
const opts = buildOpts();
|
const opts = buildOpts();
|
||||||
const input = readInput();
|
const input = readInput();
|
||||||
|
|
||||||
|
|||||||
Executable
+146
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* gstack-redact-prepush — git pre-push hook that scans the diff being pushed for
|
||||||
|
* HIGH-severity credentials and blocks the push on a hit.
|
||||||
|
*
|
||||||
|
* THIS IS A GUARDRAIL, NOT ENFORCEMENT. `git push --no-verify` bypasses it, as
|
||||||
|
* does `GSTACK_REDACT_PREPUSH=skip`. It catches accidental credential pushes,
|
||||||
|
* the most common real-world leak. It does NOT scan history, binary/LFS/submodule
|
||||||
|
* files, or non-added lines. History scanning is /cso's job.
|
||||||
|
*
|
||||||
|
* Git pre-push interface: refs are read from STDIN, one per line:
|
||||||
|
* <local ref> <local sha> <remote ref> <remote sha>
|
||||||
|
* We scan the ADDED lines of <remote sha>..<local sha> per ref (what's being
|
||||||
|
* pushed). Special cases:
|
||||||
|
* - remote sha all-zeroes → new branch: diff against merge-base with the
|
||||||
|
* remote's default branch (fallback: scan all commits unique to local ref).
|
||||||
|
* - local sha all-zeroes → branch delete: nothing to scan, skip.
|
||||||
|
* - force-push → remote..local still gives the net new content.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - HIGH finding in added lines → print + exit 1 (block), for public AND private.
|
||||||
|
* - MEDIUM → warn (non-blocking). LOW/WARN → silent.
|
||||||
|
* - GSTACK_REDACT_PREPUSH=skip → log + exit 0 (escape valve).
|
||||||
|
*
|
||||||
|
* Installed/uninstalled via `gstack-redact install-prepush-hook` (see the
|
||||||
|
* gstack-redact CLI), which chains any pre-existing hook.
|
||||||
|
*/
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import { scan, type Finding } from "../lib/redact-engine";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const r = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
||||||
|
return r.status === 0 ? (r.stdout ?? "") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRemoteBranch(): string {
|
||||||
|
// origin/HEAD → origin/main, fall back to main/master.
|
||||||
|
const sym = git(["symbolic-ref", "refs/remotes/origin/HEAD"]).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;
|
||||||
|
}
|
||||||
|
return "origin/main";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the added-line text for a ref update being pushed. */
|
||||||
|
function addedLinesFor(localSha: string, remoteSha: string): string {
|
||||||
|
let range: string;
|
||||||
|
if (ZERO.test(remoteSha)) {
|
||||||
|
// 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();
|
||||||
|
range = base ? `${base}..${localSha}` : `${EMPTY_TREE}..${localSha}`;
|
||||||
|
} else {
|
||||||
|
// Existing branch (incl. force-push): net new content remote..local.
|
||||||
|
range = `${remoteSha}..${localSha}`;
|
||||||
|
}
|
||||||
|
// -U0: only changed lines; we keep lines starting with '+' (added), drop the
|
||||||
|
// +++ file header. Unified diff added lines start with a single '+'.
|
||||||
|
const diff = git(["diff", "--unified=0", "--no-color", range]);
|
||||||
|
const added: string[] = [];
|
||||||
|
for (const line of diff.split("\n")) {
|
||||||
|
if (line.startsWith("+") && !line.startsWith("+++")) {
|
||||||
|
added.push(line.slice(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return added.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSkip(reason: string): void {
|
||||||
|
try {
|
||||||
|
const home = process.env.GSTACK_HOME || path.join(os.homedir(), ".gstack");
|
||||||
|
const dir = path.join(home, "security");
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(dir, "prepush-skip.jsonl"),
|
||||||
|
JSON.stringify({ ts: new Date().toISOString(), reason }) + "\n",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// best-effort; never block a push because logging failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if ((process.env.GSTACK_REDACT_PREPUSH || "").toLowerCase() === "skip") {
|
||||||
|
logSkip(process.env.GSTACK_REDACT_PREPUSH_REASON || "env-skip");
|
||||||
|
process.stderr.write("gstack-redact-prepush: skipped via GSTACK_REDACT_PREPUSH=skip\n");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdin = fs.readFileSync(0, "utf8");
|
||||||
|
const refs = stdin
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => l.split(/\s+/));
|
||||||
|
|
||||||
|
const allHigh: Finding[] = [];
|
||||||
|
let mediumCount = 0;
|
||||||
|
|
||||||
|
for (const [, localSha, , remoteSha] of refs) {
|
||||||
|
if (!localSha || ZERO.test(localSha)) continue; // branch delete → nothing pushed
|
||||||
|
const added = addedLinesFor(localSha, remoteSha || "0");
|
||||||
|
if (!added.trim()) continue;
|
||||||
|
// Visibility doesn't change HIGH behavior; pass private so nothing is treated
|
||||||
|
// as public-strict (HIGH blocks regardless either way).
|
||||||
|
const result = scan(added, { repoVisibility: "private" });
|
||||||
|
for (const f of result.findings) {
|
||||||
|
if (f.severity === "HIGH") allHigh.push(f);
|
||||||
|
else if (f.severity === "MEDIUM") mediumCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediumCount > 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`gstack-redact-prepush: ${mediumCount} MEDIUM finding(s) in pushed diff (PII/internal). ` +
|
||||||
|
"Not blocking. Review before this becomes public.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allHigh.length > 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
"\n⛔ gstack-redact-prepush BLOCKED the push — credential(s) in the pushed diff:\n\n",
|
||||||
|
);
|
||||||
|
for (const f of allHigh) {
|
||||||
|
process.stderr.write(` HIGH ${f.id} ${f.preview}\n`);
|
||||||
|
}
|
||||||
|
process.stderr.write(
|
||||||
|
"\nRotate the credential (a pushed secret is compromised) and remove it from the diff.\n" +
|
||||||
|
"This is a guardrail: `git push --no-verify` or `GSTACK_REDACT_PREPUSH=skip git push` bypass it.\n",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Pre-push hook tests (T9). Builds a throwaway local "remote" + working repo,
|
||||||
|
* drives the hook with realistic stdin ref-lines, and checks: HIGH blocks,
|
||||||
|
* MEDIUM warns (non-blocking), correct remote..local diff direction, new-branch
|
||||||
|
* zero-SHA handling, branch-delete skip, escape valve, and hook chaining.
|
||||||
|
*
|
||||||
|
* We invoke bin/gstack-redact-prepush directly with the git pre-push stdin
|
||||||
|
* protocol rather than going through `git push`, which keeps the test fast and
|
||||||
|
* deterministic while exercising the exact code path git would.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import { spawnSync } from "child_process";
|
||||||
|
|
||||||
|
const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush");
|
||||||
|
const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact");
|
||||||
|
|
||||||
|
let repo: string;
|
||||||
|
|
||||||
|
function git(args: string[], cwd = repo): string {
|
||||||
|
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
||||||
|
return r.stdout?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(file: string, content: string, msg: string): string {
|
||||||
|
fs.writeFileSync(path.join(repo, file), content);
|
||||||
|
git(["add", file]);
|
||||||
|
git(["commit", "-q", "-m", msg]);
|
||||||
|
return git(["rev-parse", "HEAD"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHook(
|
||||||
|
stdinLines: string,
|
||||||
|
env: Record<string, string> = {},
|
||||||
|
): { code: number; stderr: string } {
|
||||||
|
const r = spawnSync("bun", [PREPUSH], {
|
||||||
|
cwd: repo,
|
||||||
|
input: Buffer.from(stdinLines),
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, ...env },
|
||||||
|
});
|
||||||
|
return { code: r.status ?? 0, stderr: r.stderr ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZERO = "0000000000000000000000000000000000000000";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-"));
|
||||||
|
git(["init", "-q", "-b", "main"]);
|
||||||
|
git(["config", "user.email", "t@example.com"]);
|
||||||
|
git(["config", "user.name", "T"]);
|
||||||
|
commit("README.md", "hello\n", "init");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(repo, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pre-push hook gating", () => {
|
||||||
|
test("HIGH credential in pushed diff blocks (exit 1)", () => {
|
||||||
|
const base = git(["rev-parse", "HEAD"]);
|
||||||
|
const head = commit("config.txt", "key AKIA1234567890ABCDEF\n", "add key");
|
||||||
|
const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`);
|
||||||
|
expect(code).toBe(1);
|
||||||
|
expect(stderr).toContain("BLOCKED");
|
||||||
|
expect(stderr).toContain("aws.access_key");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clean diff passes (exit 0)", () => {
|
||||||
|
const base = git(["rev-parse", "HEAD"]);
|
||||||
|
const head = commit("doc.md", "just documentation\n", "add doc");
|
||||||
|
const { code } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MEDIUM warns but does not block", () => {
|
||||||
|
const base = git(["rev-parse", "HEAD"]);
|
||||||
|
const head = commit("notes.md", "contact bob@corp.io\n", "add note");
|
||||||
|
const { code, stderr } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
expect(stderr).toContain("MEDIUM");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("diff direction + special refs", () => {
|
||||||
|
test("only NEW content is scanned (remote..local), not pre-existing", () => {
|
||||||
|
// Put a secret in the FIRST commit (already on remote), then push a clean commit.
|
||||||
|
const withSecret = commit("old.txt", "AKIA1234567890ABCDEF\n", "old secret already pushed");
|
||||||
|
const clean = commit("new.txt", "totally clean\n", "new clean commit");
|
||||||
|
// remote already has withSecret; we push only the clean commit on top.
|
||||||
|
const { code } = runHook(`refs/heads/main ${clean} refs/heads/main ${withSecret}\n`);
|
||||||
|
expect(code).toBe(0); // pre-existing secret is not in the pushed delta
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new branch (zero remote sha) scans commits unique to the branch", () => {
|
||||||
|
const head = commit("feature.txt", "ghp_" + "a".repeat(36) + "\n", "feature with token");
|
||||||
|
const { code, stderr } = runHook(`refs/heads/feat ${head} refs/heads/feat ${ZERO}\n`);
|
||||||
|
expect(code).toBe(1);
|
||||||
|
expect(stderr).toContain("github.pat");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("branch delete (zero local sha) is skipped", () => {
|
||||||
|
const { code } = runHook(`(delete) ${ZERO} refs/heads/old ${git(["rev-parse", "HEAD"])}\n`);
|
||||||
|
expect(code).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("escape valve", () => {
|
||||||
|
test("GSTACK_REDACT_PREPUSH=skip bypasses + logs", () => {
|
||||||
|
const base = git(["rev-parse", "HEAD"]);
|
||||||
|
const head = commit("config.txt", "key AKIA1234567890ABCDEF\n", "add key");
|
||||||
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ghome-"));
|
||||||
|
const { code } = runHook(`refs/heads/main ${head} refs/heads/main ${base}\n`, {
|
||||||
|
GSTACK_REDACT_PREPUSH: "skip",
|
||||||
|
GSTACK_HOME: home,
|
||||||
|
});
|
||||||
|
expect(code).toBe(0);
|
||||||
|
const log = fs.readFileSync(path.join(home, "security", "prepush-skip.jsonl"), "utf8");
|
||||||
|
expect(log).toContain("env-skip");
|
||||||
|
fs.rmSync(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("install / chaining", () => {
|
||||||
|
test("install creates a managed hook; existing hook preserved + chained", () => {
|
||||||
|
const hookDir = path.join(repo, ".git", "hooks");
|
||||||
|
fs.mkdirSync(hookDir, { recursive: true });
|
||||||
|
const existing = path.join(hookDir, "pre-push");
|
||||||
|
fs.writeFileSync(existing, "#!/usr/bin/env bash\necho mine\n", { mode: 0o755 });
|
||||||
|
|
||||||
|
const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8" });
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
const installed = fs.readFileSync(existing, "utf8");
|
||||||
|
expect(installed).toContain("gstack-redact pre-push (managed)");
|
||||||
|
expect(fs.existsSync(path.join(hookDir, "pre-push.local"))).toBe(true);
|
||||||
|
expect(fs.readFileSync(path.join(hookDir, "pre-push.local"), "utf8")).toContain("echo mine");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uninstall restores the chained original", () => {
|
||||||
|
const hookDir = path.join(repo, ".git", "hooks");
|
||||||
|
fs.mkdirSync(hookDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(hookDir, "pre-push"), "#!/usr/bin/env bash\necho mine\n", {
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo });
|
||||||
|
spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo });
|
||||||
|
const restored = fs.readFileSync(path.join(hookDir, "pre-push"), "utf8");
|
||||||
|
expect(restored).toContain("echo mine");
|
||||||
|
expect(restored).not.toContain("managed");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user