* fix(#2997): mask SECRET_CONFIG_KEYS in SDK config-set/get and init responses The CJS→TS port at sdk/src/query/config-mutation.ts:240,243 and config-query.ts:122,128,132 dropped the masking layer that secrets.cjs spec defines for brave_search/firecrawl/exa_search. Result: the SDK echoed plaintext API keys into machine-readable JSON output (stdout, transcripts, CI logs). Adjacent leak in init.ts:673-675 / init.cjs:728-730: the init bundle passed config.brave_search through raw, leaking the API key whenever the user had stored one. Fix: - New sdk/src/query/secrets.ts ports SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret. Exact CJS parity (verified by 17 tests in secrets.test.ts that import secrets.cjs and compare). - config-set masks value + previousValue in response; on-disk plaintext intact (key stays usable). - config-get masks read response. --default flows through unmasked (user's own input, not stored secret). - init.ts/init.cjs mask string values only; booleans (availability flags) pass through unchanged so the typed contract is preserved. Tests: 17 in secrets.test.ts (including CJS parity), 5 in config-mutation.test.ts (#2997 block — covers on-disk-preserved, previousValue masking, short-value, unset, non-secret pass-through), 4 in config-query.test.ts. Closes #2997 * chore(#2997): add changeset fragment for PR #2999 * chore(#2997): add changeset fragment for PR #2999 * chore(#2999): drop direct CHANGELOG.md edit; release entry now lives in .changeset/ The changeset-fragment workflow (#2975) renders fragments into CHANGELOG.md at release time. Direct edits to [Unreleased] on each PR caused merge conflicts on every concurrent PR. This commit restores CHANGELOG.md to match origin/main; the release entry for this fix is preserved in the .changeset/*.md fragment(s) on this branch, which the release workflow consolidates.
67 lines
2.6 KiB
TypeScript
67 lines
2.6 KiB
TypeScript
import { describe, it } from 'vitest';
|
|
import assert from 'node:assert/strict';
|
|
import { SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret } from './secrets.js';
|
|
// Parity check against the CJS module.
|
|
import secretsCjs from '../../../get-shit-done/bin/lib/secrets.cjs';
|
|
|
|
describe('Bug #2997: SDK secrets module', () => {
|
|
it('SECRET_CONFIG_KEYS exposes the documented set (locked)', () => {
|
|
assert.deepEqual([...SECRET_CONFIG_KEYS].sort(), ['brave_search', 'exa_search', 'firecrawl']);
|
|
});
|
|
|
|
it('isSecretKey returns true for each registered secret', () => {
|
|
for (const k of SECRET_CONFIG_KEYS) {
|
|
assert.equal(isSecretKey(k), true, `${k} should be a secret`);
|
|
}
|
|
});
|
|
|
|
it('isSecretKey returns false for unrelated keys', () => {
|
|
for (const k of ['model_profile', 'commit_docs', 'workflow.plan_bounce', 'unknown_key']) {
|
|
assert.equal(isSecretKey(k), false);
|
|
}
|
|
});
|
|
|
|
it('maskSecret renders the convention ****<last-4> for ≥8-char strings', () => {
|
|
assert.equal(maskSecret('BSA-secret-key-abcd1234'), '****1234');
|
|
assert.equal(maskSecret('12345678'), '****5678');
|
|
});
|
|
|
|
it('maskSecret renders **** with no tail for <8-char strings', () => {
|
|
assert.equal(maskSecret('short'), '****');
|
|
assert.equal(maskSecret('abc'), '****');
|
|
assert.equal(maskSecret('1234567'), '****');
|
|
});
|
|
|
|
it('maskSecret renders (unset) for null/undefined/empty', () => {
|
|
assert.equal(maskSecret(null), '(unset)');
|
|
assert.equal(maskSecret(undefined), '(unset)');
|
|
assert.equal(maskSecret(''), '(unset)');
|
|
});
|
|
|
|
it('maskIfSecret passes non-secret values through unchanged', () => {
|
|
assert.equal(maskIfSecret('model_profile', 'quality'), 'quality');
|
|
assert.equal(maskIfSecret('commit_docs', true), true);
|
|
});
|
|
|
|
it('maskIfSecret masks secret values', () => {
|
|
assert.equal(maskIfSecret('brave_search', 'BSA-1234567890'), '****7890');
|
|
assert.equal(maskIfSecret('firecrawl', null), '(unset)');
|
|
});
|
|
|
|
// Parity with the CJS module — single source of truth via test enforcement,
|
|
// not import. Ensures SDK and CJS can never drift on the masking rule.
|
|
describe('CJS parity (#2997)', () => {
|
|
it('SECRET_CONFIG_KEYS matches the CJS set exactly', () => {
|
|
const cjsKeys = [...secretsCjs.SECRET_CONFIG_KEYS].sort();
|
|
const tsKeys = [...SECRET_CONFIG_KEYS].sort();
|
|
assert.deepEqual(tsKeys, cjsKeys);
|
|
});
|
|
|
|
for (const sample of ['', 'a', 'abc', '12345678', 'BSA-1234567890', null, undefined]) {
|
|
it(`maskSecret(${JSON.stringify(sample)}) matches CJS output`, () => {
|
|
assert.equal(maskSecret(sample), secretsCjs.maskSecret(sample));
|
|
});
|
|
}
|
|
});
|
|
});
|