Skip to content

Commit e147fed

Browse files
committed
shorten holdback flush to 15ms and cover remaining holdback paths
Fragments that split a secret arrive within the same tick or a few ms, so a 15ms flush valve covers them the same as 100ms while keeping the worst-case stall of a lookalike tail imperceptible. New tests: callback fires when a whole chunk is withheld, bare end() flushes withheld text, and a secret completed after the timed flush is still caught via carry.
1 parent 6e38bde commit e147fed

6 files changed

Lines changed: 181 additions & 6 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"specVersion": "1.0",
3+
"host": {
4+
"displayName": "Varlock",
5+
"identifier": "varlock.dev",
6+
"url": "https://varlock.dev"
7+
},
8+
"entries": [
9+
{
10+
"identifier": "urn:ai:varlock.dev:mcp:docs",
11+
"displayName": "Varlock docs MCP server",
12+
"description": "Search and read the varlock documentation over MCP (streamable HTTP at /mcp, SSE at /sse). No authentication required.",
13+
"type": "application/mcp-server+json",
14+
"url": "https://varlock.dev/.well-known/mcp/server-card.json",
15+
"endpoint": "https://docs.mcp.varlock.dev/mcp",
16+
"representativeQueries": [
17+
"How do I mark a variable as sensitive in .env.schema?",
18+
"How do I run a command with varlock run?",
19+
"How do I load secrets from 1Password with varlock?",
20+
"How do I set up varlock with Next.js?"
21+
]
22+
},
23+
{
24+
"identifier": "urn:ai:varlock.dev:docs:llms-txt",
25+
"displayName": "Varlock documentation index (llms.txt)",
26+
"description": "Entry point for agents: what varlock is, when to use it, install commands, and links to topic-specific documentation bundles.",
27+
"type": "text/markdown",
28+
"url": "https://varlock.dev/llms.txt",
29+
"representativeQueries": [
30+
"What is varlock and when should I use it?",
31+
"How do I install the varlock CLI?"
32+
]
33+
},
34+
{
35+
"identifier": "urn:ai:varlock.dev:docs:llms-full",
36+
"displayName": "Varlock full documentation (llms-full.txt)",
37+
"description": "Every varlock docs page concatenated into one markdown file.",
38+
"type": "text/markdown",
39+
"url": "https://varlock.dev/llms-full.txt",
40+
"representativeQueries": [
41+
"Reference for varlock .env.schema decorators",
42+
"Which secret providers does varlock support?"
43+
]
44+
},
45+
{
46+
"identifier": "urn:ai:varlock.dev:skills:index",
47+
"displayName": "Varlock agent skills",
48+
"description": "Agent Skills discovery index with SKILL.md files for using varlock, searching its docs, and verifying its discovery endpoints.",
49+
"type": "application/json",
50+
"url": "https://varlock.dev/.well-known/agent-skills/index.json",
51+
"representativeQueries": [
52+
"Install the varlock skill for my coding agent",
53+
"How should an agent edit .env.schema safely?"
54+
]
55+
},
56+
{
57+
"identifier": "urn:ai:varlock.dev:cli:varlock",
58+
"displayName": "varlock CLI",
59+
"description": "Command line tool for loading, validating, encrypting, and scanning .env files. Install with npx varlock init, brew install dmno-dev/tap/varlock, or the install.sh script.",
60+
"type": "text/html",
61+
"url": "https://varlock.dev/getting-started/installation/",
62+
"representativeQueries": [
63+
"Install varlock",
64+
"Validate my .env file from the command line",
65+
"Scan my repo for leaked secrets"
66+
]
67+
}
68+
]
69+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"$schema": "https://modelcontextprotocol.io/schemas/2025-03-26/server-card.json",
3+
"serverInfo": {
4+
"name": "Varlock docs MCP",
5+
"version": "1.0.0",
6+
"description": "Search and read the varlock documentation. No authentication required.",
7+
"documentationUrl": "https://varlock.dev/guides/mcp/docs-mcp/"
8+
},
9+
"transports": [
10+
{
11+
"type": "streamable-http",
12+
"endpoint": "https://docs.mcp.varlock.dev/mcp"
13+
},
14+
{
15+
"type": "sse",
16+
"endpoint": "https://docs.mcp.varlock.dev/sse"
17+
}
18+
],
19+
"capabilities": {
20+
"tools": {
21+
"listChanged": false
22+
},
23+
"resources": {
24+
"subscribe": false,
25+
"listChanged": false
26+
},
27+
"prompts": {
28+
"listChanged": false
29+
}
30+
}
31+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"servers": [
3+
{
4+
"$schema": "https://modelcontextprotocol.io/schemas/2025-03-26/server-card.json",
5+
"serverInfo": {
6+
"name": "Varlock docs MCP",
7+
"version": "1.0.0",
8+
"description": "Search and read the varlock documentation. No authentication required.",
9+
"documentationUrl": "https://varlock.dev/guides/mcp/docs-mcp/"
10+
},
11+
"transports": [
12+
{
13+
"type": "streamable-http",
14+
"endpoint": "https://docs.mcp.varlock.dev/mcp"
15+
},
16+
{
17+
"type": "sse",
18+
"endpoint": "https://docs.mcp.varlock.dev/sse"
19+
}
20+
],
21+
"capabilities": {
22+
"tools": {
23+
"listChanged": false
24+
},
25+
"resources": {
26+
"subscribe": false,
27+
"listChanged": false
28+
},
29+
"prompts": {
30+
"listChanged": false
31+
}
32+
}
33+
}
34+
]
35+
}

