fix: keep the gbrain database URL off argv; redact more credential formats (GST-1747) (#4114)

* fix: keep the gbrain database URL off argv; redact more credential formats (GST-1747)

/setup-gbrain runs gstack-gbrain-source-wireup without --database-url; the
helper reads ~/.gbrain/config.json once at startup and pins the URL. A new
guard test keeps setup-gbrain docs from passing the URL on argv.

lib/redact-patterns.ts adds HIGH patterns for OpenRouter keys, GitHub App
ghu_/ghr_ tokens, Stripe live restricted keys, Supabase sb_secret_ and sbp_
tokens, and matches temporary ASIA AWS key IDs in aws.access_key.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: name the changelog fragment after the branch (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Clear GBRAIN_DATABASE_URL and DATABASE_URL for the setup-gbrain source wireup call so the helper pins the ~/.gbrain/config.json URL, as the --database-url flag did before. Guard test added; changelog fragment scoped to the wireup step.

Review-Finding: p1/specialist-batched#1 reader=security
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: match full-length GitHub refresh tokens in the redaction gate

github.refresh now accepts a body of 36 or more characters, so the 76-character refresh tokens GitHub issues match and are masked whole. Test samples use a 76-character body; the 35-character near-miss stays negative.

Review-Finding: p3/adversarial-native#1 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-07 06:56:32 +00:00
1 parent 0dbc60708e
commit e75a69c9e1
7 files changed
+150 -30

No files matched your search

+6
View File
@@ -19,6 +19,8 @@ const SAMPLES: Record<string, string> = {
"github.pat": j("ghp_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"),
"github.oauth": j("gho_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"),
"github.server": j("ghs_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"),
"github.user_to_server": j("ghu_", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"),
"github.refresh": j("ghr_", "a1B2c3D4e5F6g7H8i9J0".repeat(3), "k1L2m3N4o5P6q7R8"),
"github.fine_grained": j("github_pat_", "a1B2c3D4e5F6g7H8i9J0".repeat(4), "kL"),
"gitlab.token": j("glpat-", "a1B2c3D4e5F6g7H8i9J0"),
"groq.key": j("gsk_", "a1B2c3D4e5F6g7H8i9J0k1L2"),
@@ -31,7 +33,11 @@ const SAMPLES: Record<string, string> = {
"anthropic.key": j("sk-ant-", "api03-a1B2c3D4e5F6g7H8i9J0k1L2"),
"openai.key": j("sk-proj-", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3X4"),
"sendgrid.key": j("SG.", "a1B2c3D4e5F6g7H8i9J0k1", ".", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V"),
"openrouter.key": j("sk-or-v1-", hex32, hex32),
"stripe.secret": j("sk_live_", "a1B2c3D4e5F6g7H8i9J0k1L2"),
"stripe.restricted": j("rk_live_", "a1B2c3D4e5F6g7H8i9J0k1L2"),
"supabase.secret": j("sb_secret_", "tR4i6cyMIrYTE3s6OyHGHw_ppx2p6WK"),
"supabase.pat": j("sbp_", hex32, "9f8e7d6c"),
"slack.token": j("xoxb-", "12345", "67890-a1B2c3D4e5F6"),
"slack.webhook": j("https://hooks.slack.com/services/", "T0ABCDEFG/B0ABCDEFG/", "a1B2c3D4e5F6g7H8i9J0k1L2"),
"discord.webhook": j("https://discord.com/api/webhooks/", "123456789012345678/", "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3X4y5Z6a7B8c9D0e1F2"),
+27
View File
@@ -62,6 +62,13 @@ describe("HIGH credential patterns", () => {
],
["google.oauth_client_secret", 'client_secret: "GOCSPX-' + "Ab3xQ9zLmNp2RtVw7YkD1sHf" + '"'],
["telegram.bot_token", "TELEGRAM_TOKEN=8326208591:AA" + "HdqRy9Lm2ZpXvKb4NcQw8TuEr6YoP1sVg"],
["aws.access_key", "temporary key ASIA" + "ZQ3XN7P2LM4RT8WV"],
["github.user_to_server", "ghu_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8"],
["github.refresh", "ghr_" + "a1B2c3D4e5F6g7H8i9J0".repeat(3) + "k1L2m3N4o5P6q7R8"],
["openrouter.key", "OPENROUTER sk-or-v1-" + "9f8e7d6c5b4a39281706f5e4d3c2b1a0".repeat(2)],
["stripe.restricted", "rk_live_" + "a1B2c3D4e5F6g7H8i9J0k1L2"],
["supabase.secret", "sb_secret_" + "tR4i6cyMIrYTE3s6OyHGHw_ppx2p6WK"],
["supabase.pat", "sbp_" + "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c"],
];
for (const [id, text] of cases) {
test(`flags ${id}`, () => {
@@ -463,6 +470,20 @@ describe("#1946 pattern negatives (placeholders never fire)", () => {
});
});
describe("OpenRouter, GitHub ghu_/ghr_, Stripe restricted, Supabase and AWS temporary key negatives", () => {
test("near-miss shapes never fire", () => {
expect(ids("sk-or-v1-" + "9f8e7d6c5b4a39281706f5e4d3c2b1a0")).not.toContain("openrouter.key");
expect(ids("ghu_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R")).not.toContain("github.user_to_server");
expect(ids("ghr_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R")).not.toContain("github.refresh");
expect(ids("rk_test_" + "a1B2c3D4e5F6g7H8i9J0k1L2")).not.toContain("stripe.restricted");
expect(ids("sb_publishable_" + "tR4i6cyMIrYTE3s6OyHGHw_ppx2p6WK")).not.toContain("supabase.secret");
expect(ids("sb_secret_key")).not.toContain("supabase.secret");
expect(ids("sbp_" + "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6")).not.toContain("supabase.pat");
expect(ids("sbp_" + "Zf8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c")).not.toContain("supabase.pat");
expect(ids("ASIA" + "ZQ3XN7P2LM4RT8W")).not.toContain("aws.access_key");
});
});
describe("google.oauth_client_secret / telegram.bot_token negatives", () => {
test("undersized and placeholder shapes never fire", () => {
// Length floor keeps short repo fixtures quiet (e.g. the 19-char body in
@@ -749,6 +770,12 @@ describe("redactFindingSpans — machine-egress masking (#1947)", () => {
expect(out).toBe("auth <REDACTED-github.pat> rejected");
});
test("a 76-character GitHub refresh token is masked whole", () => {
const token = "ghr_" + "a1B2c3D4e5F6g7H8i9J0".repeat(3) + "k1L2m3N4o5P6q7R8";
const out = redactFindingSpans(`auth ${token} rejected`, { repoVisibility: "private" });
expect(out).toBe("auth <REDACTED-github.refresh> rejected");
});
test("multiple findings are all replaced (right-to-left splice keeps offsets valid)", () => {
const pat = "ghp_" + "1234567890abcdefghijklmnopqrstuvwxyz";
const aws = "AKIA1234567890ABCDEF";
+53
View File
@@ -0,0 +1,53 @@
// setup-gbrain keeps the gbrain database URL off the process command line.
//
// The URL carries the Postgres password. A value passed as an argument is
// readable by every local user through `ps` for as long as the process runs,
// so gstack passes credentials through env or a config file, never argv (the
// same rule lib/gbrain-supabase-provision.ts states for DB_PASS). The wireup
// helper already reads ~/.gbrain/config.json once at startup and pins it for
// every child gbrain call (covered in test/gstack-gbrain-source-wireup.test.ts),
// so no skill doc needs to hand it the URL.
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const SKILL_DIR = path.join(ROOT, 'setup-gbrain');
const SECTIONS_DIR = path.join(SKILL_DIR, 'sections');
const docs: Array<[string, string]> = [
'SKILL.md.tmpl',
'SKILL.md',
...fs
.readdirSync(SECTIONS_DIR)
.filter((f) => f.endsWith('.md.tmpl') || f.endsWith('.md'))
.sort()
.map((f) => path.join('sections', f)),
].map((rel) => [rel, fs.readFileSync(path.join(SKILL_DIR, rel), 'utf-8')]);
// One shell command: the helper name plus any backslash-continued lines.
const wireupCommand = /gstack-gbrain-source-wireup\b(?:[^\n]*\\\r?\n)*[^\n]*/g;
describe('setup-gbrain passes no database URL on argv', () => {
test('the wireup command is still documented (guard is not vacuous)', () => {
const invocations = docs.flatMap(([, text]) => text.match(wireupCommand) ?? []);
expect(invocations.some((cmd) => cmd.includes('--strict'))).toBe(true);
});
test('no gstack-gbrain-source-wireup invocation carries --database-url', () => {
const offenders = docs.flatMap(([rel, text]) =>
(text.match(wireupCommand) ?? []).filter((cmd) => cmd.includes('--database-url')).map((cmd) => `${rel}: ${cmd}`),
);
expect(offenders).toEqual([]);
});
test('the wireup command clears ambient database URLs so config.json wins', () => {
const lines = docs.flatMap(([rel, text]) =>
text.split(/\r?\n/).filter((l) => /bin\/gstack-gbrain-source-wireup --strict/.test(l)).map((l) => `${rel}: ${l.trim()}`),
);
expect(lines.length).toBeGreaterThan(0);
const offenders = lines.filter((l) => !/: GBRAIN_DATABASE_URL= DATABASE_URL= ~\/\.claude\//.test(l));
expect(offenders).toEqual([]);
});
});