mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-07 22:18:33 +00:00
426 lines
16 KiB
JavaScript
426 lines
16 KiB
JavaScript
/**
|
|
* Shared context formatting + redaction primitives used by every AI surface
|
|
* (chat sidebar, script generation, autocomplete).
|
|
*
|
|
* Everything that goes to a provider passes through this module so the rules
|
|
* (which header names are sensitive, how response bodies are stripped, how a
|
|
* variable marked `secret: true` appears) stay consistent across surfaces.
|
|
*/
|
|
|
|
// --- Sensitive header / param / variable names ---------------------------
|
|
|
|
const SENSITIVE_HEADER_PATTERNS = [
|
|
/^authorization$/i,
|
|
/^proxy-authorization$/i,
|
|
/^cookie$/i,
|
|
/^set-cookie$/i,
|
|
/^x-api-key$/i,
|
|
/^x-auth-token$/i,
|
|
/^x-access-token$/i,
|
|
/^x-csrf-token$/i,
|
|
/api[_-]?key/i,
|
|
// Catches refresh_token, id_token, csrfToken, plain TOKEN, etc. on top of
|
|
// the specific access/auth-token forms above.
|
|
/token/i,
|
|
/access[_-]?token/i,
|
|
/auth[_-]?token/i,
|
|
/secret/i,
|
|
/password/i
|
|
];
|
|
|
|
const REDACTED_VALUE = '<redacted>';
|
|
|
|
const isSensitiveName = (name) => {
|
|
if (!name) return false;
|
|
return SENSITIVE_HEADER_PATTERNS.some((re) => re.test(name));
|
|
};
|
|
|
|
const maskValue = (name, value) => (isSensitiveName(name) ? REDACTED_VALUE : value);
|
|
|
|
const normalizeList = (list) =>
|
|
Array.isArray(list)
|
|
? list.map((s) => String(s || '').trim().toLowerCase()).filter(Boolean)
|
|
: [];
|
|
|
|
const buildRedactionPolicy = (security) => {
|
|
const redactHeaders = security?.redactHeaders !== false;
|
|
const redactBody = security?.redactBody !== false;
|
|
const redactVariables = security?.redactVariables !== false;
|
|
const redactResponse = security?.redactResponse !== false;
|
|
const customHeaderSet = new Set(normalizeList(security?.customRedactedHeaders));
|
|
const customVarSet = new Set(normalizeList(security?.customRedactedVariables));
|
|
|
|
return {
|
|
redactHeaders,
|
|
redactBody,
|
|
redactVariables,
|
|
redactResponse,
|
|
isSensitiveHeader: (name) => {
|
|
if (!name) return false;
|
|
if (customHeaderSet.has(String(name).toLowerCase())) return true;
|
|
return isSensitiveName(name);
|
|
},
|
|
// Body checks reuse the header list — custom entries are matched exactly,
|
|
// so a user who adds `password` here also catches JSON keys named the same.
|
|
isSensitiveKey: (name) => {
|
|
if (!name) return false;
|
|
if (customHeaderSet.has(String(name).toLowerCase())) return true;
|
|
return isSensitiveName(name);
|
|
},
|
|
// Explicit custom entries always redact — the toggle only gates
|
|
// pattern-based matches, since the user opted into the specific names.
|
|
isCustomVariable: (name) => Boolean(name) && customVarSet.has(String(name).toLowerCase()),
|
|
matchesVariablePattern: (name) => Boolean(name) && isSensitiveName(name)
|
|
};
|
|
};
|
|
|
|
const DEFAULT_POLICY = buildRedactionPolicy(null);
|
|
|
|
// --- Response body shape redaction ---------------------------------------
|
|
|
|
const REDACTED_TRUNCATED = '<truncated>';
|
|
const REDACTED_NULL = '<null>';
|
|
const REDACTED_BY_TYPE = {
|
|
string: '<string>',
|
|
number: '<number>',
|
|
boolean: '<boolean>',
|
|
bigint: '<bigint>'
|
|
};
|
|
|
|
const REDACTION_NOTICE = 'Values are placeholders (`<string>`, `<number>`, …). The shape, keys, and types are accurate but no real data is shown. Reference fields by path in generated code — do not hard-code these placeholders as literal values.';
|
|
|
|
const redactResponseValues = (data, depth = 0, maxDepth = 6) => {
|
|
if (data === null || data === undefined) return REDACTED_NULL;
|
|
if (depth >= maxDepth) return REDACTED_TRUNCATED;
|
|
|
|
if (Array.isArray(data)) {
|
|
if (data.length === 0) return [];
|
|
// Cap sample size — long arrays only need a few items to convey shape.
|
|
const sampleSize = Math.min(data.length, 3);
|
|
const out = data.slice(0, sampleSize).map((item) => redactResponseValues(item, depth + 1, maxDepth));
|
|
if (data.length > sampleSize) out.push(`<${data.length - sampleSize} more items>`);
|
|
return out;
|
|
}
|
|
|
|
if (typeof data === 'object') {
|
|
const keys = Object.keys(data);
|
|
const out = {};
|
|
for (const key of keys.slice(0, 30)) {
|
|
out[key] = redactResponseValues(data[key], depth + 1, maxDepth);
|
|
}
|
|
if (keys.length > 30) out['...'] = `<${keys.length - 30} more keys>`;
|
|
return out;
|
|
}
|
|
|
|
return REDACTED_BY_TYPE[typeof data] || '<unknown>';
|
|
};
|
|
|
|
const RESPONSE_RAW_BODY_MAX_CHARS = 8000;
|
|
|
|
const formatResponseShape = (status, data, opts = {}) => {
|
|
if (!status && data == null) return '';
|
|
const policy = buildRedactionPolicy(opts.security);
|
|
const parts = [];
|
|
if (status) parts.push(`**Last Response Status:** ${status}`);
|
|
|
|
if (data != null) {
|
|
let parsed = data;
|
|
let parsedOk = false;
|
|
if (typeof data === 'string') {
|
|
try {
|
|
parsed = JSON.parse(data); parsedOk = true;
|
|
} catch { parsedOk = false; }
|
|
} else if (typeof data === 'object') {
|
|
parsedOk = true;
|
|
}
|
|
|
|
if (parsedOk) {
|
|
if (policy.redactResponse) {
|
|
const redacted = redactResponseValues(parsed);
|
|
if (redacted != null) {
|
|
parts.push(`**Response Shape (values redacted — ${REDACTION_NOTICE}):**\n\`\`\`json\n${JSON.stringify(redacted, null, 2)}\n\`\`\``);
|
|
}
|
|
} else {
|
|
// User opted out of response-value redaction — send the real body,
|
|
// capped so a huge response doesn't blow the model's context window.
|
|
const raw = JSON.stringify(parsed, null, 2);
|
|
const truncated = raw.length > RESPONSE_RAW_BODY_MAX_CHARS;
|
|
const shown = truncated ? `${raw.slice(0, RESPONSE_RAW_BODY_MAX_CHARS)}…` : raw;
|
|
parts.push(`**Response Body:**\n\`\`\`json\n${shown}\n\`\`\`${truncated ? '\n\n(truncated)' : ''}`);
|
|
}
|
|
} else if (typeof data === 'string' && data.trim()) {
|
|
if (policy.redactResponse) {
|
|
parts.push(`**Response:** non-JSON, ${data.length} chars (call read_response() for the redacted view)`);
|
|
} else {
|
|
const truncated = data.length > RESPONSE_RAW_BODY_MAX_CHARS;
|
|
const shown = truncated ? `${data.slice(0, RESPONSE_RAW_BODY_MAX_CHARS)}…` : data;
|
|
parts.push(`**Response Body:**\n\`\`\`\n${shown}\n\`\`\`${truncated ? '\n\n(truncated)' : ''}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return parts.join('\n\n');
|
|
};
|
|
|
|
/**
|
|
* Walk a JSON-shaped value and replace primitive values whose KEY is sensitive
|
|
* (`password`, `*_token`, `secret`, etc.) with `<redacted>`. Keeps the shape
|
|
* intact so the model can still see the body's structure and field names.
|
|
*
|
|
* Differs from `redactResponseValues` (which replaces ALL primitives with
|
|
* type placeholders): we DO want the model to see non-sensitive request body
|
|
* values so it can write code that references them correctly.
|
|
*/
|
|
const redactJsonBodyValues = (data, policy, depth = 0, maxDepth = 8) => {
|
|
if (data === null || data === undefined) return data;
|
|
if (depth >= maxDepth) return REDACTED_TRUNCATED;
|
|
if (Array.isArray(data)) return data.map((item) => redactJsonBodyValues(item, policy, depth + 1, maxDepth));
|
|
if (typeof data === 'object') {
|
|
const out = {};
|
|
for (const key of Object.keys(data)) {
|
|
if (policy.isSensitiveKey(key)) {
|
|
out[key] = REDACTED_VALUE;
|
|
} else {
|
|
out[key] = redactJsonBodyValues(data[key], policy, depth + 1, maxDepth);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
return data;
|
|
};
|
|
|
|
const redactJsonBodyString = (raw, policy = DEFAULT_POLICY) => {
|
|
if (typeof raw !== 'string' || !raw.trim()) return raw || '';
|
|
if (!policy.redactBody) return raw;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return JSON.stringify(redactJsonBodyValues(parsed, policy), null, 2);
|
|
} catch {
|
|
// Not parseable JSON — return as-is. The renderer-side patterns + variable
|
|
// redaction are the main line of defense for arbitrary text bodies.
|
|
return raw;
|
|
}
|
|
};
|
|
|
|
// --- Request context (method/url/headers/params/body/+response) ---------
|
|
|
|
/**
|
|
* Format the renderer-supplied requestContext as Markdown for the model.
|
|
*
|
|
* @param {object} ctx { url, method, headers, params, body, docs,
|
|
* responseStatus?, responseData? }
|
|
* @param {object} opts
|
|
* includeBody - include body (default true)
|
|
* bodyMaxChars - truncate body to this many chars (default null = full)
|
|
* includeResponse - inline the redacted response shape (default false)
|
|
* includeDocs - include the request's docs field (default true)
|
|
* security - user-configured redaction toggles (see
|
|
* buildRedactionPolicy). Omit for strict defaults.
|
|
*/
|
|
const formatRequestContext = (ctx, opts = {}) => {
|
|
const {
|
|
includeBody = true,
|
|
bodyMaxChars = null,
|
|
includeResponse = false,
|
|
includeDocs = true,
|
|
security = null
|
|
} = opts;
|
|
if (!ctx) return '';
|
|
const policy = buildRedactionPolicy(security);
|
|
const maskHeader = (name, value) => (policy.redactHeaders && policy.isSensitiveHeader(name) ? REDACTED_VALUE : (value ?? ''));
|
|
const maskFormField = (name, value) => (policy.redactBody && policy.isSensitiveKey(name) ? REDACTED_VALUE : (value ?? ''));
|
|
const parts = [];
|
|
|
|
if (ctx.url || ctx.method) {
|
|
parts.push(`**Request:** ${ctx.method || 'GET'} ${ctx.url || ''}`);
|
|
}
|
|
|
|
const headers = (ctx.headers || []).filter((h) => h?.enabled && h?.name);
|
|
if (headers.length) {
|
|
parts.push(`**Headers:**\n${headers.map((h) => ` ${h.name}: ${maskHeader(h.name, h.value)}`).join('\n')}`);
|
|
}
|
|
|
|
const params = (ctx.params || []).filter((p) => p?.enabled && p?.name);
|
|
const query = params.filter((p) => p.type === 'query' || !p.type);
|
|
const pathParams = params.filter((p) => p.type === 'path');
|
|
if (query.length) {
|
|
parts.push(`**Query Parameters:**\n${query.map((p) => ` ${p.name}: ${maskHeader(p.name, p.value)}`).join('\n')}`);
|
|
}
|
|
if (pathParams.length) {
|
|
parts.push(`**Path Parameters:**\n${pathParams.map((p) => ` ${p.name}: ${maskHeader(p.name, p.value)}`).join('\n')}`);
|
|
}
|
|
|
|
const body = ctx.body;
|
|
if (includeBody && body && body.mode && body.mode !== 'none') {
|
|
let content = '';
|
|
switch (body.mode) {
|
|
// JSON bodies often contain fields like `password`, `client_secret`,
|
|
// `refresh_token`. Redact by key so the model sees the structure but
|
|
// not the secret values.
|
|
case 'json': content = redactJsonBodyString(body.json || '', policy); break;
|
|
case 'text': content = body.text || ''; break;
|
|
case 'xml': content = body.xml || ''; break;
|
|
case 'sparql': content = body.sparql || ''; break;
|
|
case 'formUrlEncoded': {
|
|
const items = (body.formUrlEncoded || []).filter((p) => p.enabled);
|
|
content = items.map((p) => ` ${p.name}: ${maskFormField(p.name, p.value)}`).join('\n');
|
|
break;
|
|
}
|
|
case 'multipartForm': {
|
|
const items = (body.multipartForm || []).filter((p) => p.enabled);
|
|
content = items.map((p) => ` ${p.name}: ${p.type === 'file' ? '[file]' : maskFormField(p.name, p.value)}`).join('\n');
|
|
break;
|
|
}
|
|
case 'graphql':
|
|
content = body.graphql?.query || '';
|
|
if (body.graphql?.variables) {
|
|
// GraphQL variables are stored as a JSON string in Bruno — same
|
|
// key-based redaction applies.
|
|
content += `\n\nVariables:\n${redactJsonBodyString(body.graphql.variables, policy)}`;
|
|
}
|
|
break;
|
|
default: content = '';
|
|
}
|
|
if (content) {
|
|
const truncate = bodyMaxChars && content.length > bodyMaxChars;
|
|
const shown = truncate ? content.slice(0, bodyMaxChars) + '…' : content;
|
|
parts.push(`**Body (${body.mode}):**\n\`\`\`\n${shown}\n\`\`\``);
|
|
}
|
|
}
|
|
|
|
if (includeResponse) {
|
|
const responseStr = formatResponseShape(ctx.responseStatus, ctx.responseData, { security });
|
|
if (responseStr) parts.push(responseStr);
|
|
}
|
|
|
|
if (includeDocs && ctx.docs && typeof ctx.docs === 'string' && ctx.docs.trim()) {
|
|
parts.push(`**Documentation:**\n${ctx.docs.trim()}`);
|
|
}
|
|
|
|
return parts.join('\n\n');
|
|
};
|
|
|
|
// --- Variables (env / global / collection / folder / request / runtime) -
|
|
|
|
/**
|
|
* Variable record shape (what the renderer sends over IPC):
|
|
* { name: string, value: string | null, scope: string, secret: boolean }
|
|
*
|
|
* - `scope` is informational ('env', 'global', 'collection', 'folder',
|
|
* 'request', 'runtime', 'process', 'oauth2', ...). Used only for the
|
|
* short list shown to the model.
|
|
* - `secret: true` means the value is omitted from the renderer's payload
|
|
* too — never trust the value field on a secret. The backend re-masks.
|
|
*/
|
|
|
|
const isSecretVariable = (v, policy = DEFAULT_POLICY) => {
|
|
if (!v) return false;
|
|
// `secret: true` is a hard promise from the renderer's variable
|
|
// pipeline - always honor it, even when the pattern-match toggle is off.
|
|
if (v.secret) return true;
|
|
// Custom-listed names redact unconditionally, the user opted in explicitly.
|
|
if (policy.isCustomVariable(v.name)) return true;
|
|
// Built-in pattern matches only apply when the toggle is on.
|
|
if (!policy.redactVariables) return false;
|
|
return policy.matchesVariablePattern(v.name);
|
|
};
|
|
|
|
const variableValueForModel = (v, policy) => {
|
|
if (!v) return '';
|
|
if (isSecretVariable(v, policy)) return REDACTED_VALUE;
|
|
if (v.value == null) return '';
|
|
return String(v.value);
|
|
};
|
|
|
|
const VAR_NAMES_PREVIEW_PER_SCOPE = 25;
|
|
|
|
/**
|
|
* Inline preview shown in the prompt. Names + a small per-scope sample so
|
|
* the model knows what's available without us dumping 500 env vars.
|
|
*/
|
|
const formatVariablesList = (variables, opts = {}) => {
|
|
if (!Array.isArray(variables) || !variables.length) return '';
|
|
const policy = buildRedactionPolicy(opts.security);
|
|
|
|
const byScope = new Map();
|
|
for (const v of variables) {
|
|
if (!v || !v.name) continue;
|
|
const scope = v.scope || 'unknown';
|
|
if (!byScope.has(scope)) byScope.set(scope, []);
|
|
byScope.get(scope).push(v);
|
|
}
|
|
|
|
const lines = [];
|
|
for (const [scope, list] of byScope.entries()) {
|
|
const total = list.length;
|
|
const preview = list.slice(0, VAR_NAMES_PREVIEW_PER_SCOPE);
|
|
const more = total > preview.length ? ` (+${total - preview.length} more — use search_variables to find them)` : '';
|
|
const names = preview
|
|
.map((v) => (isSecretVariable(v, policy) ? `${v.name} (secret)` : v.name))
|
|
.join(', ');
|
|
lines.push(`- ${scope} (${total}): ${names}${more}`);
|
|
}
|
|
return lines.join('\n');
|
|
};
|
|
|
|
const SEARCH_LIMIT = 50;
|
|
|
|
/**
|
|
* Returns `{ items, totalMatched, limit }`. `totalMatched` is the number of
|
|
* variables that matched the query BEFORE truncation, so the caller can tell
|
|
* the model when there are more matches than were returned.
|
|
*/
|
|
const searchVariables = (variables, rawQuery, limit = SEARCH_LIMIT) => {
|
|
if (!Array.isArray(variables) || !variables.length) {
|
|
return { items: [], totalMatched: 0, limit };
|
|
}
|
|
const query = String(rawQuery || '').toLowerCase().trim();
|
|
const filtered = query
|
|
? variables.filter((v) => v?.name && v.name.toLowerCase().includes(query))
|
|
: variables.slice();
|
|
return { items: filtered.slice(0, limit), totalMatched: filtered.length, limit };
|
|
};
|
|
|
|
const formatSearchVariablesResult = ({ items, totalMatched, limit }, query, opts = {}) => {
|
|
if (!items.length) {
|
|
return query
|
|
? `No variables match "${query}".`
|
|
: 'No variables defined for this collection/environment.';
|
|
}
|
|
const policy = buildRedactionPolicy(opts.security);
|
|
const lines = items.map((v) => {
|
|
const value = variableValueForModel(v, policy);
|
|
const tags = [v.scope || 'unknown'];
|
|
if (isSecretVariable(v, policy)) tags.push('secret');
|
|
return ` ${v.name} = ${value} [${tags.join(', ')}]`;
|
|
});
|
|
const heading = query
|
|
? `Found ${items.length}${totalMatched > items.length ? ` of ${totalMatched}` : ''} variable(s) matching "${query}":`
|
|
: `Variables (${items.length}${totalMatched > items.length ? ` of ${totalMatched}` : ''}):`;
|
|
const trailer = totalMatched > items.length
|
|
? `\n\n(${totalMatched - items.length} more match — narrow the query to see them.)`
|
|
: '';
|
|
return `${heading}\n${lines.join('\n')}${trailer}`;
|
|
};
|
|
|
|
module.exports = {
|
|
// patterns + helpers
|
|
SENSITIVE_HEADER_PATTERNS,
|
|
REDACTED_VALUE,
|
|
REDACTION_NOTICE,
|
|
isSensitiveName,
|
|
maskValue,
|
|
buildRedactionPolicy,
|
|
// response shape
|
|
redactResponseValues,
|
|
formatResponseShape,
|
|
// request context
|
|
formatRequestContext,
|
|
redactJsonBodyString,
|
|
// variables
|
|
isSecretVariable,
|
|
formatVariablesList,
|
|
searchVariables,
|
|
formatSearchVariablesResult
|
|
};
|