Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions node_modules
91 changes: 91 additions & 0 deletions packages/create-agent-harness/__tests__/redact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: MIT
// GH #4 HIGH-2: the bundle/export/score sanitisers must redact secret-shaped VALUES, not only
// secret-named KEYS. These tests pin looksLikeSecretValue's precision (real tokens redact; SHAs/UUIDs
// do not) and redactSecretsDeep's key + value behaviour, plus the diag-bundle integration.

import { describe, it, expect } from 'vitest';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { looksLikeSecretValue, redactSecretsDeep } from '../src/redact.js';
import { buildSupportBundle } from '../src/diag.js';

describe('looksLikeSecretValue — real secrets (redact)', () => {
const secrets = [
['OpenAI key', 'sk-ant-api03-AbCdEf0123456789AbCdEf0123456789'],
['GitHub PAT', 'ghp_16CharsOrMoreToken0123456789abcd'],
['AWS access key id', 'AKIAIOSFODNN7EXAMPLE'],
['Google API key', 'AIzaSyD-1234567890abcdEFGHijklMNOpqrstuv'],
['JWT', 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'],
['base64 secret in a plain field', 'dGhpc0lzQVZlcnlMb25nQmFzZTY0U2VjcmV0VmFsdWUxMjM0NTY3ODkw'],
['PEM header', '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...'],
];
for (const [name, val] of secrets) {
it(`redacts ${name}`, () => expect(looksLikeSecretValue(val)).toBe(true));
}
});

describe('looksLikeSecretValue — non-secrets (keep, avoid false positives)', () => {
const keep = [
['a git SHA (lowercase hex)', 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0'],
['a sha256 digest (lowercase hex)', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'],
['a dashed UUID', '550e8400-e29b-41d4-a716-446655440000'],
['a short version string', '1.2.3-beta.4'],
['an ordinary sentence', 'the quick brown fox jumps over the lazy dog'],
['a file path', 'packages/create-agent-harness/src/redact.ts'],
['empty', ''],
];
for (const [name, val] of keep) {
it(`keeps ${name}`, () => expect(looksLikeSecretValue(val)).toBe(false));
}
});

describe('redactSecretsDeep', () => {
const opts = { keyRe: /(secret|token|key|password)/i, replacement: '[REDACTED]' };

it('redacts by key name (existing behaviour preserved)', () => {
const out = redactSecretsDeep({ api_key: 'anything', ok: 'fine' }, opts) as Record<string, unknown>;
expect(out.api_key).toBe('[REDACTED]');
expect(out.ok).toBe('fine');
});

it('redacts a secret-shaped VALUE under a NON-secret key (the HIGH-2 fix)', () => {
const out = redactSecretsDeep({ vars: { deploy_hook: 'sk-ant-api03-AbCdEf0123456789AbCdEf0123456789' } }, opts) as any;
expect(out.vars.deploy_hook).toBe('[REDACTED]');
});

it('recurses through arrays and nested objects', () => {
const out = redactSecretsDeep({ list: [{ note: 'ghp_16CharsOrMoreToken0123456789abcd' }] }, opts) as any;
expect(out.list[0].note).toBe('[REDACTED]');
});

it('leaves ordinary values and non-strings untouched', () => {
const out = redactSecretsDeep({ name: 'demo', count: 42, on: true }, opts) as any;
expect(out).toEqual({ name: 'demo', count: 42, on: true });
});

it('honours the per-site replacement token', () => {
const out = redactSecretsDeep({ password: 'x' }, { keyRe: /password/i, replacement: '<redacted>' }) as any;
expect(out.password).toBe('<redacted>');
});
});

describe('diag --bundle integration (GH #4 HIGH-2 leak)', () => {
it('redacts a secret-shaped value pasted into a non-secret manifest field', async () => {
const dir = await mkdtemp(join(tmpdir(), 'cah-redact-'));
await mkdir(join(dir, '.harness'), { recursive: true });
const manifest = {
schema: 1, generator: '0.3.1', template: 'minimal',
// A token captured into a user-chosen `vars` entry — NOT a secret-named key.
vars: { name: 'demo', webhook: 'sk-ant-api03-AbCdEf0123456789AbCdEf0123456789' },
hosts: ['claude-code'], files: {},
};
await writeFile(join(dir, '.harness', 'manifest.json'), JSON.stringify(manifest));
const bundle = await buildSupportBundle(dir);
const blob = JSON.stringify(bundle);
expect(blob).not.toContain('sk-ant-api03-AbCdEf0123456789AbCdEf0123456789');
expect((bundle.manifest.content as any).vars.webhook).toBe('<redacted>');
// ordinary value stays
expect((bundle.manifest.content as any).vars.name).toBe('demo');
});
});
14 changes: 5 additions & 9 deletions packages/create-agent-harness/src/diag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { readFile } from 'node:fs/promises';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { redactSecretsDeep } from './redact.js';
/** Mirror of subcommands.ts's exported shape — kept local to avoid a
* cyclic import (subcommands.ts imports from diag.ts via diagCmd). */
export type SubcommandResult = { code: number; lines: string[] };
Expand Down Expand Up @@ -393,16 +394,11 @@ export interface SupportBundle {

const REDACT_KEY_RE = /^(secret|token|key|password|api[-_]?key)/i;

// GH #4 (HIGH-2): redact by KEY name AND by VALUE shape — a secret-shaped value in a non-secret-named
// field (e.g. a token pasted into a `vars` entry) must not survive into a bundle a user pastes into an
// issue. Value-aware detection lives in redact.ts (single source of truth, finding #7).
function sanitiseManifest(m: unknown): unknown {
if (m === null || typeof m !== 'object') return m;
if (Array.isArray(m)) return m.map(sanitiseManifest);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(m as Record<string, unknown>)) {
if (REDACT_KEY_RE.test(k)) out[k] = '<redacted>';
else if (typeof v === 'string' && REDACT_KEY_RE.test(k)) out[k] = '<redacted>';
else out[k] = sanitiseManifest(v);
}
return out;
return redactSecretsDeep(m, { keyRe: REDACT_KEY_RE, replacement: '<redacted>' });
}

export async function buildSupportBundle(harnessDir: string): Promise<SupportBundle> {
Expand Down
11 changes: 3 additions & 8 deletions packages/create-agent-harness/src/export-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { redactSecretsDeep } from './redact.js';

export type SubcommandResult = { code: number; lines: string[] };

Expand All @@ -28,15 +29,9 @@ export type SubcommandResult = { code: number; lines: string[] };
// for an audit-share artefact.
const REDACT_KEY_RE = /(secret|token|key|password|passphrase)/i;

// GH #4 (HIGH-2): redact by KEY name AND by VALUE shape (redact.ts — single source of truth, #7).
function redact(v: unknown): unknown {
if (v === null || typeof v !== 'object') return v;
if (Array.isArray(v)) return v.map(redact);
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (REDACT_KEY_RE.test(k)) out[k] = '<redacted>';
else out[k] = redact(val);
}
return out;
return redactSecretsDeep(v, { keyRe: REDACT_KEY_RE, replacement: '<redacted>' });
}

export interface ExportedConfig {
Expand Down
13 changes: 3 additions & 10 deletions packages/create-agent-harness/src/genome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { existsSync, statSync, writeFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { inventory, analyzeFiles, recommendPlan, type RepoProfile, type HarnessPlan } from './analyze-repo.js';
import { redactSecretsDeep } from './redact.js';
import {
resolveAgentTopology,
scoreMcpRisk,
Expand Down Expand Up @@ -177,17 +178,9 @@ export function formatGenomeReport(r: GenomeReport): string[] {

const SECRET_RE = /(secret|token|key|password|passphrase)/i;

// GH #4 (HIGH-2): redact by KEY name AND by VALUE shape (redact.ts — single source of truth, #7).
function sanitiseValue(v: unknown): unknown {
if (v == null) return v;
if (Array.isArray(v)) return v.map(sanitiseValue);
if (typeof v === 'object') {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
out[k] = SECRET_RE.test(k) ? '[REDACTED]' : sanitiseValue(val);
}
return out;
}
return v;
return redactSecretsDeep(v, { keyRe: SECRET_RE, replacement: '[REDACTED]' });
}

// --- dispatch --------------------------------------------------------------
Expand Down
66 changes: 66 additions & 0 deletions packages/create-agent-harness/src/redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: MIT
//
// Single source of truth for secret redaction in every user-facing output surface — the diag
// support-bundle, export-config, score/genome/threat-model sanitisers (GH #4 finding #7). Before this,
// five copies of the same recursive redactor each keyed ONLY on object-key NAMES, so a secret-shaped
// VALUE landing in a field NOT named secret/token/key (e.g. a token pasted into a `vars` entry) sailed
// through into a `harness diag --bundle` a user pastes into a GitHub issue — the exact HIGH-2 leak in #4
// (the "bundle is sanitised" promise was value-blind). This module adds VALUE-aware detection on top of
// the key-name pass, so both a secret-named key AND a secret-shaped value redact.

// High-confidence provider token shapes (prefix-anchored — near-zero false positives).
const SECRET_PREFIX_RE =
/(?:^|[^A-Za-z0-9])(sk-[A-Za-z0-9]|sk-ant-|rk_[A-Za-z0-9]|ghp_|gho_|ghu_|ghs_|github_pat_|glpat-|xox[baprs]-|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{10,}|ya29\.|npm_[A-Za-z0-9]{20}|AC[a-z0-9]{30,}|SK[a-z0-9]{30,})/;
// PEM private-key blocks.
const PEM_RE = /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/;
// JWT (header.payload.signature, all base64url).
const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/;

/**
* Heuristic: does this STRING look like a secret, independent of the key it's stored under?
*
* Conservative by construction — precision is favoured over recall because over-redacting a legitimate
* value in a score/genome dump is a real (if smaller) cost:
* 1. a known provider token prefix (sk-/ghp_/AKIA/AIza/JWT/PEM/…) — high confidence, OR
* 2. a generic ≥40-char base64/token run that is MIXED-CASE alphanumeric (letters+digits, upper+lower).
*
* The mixed-case+digit requirement in (2) deliberately EXCLUDES lowercase-hex git SHAs, uppercase-hex
* digests, and dashed UUIDs — all common NON-secret identifiers — so we don't redact them.
*/
export function looksLikeSecretValue(s: unknown): boolean {
if (typeof s !== 'string' || s.length < 20) return false;
if (PEM_RE.test(s) || JWT_RE.test(s) || SECRET_PREFIX_RE.test(s)) return true;
const m = s.match(/[A-Za-z0-9_\-+/=]{40,}/);
if (m) {
const tok = m[0];
if (/[A-Z]/.test(tok) && /[a-z]/.test(tok) && /[0-9]/.test(tok)) return true;
}
return false;
}

export interface RedactOptions {
/** Redact a value when its KEY name matches (the existing behaviour of every call site). */
keyRe: RegExp;
/** The replacement token — kept per-site (`<redacted>` for bundles, `[REDACTED]` for score/genome/threat). */
replacement: string;
}

/**
* Deep-copy `value`, redacting (a) any object value whose KEY matches `keyRe`, and (b) any string
* value that `looksLikeSecretValue` — anywhere in the tree, including a bare top-level string.
* Non-string primitives pass through. Pure (no I/O); safe to run on untrusted manifest/config data.
*/
export function redactSecretsDeep(value: unknown, opts: RedactOptions): unknown {
const { keyRe, replacement } = opts;
if (value == null) return value;
if (typeof value === 'string') return looksLikeSecretValue(value) ? replacement : value;
if (Array.isArray(value)) return value.map(v => redactSecretsDeep(v, opts));
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = keyRe.test(k) ? replacement : redactSecretsDeep(v, opts);
}
return out;
}
return value;
}
13 changes: 3 additions & 10 deletions packages/create-agent-harness/src/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import { existsSync, statSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { redactSecretsDeep } from './redact.js';

export type SubcommandResult = { code: number; lines: string[] };

Expand Down Expand Up @@ -344,17 +345,9 @@ export function formatScorecard(sc: Scorecard): string[] {

const SECRET_RE = /(secret|token|key|password|passphrase)/i;

// GH #4 (HIGH-2): redact by KEY name AND by VALUE shape (redact.ts — single source of truth, #7).
function sanitise(v: unknown): unknown {
if (v == null) return v;
if (Array.isArray(v)) return v.map(sanitise);
if (typeof v === 'object') {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
out[k] = SECRET_RE.test(k) ? '[REDACTED]' : sanitise(val);
}
return out;
}
return v;
return redactSecretsDeep(v, { keyRe: SECRET_RE, replacement: '[REDACTED]' });
}

// --- dispatch --------------------------------------------------------------
Expand Down
13 changes: 3 additions & 10 deletions packages/create-agent-harness/src/threat-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import { existsSync, statSync, writeFileSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { scanMcp, type Severity } from './mcp-scan.js';
import { redactSecretsDeep } from './redact.js';

export type SubcommandResult = { code: number; lines: string[] };

Expand Down Expand Up @@ -177,17 +178,9 @@ export function formatThreatModel(tm: ThreatModel): string[] {

const SECRET_RE = /(secret|token|key|password|passphrase)/i;

// GH #4 (HIGH-2): redact by KEY name AND by VALUE shape (redact.ts — single source of truth, #7).
function sanitise(v: unknown): unknown {
if (v == null) return v;
if (Array.isArray(v)) return v.map(sanitise);
if (typeof v === 'object') {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
out[k] = SECRET_RE.test(k) ? '[REDACTED]' : sanitise(val);
}
return out;
}
return v;
return redactSecretsDeep(v, { keyRe: SECRET_RE, replacement: '[REDACTED]' });
}

// --- dispatch --------------------------------------------------------------
Expand Down
Loading