diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..4c37106c --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/ruvultra/projects/agent-harness-generator/node_modules \ No newline at end of file diff --git a/packages/create-agent-harness/__tests__/redact.test.ts b/packages/create-agent-harness/__tests__/redact.test.ts new file mode 100644 index 00000000..89419384 --- /dev/null +++ b/packages/create-agent-harness/__tests__/redact.test.ts @@ -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; + 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: '' }) as any; + expect(out.password).toBe(''); + }); +}); + +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(''); + // ordinary value stays + expect((bundle.manifest.content as any).vars.name).toBe('demo'); + }); +}); diff --git a/packages/create-agent-harness/src/diag.ts b/packages/create-agent-harness/src/diag.ts index 6bac6be3..56bacab9 100644 --- a/packages/create-agent-harness/src/diag.ts +++ b/packages/create-agent-harness/src/diag.ts @@ -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[] }; @@ -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 = {}; - for (const [k, v] of Object.entries(m as Record)) { - if (REDACT_KEY_RE.test(k)) out[k] = ''; - else if (typeof v === 'string' && REDACT_KEY_RE.test(k)) out[k] = ''; - else out[k] = sanitiseManifest(v); - } - return out; + return redactSecretsDeep(m, { keyRe: REDACT_KEY_RE, replacement: '' }); } export async function buildSupportBundle(harnessDir: string): Promise { diff --git a/packages/create-agent-harness/src/export-config.ts b/packages/create-agent-harness/src/export-config.ts index d8274278..798c55b5 100644 --- a/packages/create-agent-harness/src/export-config.ts +++ b/packages/create-agent-harness/src/export-config.ts @@ -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[] }; @@ -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 = {}; - for (const [k, val] of Object.entries(v as Record)) { - if (REDACT_KEY_RE.test(k)) out[k] = ''; - else out[k] = redact(val); - } - return out; + return redactSecretsDeep(v, { keyRe: REDACT_KEY_RE, replacement: '' }); } export interface ExportedConfig { diff --git a/packages/create-agent-harness/src/genome.ts b/packages/create-agent-harness/src/genome.ts index f8dc9599..b841f051 100644 --- a/packages/create-agent-harness/src/genome.ts +++ b/packages/create-agent-harness/src/genome.ts @@ -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, @@ -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 = {}; - for (const [k, val] of Object.entries(v as Record)) { - out[k] = SECRET_RE.test(k) ? '[REDACTED]' : sanitiseValue(val); - } - return out; - } - return v; + return redactSecretsDeep(v, { keyRe: SECRET_RE, replacement: '[REDACTED]' }); } // --- dispatch -------------------------------------------------------------- diff --git a/packages/create-agent-harness/src/redact.ts b/packages/create-agent-harness/src/redact.ts new file mode 100644 index 00000000..36bf78e0 --- /dev/null +++ b/packages/create-agent-harness/src/redact.ts @@ -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 (`` 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 = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = keyRe.test(k) ? replacement : redactSecretsDeep(v, opts); + } + return out; + } + return value; +} diff --git a/packages/create-agent-harness/src/score.ts b/packages/create-agent-harness/src/score.ts index 026a07a3..85d04444 100644 --- a/packages/create-agent-harness/src/score.ts +++ b/packages/create-agent-harness/src/score.ts @@ -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[] }; @@ -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 = {}; - for (const [k, val] of Object.entries(v as Record)) { - out[k] = SECRET_RE.test(k) ? '[REDACTED]' : sanitise(val); - } - return out; - } - return v; + return redactSecretsDeep(v, { keyRe: SECRET_RE, replacement: '[REDACTED]' }); } // --- dispatch -------------------------------------------------------------- diff --git a/packages/create-agent-harness/src/threat-model.ts b/packages/create-agent-harness/src/threat-model.ts index f283e14f..237fa56b 100644 --- a/packages/create-agent-harness/src/threat-model.ts +++ b/packages/create-agent-harness/src/threat-model.ts @@ -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[] }; @@ -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 = {}; - for (const [k, val] of Object.entries(v as Record)) { - out[k] = SECRET_RE.test(k) ? '[REDACTED]' : sanitise(val); - } - return out; - } - return v; + return redactSecretsDeep(v, { keyRe: SECRET_RE, replacement: '[REDACTED]' }); } // --- dispatch --------------------------------------------------------------