packages/varlock-website/src/content/docs/guides/secrets.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ This works by patching:
188188
- **Node.js `ServerResponse`**: intercepts `write()` and `end()` calls, scanning text and JSON response bodies (including gzip-compressed responses)
189189
- **Global `Response` constructor**: intercepts the `Response` class used in edge runtimes (e.g., Cloudflare Workers), scanning bodies passed to the constructor and `Response.json()`
190190

191-
Streamed responses are scanned across chunk boundaries, so a sensitive value that gets split between two writes is still detected. When the end of a chunk looks like the start of a sensitive value, that trailing text is held back for up to 100ms (or until the next chunk arrives) so the full value can be caught before any of it is sent.
191+
Streamed responses are scanned across chunk boundaries, so a sensitive value that gets split between two writes is still detected. When the end of a chunk looks like the start of a sensitive value, that trailing text is held back briefly (up to 15ms, or until the next chunk arrives) so the full value can be caught before any of it is sent.
192192

193193
Our [framework integrations](/integrations/overview/) automatically apply the appropriate patches for your environment. For example, a Next.js integration will scan both server-rendered pages and API route responses.
194194

packages/varlock/src/runtime/patch-server-response.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ import { debug } from './lib/debug';
1313
const patchedKey = '_patchedByVarlock';
1414

1515
/**
16-
* How long to hold back a possible partial secret before writing it out anyway. Chunks that
17-
* split a secret arrive back-to-back in practice, so this only fires when a response genuinely
18-
* pauses right after emitting something that looks like the start of a secret (e.g. SSE) -
19-
* without it, that trailing text would sit unsent until the next chunk or `end()`.
16+
* How long to hold back a possible partial secret before writing it out anyway. This is a
17+
* flush valve, not a detection window: chunks that split a secret arrive within the same tick
18+
* or a few ms of each other (renderer buffer splits, piped upstream fragments), so a short
19+
* timeout covers them while keeping the worst-case stall of a lookalike tail imperceptible.
20+
* It only fires when a response genuinely pauses right after emitting something that looks
21+
* like the start of a secret (e.g. an unframed SSE-style stream) - without it, that trailing
22+
* text would sit unsent until the next chunk or `end()`.
2023
*/
21-
const PENDING_FLUSH_TIMEOUT_MS = 100;
24+
const PENDING_FLUSH_TIMEOUT_MS = 15;
2225

2326
/**
2427
* Returns a sync decompressor for a Content-Encoding value, or undefined if unsupported.

packages/varlock/src/runtime/test/patch-server-response.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,33 @@ describe('patched ServerResponse - sensitive values split across chunks', () =>
223223
expect(() => res.write(`<html>${head}`)).not.toThrow();
224224
expect(() => res.end('-but-not-really</html>')).not.toThrow();
225225
});
226+
227+
it('still detects the secret when its head was already flushed by the holdback timer', async () => {
228+
const res = makeRes();
229+
expect(() => res.write(`<html>leaked: ${head}`)).not.toThrow();
230+
// wait past the holdback window so the timer flushes the withheld head, which
231+
// moves it into `carry` - the completing chunk must still be caught there
232+
await new Promise((resolve) => {
233+
setTimeout(resolve, 60);
234+
});
235+
expect(() => res.write(`${tail}</html>`)).toThrow(/DETECTED LEAKED SENSITIVE CONFIG/);
236+
});
237+
238+
it('reports success and fires the callback when a whole chunk is withheld', async () => {
239+
const res = makeRes();
240+
// the entire chunk is a possible secret prefix, so nothing goes out yet - but the
241+
// caller must still see a normal successful write or streams piping into the
242+
// response would stall waiting on the callback
243+
let cbFired = false;
244+
const returned = res.write(head, () => {
245+
cbFired = true;
246+
});
247+
expect(returned).toBe(true);
248+
await new Promise((resolve) => {
249+
process.nextTick(resolve);
250+
});
251+
expect(cbFired).toBe(true);
252+
});
226253
});
227254

228255
/*
@@ -253,6 +280,11 @@ describe('patched ServerResponse - pass-through integrity', () => {
253280
res.write(buf.subarray(0, mid));
254281
res.write(buf.subarray(mid));
255282
res.end();
283+
} else if (req.url === '/withheld-then-bare-end') {
284+
// tail is withheld as a lookalike, and end() carries no chunk of its own -
285+
// the withheld text must still be flushed as the final chunk
286+
res.write(`<html>${SECRET.slice(0, 12)}`);
287+
res.end();
256288
} else if (req.url === '/slow-lookalike') {
257289
// pauses right after a partial match, so the held-back text must still be flushed
258290
res.write(`<html>${SECRET.slice(0, 12)}`);
@@ -289,6 +321,11 @@ describe('patched ServerResponse - pass-through integrity', () => {
289321
expect(body).toBe(`<html>${multibyte}</html>`);
290322
});
291323

324+
it('delivers withheld text when end() carries no chunk', async () => {
325+
const body = await (await fetch(`${baseUrl}/withheld-then-bare-end`)).text();
326+
expect(body).toBe(`<html>${SECRET.slice(0, 12)}`);
327+
});
328+
292329
it('flushes withheld text without waiting for the response to end', async () => {
293330
const resp = await fetch(`${baseUrl}/slow-lookalike`);
294331
const reader = resp.body!.getReader();

0 commit comments

Comments
 (0)