Skip to content

Commit 5559919

Browse files
author
zho
committed
test: credential redaction verification and metadata filter fuzz hardening
1 parent 687a9a6 commit 5559919

10 files changed

Lines changed: 559 additions & 37 deletions

docs/SECURITY.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,18 @@
1010
`src/logger.ts` implements `redactApiKey` and recursive redaction for structured log data:
1111

1212
- UUID-shaped tokens (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) → `***`
13+
- Modern Pinecone keys (`pcsk_…`) → `***`
1314
- Substrings after `apiKey` / `api_key` / similar patterns → masked
1415
- `Authorization: Bearer …` tokens → masked
1516

1617
Logs go to **stderr**; use `PINECONE_READ_ONLY_MCP_LOG_FORMAT=json` for pipelines and ensure downstream sinks treat stderr as sensitive.
1718

19+
## MCP response redaction
20+
21+
Tool responses returned to MCP clients (and LLM consumers) are sanitized in `src/core/server/tool-response.ts` via `redactSensitiveFields()` before JSON serialization. Only known sensitive keys are masked (`message`, `suggestion`, `degradation_reason`); document metadata UUIDs and other non-sensitive fields are preserved.
22+
23+
This covers tool error payloads, hybrid degradation reasons, and SDK error text surfaced in DEBUG log mode — not only stderr logs.
24+
1825
## Docker image
1926

2027
The multi-stage [`Dockerfile`](../Dockerfile):

package-lock.json

Lines changed: 54 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"@vitest/coverage-v8": "^4.0.18",
8787
"eslint": "^10.0.2",
8888
"eslint-config-prettier": "^10.1.8",
89+
"fast-check": "^4.8.0",
8990
"prettier": "^3.5.3",
9091
"tsx": "^4.19.2",
9192
"typescript": "^6.0.3",

src/core/pinecone/rerank.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import type { Pinecone } from '@pinecone-database/pinecone';
6-
import { error as logError } from '../../logger.js';
6+
import { error as logError, redactApiKey } from '../../logger.js';
77
import type { MergedHit, SearchResult } from '../../types.js';
88

99
export type RerankOutcome = {
@@ -59,7 +59,7 @@ export async function rerankResults(
5959
return { results: reranked, degraded: false };
6060
} catch (error) {
6161
logError('Error reranking results', error);
62-
const msg = error instanceof Error ? error.message : String(error);
62+
const msg = redactApiKey(error instanceof Error ? error.message : String(error));
6363
return {
6464
results: results.slice(0, topN).map((result) => ({
6565
id: result._id || '',
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import * as fc from 'fast-check';
2+
import { describe, expect, it } from 'vitest';
3+
import { MAX_FILTER_DEPTH, validateMetadataFilterDetailed } from './metadata-filter.js';
4+
5+
const primitiveArb = fc.oneof(fc.string(), fc.integer(), fc.boolean());
6+
7+
const validLeafArb: fc.Arbitrary<Record<string, unknown>> = fc
8+
.record({
9+
field: fc
10+
.string({ minLength: 1 })
11+
.filter((s) => !s.startsWith('$'))
12+
.map((name) => name || 'f'),
13+
op: fc.constantFrom('$eq', '$ne', '$gt', '$gte', '$lt', '$lte'),
14+
value: primitiveArb,
15+
})
16+
.map(({ field, op, value }) => ({ [field]: { [op]: value } }));
17+
18+
const { filter: validFilterArb } = fc.letrec<{ filter: fc.Arbitrary<Record<string, unknown>> }>(
19+
(tie) => ({
20+
filter: fc.oneof(
21+
validLeafArb,
22+
fc.array(tie('filter'), { minLength: 1, maxLength: 3 }).map((items) => ({ $and: items })),
23+
fc.array(tie('filter'), { minLength: 1, maxLength: 3 }).map((items) => ({ $or: items }))
24+
),
25+
})
26+
);
27+
28+
function deepAnd(levels: number): Record<string, unknown> {
29+
if (levels === 0) return { leaf: { $eq: 1 } };
30+
return { $and: [deepAnd(levels - 1)] };
31+
}
32+
33+
describe('validateMetadataFilterDetailed fuzz', () => {
34+
it('never throws on arbitrary JSON-like input', () => {
35+
fc.assert(
36+
fc.property(fc.jsonValue(), (value) => {
37+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
38+
expect(() =>
39+
validateMetadataFilterDetailed(value as Record<string, unknown>)
40+
).not.toThrow();
41+
}
42+
}),
43+
{ numRuns: 100 }
44+
);
45+
});
46+
47+
it('returns structured errors for known malformed filters', () => {
48+
const malformed = [
49+
{ year: { $regex: '^202' } },
50+
{ tags: { $in: [] } },
51+
{ tags: { $nin: [] } },
52+
{ $and: [] },
53+
{ $or: [] },
54+
{ tags: { $in: [1, {} as unknown as number] } },
55+
{ tags: { $and: 'not-array' } },
56+
{ x: deepAnd(MAX_FILTER_DEPTH + 1) },
57+
];
58+
59+
for (const filter of malformed) {
60+
const result = validateMetadataFilterDetailed(filter);
61+
expect(result).not.toBeNull();
62+
expect(result!.message.length).toBeGreaterThan(0);
63+
}
64+
});
65+
66+
it('accepts valid complex generated filters unchanged', () => {
67+
fc.assert(
68+
fc.property(validFilterArb, (filter) => {
69+
const result = validateMetadataFilterDetailed(filter);
70+
expect(result).toBeNull();
71+
}),
72+
{ numRuns: 100 }
73+
);
74+
});
75+
76+
it('accepts hand-crafted deeply nested valid filters within depth limit', () => {
77+
const valid = {
78+
$and: [
79+
{ status: { $eq: 'published' } },
80+
{
81+
$or: [
82+
{ year: { $gte: 2020 } },
83+
{
84+
tags: {
85+
$in: ['cpp', 'contracts'],
86+
},
87+
},
88+
],
89+
},
90+
],
91+
};
92+
expect(validateMetadataFilterDetailed(valid)).toBeNull();
93+
expect(validateMetadataFilterDetailed({ x: deepAnd(MAX_FILTER_DEPTH) })).toBeNull();
94+
});
95+
96+
it('handles special field names without throwing', () => {
97+
fc.assert(
98+
fc.property(fc.string({ minLength: 0, maxLength: 20 }), primitiveArb, (fieldName, value) => {
99+
const filter = { [fieldName]: { $eq: value } };
100+
expect(() => validateMetadataFilterDetailed(filter)).not.toThrow();
101+
}),
102+
{ numRuns: 100 }
103+
);
104+
});
105+
106+
it('rejects empty $in and accepts large primitive $in arrays', () => {
107+
expect(validateMetadataFilterDetailed({ tags: { $in: [] } })!.field).toBe('tags.$in');
108+
109+
const largeIn = {
110+
tags: { $in: Array.from({ length: 1000 }, (_, i) => (i % 3 === 0 ? i : `v${i}`)) },
111+
};
112+
expect(validateMetadataFilterDetailed(largeIn)).toBeNull();
113+
114+
expect(validateMetadataFilterDetailed({ tags: { $in: ['only'] } })).toBeNull();
115+
});
116+
});

0 commit comments

Comments
 (0)