From 7b4e7828ce6f38ad337911bd265b370e8bcbbd91 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:05:50 -0400 Subject: [PATCH] fix: redact escaped quoted credentials in observation records Carry forward the bounded serialized-quote defect identified by PR 2786. Metadata-only observation consent remains separate; exclude the broader provider lifecycle head. Co-authored-by: 646826 <646826@gmail.com> --- .../continuous-learning-v2/hooks/observe.sh | 7 +- tests/hooks/observe-secret-redaction.test.js | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/hooks/observe-secret-redaction.test.js diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index fafb57a62..b9bc37bb9 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -273,7 +273,7 @@ import json, sys, os, re # prevent the catastrophic backtracking that pegged python at 100% CPU (#2278). _SECRET_RE = re.compile( r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)" - r"""(["'"'"'\s:=]{1,8})""" + r"""(["'"'"'\s:=\\]{1,8})""" r"((?:bearer|basic|token|bot)\s+)?" r"([A-Za-z0-9_\-/.+=]{8,256})" ) @@ -336,14 +336,15 @@ observation = { "project_name": os.environ.get("PROJECT_NAME_ENV", "global") } -# Scrub secrets: match common key=value, key: value, and key"value patterns +# Scrub secrets, including JSON-escaped quotes/backslashes in serialized tool I/O. +# This is defense in depth; arbitrary content still requires a metadata-only capture boundary. # Includes optional auth scheme (e.g., "Bearer", "Basic") before token # Linear-time secret matcher. Bounded quantifiers and a fixed set of auth # schemes (instead of a generic [A-Za-z]+\s+ that overlapped the value class) # prevent the catastrophic backtracking that pegged python at 100% CPU (#2278). _SECRET_RE = re.compile( r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)" - r"""(["'"'"'\s:=]{1,8})""" + r"""(["'"'"'\s:=\\]{1,8})""" r"((?:bearer|basic|token|bot)\s+)?" r"([A-Za-z0-9_\-/.+=]{8,256})" ) diff --git a/tests/hooks/observe-secret-redaction.test.js b/tests/hooks/observe-secret-redaction.test.js new file mode 100644 index 000000000..fbebca8ad --- /dev/null +++ b/tests/hooks/observe-secret-redaction.test.js @@ -0,0 +1,69 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const repoRoot = path.resolve(__dirname, '../..'); +const runner = path.join(repoRoot, 'scripts/hooks/run-with-flags.js'); +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { fn(); console.log(` PASS ${name}`); passed += 1; } + catch (error) { console.log(` FAIL ${name}: ${error.message}`); failed += 1; } +} + +function capture(phase, input) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-observe-redaction-')); + try { + const data = path.join(root, 'data'); + const result = spawnSync(process.execPath, [runner, `${phase}:observe`, + 'scripts/hooks/observe-runner.js', 'standard,strict'], { + input: JSON.stringify({ cwd: root, session_id: 'redaction-fixture', ...input }), + cwd: root, encoding: 'utf8', timeout: 12000, + env: { ...process.env, HOME: root, USERPROFILE: root, + CLV2_HOMUNCULUS_DIR: data, CLAUDE_CODE_ENTRYPOINT: 'cli', + ECC_HOOK_PROFILE: 'standard', ECC_SKIP_OBSERVE: '0', + ECC_DISABLED_HOOKS: '', ECC_OBSERVER_ENABLED: 'false' } + }); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stderr, /runner failed|timed out/i); + const records = fs.readFileSync(path.join(data, 'observations.jsonl'), 'utf8'); + assert.equal(records.trim().split('\n').length, 1); + assert.equal(JSON.parse(records).event, phase === 'pre' ? 'tool_start' : 'tool_complete'); + return records; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +console.log('=== Actual registered observation secret redaction ==='); +if (process.platform !== 'win32' && spawnSync('python3', ['--version']).status === 0) { + for (const [label, content] of [ + ['double-quoted source', 'const apiKey = "CANARY_TOKEN_abcdefghijk";'], + ['single-quoted source', "const password = 'CANARY_TOKEN_abcdefghijk';"], + ['JSON credential', '{"token":"CANARY_TOKEN_abcdefghijk"}'], + ['nested escaped source', 'const auth = \\"CANARY_TOKEN_abcdefghijk\\";'] + ]) { + test(`redacts ${label} before persisting tool input`, () => { + const records = capture('pre', { tool_name: 'Write', + tool_input: { file_path: '/tmp/example.js', content } }); + assert.ok(!records.includes('CANARY_TOKEN_abcdefghijk'), 'seeded credential persisted'); + assert.ok(records.includes('[REDACTED]')); + }); + test(`redacts ${label} before persisting structured tool output`, () => { + const records = capture('post', { tool_name: 'Read', + tool_input: { file_path: '/tmp/example.js' }, tool_response: { content } }); + assert.ok(!records.includes('CANARY_TOKEN_abcdefghijk'), 'seeded credential persisted'); + assert.ok(records.includes('[REDACTED]')); + }); + } +} else { + console.log('Skipping shell fixtures: native Windows runner requires platform CI.'); +} +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exitCode = failed ? 1 : 0;