mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-07 17:28:23 +01:00
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:
1 parent
0dbc60708e
commit
e75a69c9e1
7 files changed
+150
-30
No files matched your search
@@ -0,0 +1,2 @@
|
||||
- **/setup-gbrain's source wireup step no longer puts the gbrain database URL on the command line.** The step now runs `gstack-gbrain-source-wireup --strict` without `--database-url`, with `GBRAIN_DATABASE_URL` and `DATABASE_URL` cleared for the call, so the helper reads `~/.gbrain/config.json` once at startup and pins that URL for every `gbrain` call it makes, as the flag did before; a `DATABASE_URL` in the user's shell can't redirect it. The URL holds the database password, and command-line arguments are visible to other local users in `ps`. A new test keeps every setup-gbrain doc from passing the URL to the helper again. (GST-1747)
|
||||
- **The redaction gate blocks more credential formats.** `lib/redact-patterns.ts` adds HIGH patterns for OpenRouter keys (`sk-or-v1-`), GitHub App user-to-server and refresh tokens (`ghu_`, `ghr_`), Stripe live restricted keys (`rk_live_`), Supabase secret keys (`sb_secret_`) and personal access tokens (`sbp_`), and `aws.access_key` now also matches temporary `ASIA` key IDs. `gstack-redact`, the pre-push hook and every skill that scans through the redaction engine pick them up. (GST-1747)
|
||||
+44
-2
@@ -1161,8 +1161,8 @@ export const PATTERNS: RedactPattern[] = [
|
||||
id: "aws.access_key",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "AWS access key ID (AKIA…)",
|
||||
regex: /\b(AKIA[0-9A-Z]{16})\b/,
|
||||
description: "AWS access key ID (AKIA… long-term, ASIA… temporary)",
|
||||
regex: /\b((?:AKIA|ASIA)[0-9A-Z]{16})\b/,
|
||||
},
|
||||
{
|
||||
id: "aws.secret_key",
|
||||
@@ -1194,6 +1194,20 @@ export const PATTERNS: RedactPattern[] = [
|
||||
description: "GitHub server-to-server token",
|
||||
regex: /\b(ghs_[A-Za-z0-9]{36})\b/,
|
||||
},
|
||||
{
|
||||
id: "github.user_to_server",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "GitHub App user-to-server token",
|
||||
regex: /\b(ghu_[A-Za-z0-9]{36})\b/,
|
||||
},
|
||||
{
|
||||
id: "github.refresh",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "GitHub App refresh token",
|
||||
regex: /\b(ghr_[A-Za-z0-9]{36,})\b/,
|
||||
},
|
||||
{
|
||||
id: "github.fine_grained",
|
||||
tier: "HIGH",
|
||||
@@ -1289,6 +1303,13 @@ export const PATTERNS: RedactPattern[] = [
|
||||
regex:
|
||||
/\b(sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9]{32,})\b/,
|
||||
},
|
||||
{
|
||||
id: "openrouter.key",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "OpenRouter API key",
|
||||
regex: /\b(sk-or-v1-[a-f0-9]{64})\b/,
|
||||
},
|
||||
{
|
||||
id: "sendgrid.key",
|
||||
tier: "HIGH",
|
||||
@@ -1303,6 +1324,27 @@ export const PATTERNS: RedactPattern[] = [
|
||||
description: "Stripe live SECRET key",
|
||||
regex: /\b(sk_live_[A-Za-z0-9]{24,})\b/,
|
||||
},
|
||||
{
|
||||
id: "stripe.restricted",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "Stripe live restricted key",
|
||||
regex: /\b(rk_live_[A-Za-z0-9]{24,})\b/,
|
||||
},
|
||||
{
|
||||
id: "supabase.secret",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "Supabase secret API key",
|
||||
regex: /\b(sb_secret_[A-Za-z0-9_-]{20,})\b/,
|
||||
},
|
||||
{
|
||||
id: "supabase.pat",
|
||||
tier: "HIGH",
|
||||
category: "secret",
|
||||
description: "Supabase personal access token",
|
||||
regex: /\b(sbp_[a-f0-9]{40})\b/,
|
||||
},
|
||||
{
|
||||
id: "slack.token",
|
||||
tier: "HIGH",
|
||||
|
||||
+9
-14
@@ -485,22 +485,17 @@ any gbrain client. The helper creates a `git worktree` of `~/.gstack/`,
|
||||
registers it as a federated source via `gbrain sources add --path
|
||||
--federated`, and runs an initial `gbrain sync`. Local-stdio paths only.
|
||||
|
||||
Capture the database URL out of `~/.gbrain/config.json` first and pass it
|
||||
explicitly so the wireup is robust against any other process rewriting
|
||||
`~/.gbrain/config.json` mid-sync (e.g., concurrent `gbrain init` runs
|
||||
elsewhere on the machine):
|
||||
The helper reads the database URL from `~/.gbrain/config.json` once at
|
||||
startup and pins it for every `gbrain` call it makes, so another process
|
||||
rewriting that file mid-sync (e.g., a concurrent `gbrain init`) can't redirect
|
||||
it. Don't pass the URL as an argument: it holds the database password, and
|
||||
arguments are visible to other local users in `ps`. Clear
|
||||
`GBRAIN_DATABASE_URL` and `DATABASE_URL` for the call: the helper ranks both
|
||||
above `config.json`, and a shell's `DATABASE_URL` often points at an app's
|
||||
own database.
|
||||
|
||||
```bash
|
||||
GBRAIN_URL=$(python3 -c "
|
||||
import json, os, sys
|
||||
try:
|
||||
c = json.load(open(os.path.expanduser('~/.gbrain/config.json')))
|
||||
print(c.get('database_url', ''))
|
||||
except Exception:
|
||||
pass
|
||||
")
|
||||
~/.claude/skills/gstack/bin/gstack-gbrain-source-wireup --strict \
|
||||
${GBRAIN_URL:+--database-url "$GBRAIN_URL"}
|
||||
GBRAIN_DATABASE_URL= DATABASE_URL= ~/.claude/skills/gstack/bin/gstack-gbrain-source-wireup --strict
|
||||
```
|
||||
|
||||
`--strict` exits non-zero on missing prereqs (gbrain not installed, < 0.18.0,
|
||||
|
||||
@@ -400,22 +400,17 @@ any gbrain client. The helper creates a `git worktree` of `~/.gstack/`,
|
||||
registers it as a federated source via `gbrain sources add --path
|
||||
--federated`, and runs an initial `gbrain sync`. Local-stdio paths only.
|
||||
|
||||
Capture the database URL out of `~/.gbrain/config.json` first and pass it
|
||||
explicitly so the wireup is robust against any other process rewriting
|
||||
`~/.gbrain/config.json` mid-sync (e.g., concurrent `gbrain init` runs
|
||||
elsewhere on the machine):
|
||||
The helper reads the database URL from `~/.gbrain/config.json` once at
|
||||
startup and pins it for every `gbrain` call it makes, so another process
|
||||
rewriting that file mid-sync (e.g., a concurrent `gbrain init`) can't redirect
|
||||
it. Don't pass the URL as an argument: it holds the database password, and
|
||||
arguments are visible to other local users in `ps`. Clear
|
||||
`GBRAIN_DATABASE_URL` and `DATABASE_URL` for the call: the helper ranks both
|
||||
above `config.json`, and a shell's `DATABASE_URL` often points at an app's
|
||||
own database.
|
||||
|
||||
```bash
|
||||
GBRAIN_URL=$(python3 -c "
|
||||
import json, os, sys
|
||||
try:
|
||||
c = json.load(open(os.path.expanduser('~/.gbrain/config.json')))
|
||||
print(c.get('database_url', ''))
|
||||
except Exception:
|
||||
pass
|
||||
")
|
||||
~/.claude/skills/gstack/bin/gstack-gbrain-source-wireup --strict \
|
||||
${GBRAIN_URL:+--database-url "$GBRAIN_URL"}
|
||||
GBRAIN_DATABASE_URL= DATABASE_URL= ~/.claude/skills/gstack/bin/gstack-gbrain-source-wireup --strict
|
||||
```
|
||||
|
||||
`--strict` exits non-zero on missing prereqs (gbrain not installed, < 0.18.0,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user