open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
276 lines (259 loc) • 10.6 kB
text/typescript
/**
* Redaction: secrets must never land in memory files or the index in
* readable form.
*
* Layers, in order:
* 1. <private>...</private> regions are stripped first (explicit opt-out —
* the author marked this span as sensitive, so it is replaced with
* [REDACTED] before any detection runs).
* 2. Built-in provider patterns (always on, reported by id).
* 3. User patterns from config redactPatterns (reported verbatim).
* 4. High-entropy assignment heuristic (catches secrets whose provider we
* don't have a pattern for, e.g. `deploy_key = "aB3d..."`).
*
* A detected secret does NOT refuse the write: the matched string is masked
* in place — first 4 characters kept, the rest replaced with 'x' — and the
* write proceeds with the masked content. hadSecret reports that masking
* happened so callers can surface a notice. A partially-masked memory still
* identifies which credential it referred to without storing the secret.
*/
export interface SecretPattern {
/** Stable id reported in matchedPattern. */
id: string;
/** Regex source (no slashes). */
source: string;
/** Optional RegExp flags, e.g. "i". */
flags?: string;
/**
* Capture-group index holding the secret value. When set, masking replaces
* only that group — the credential name stays readable (D14: a masked
* memory should still identify which key it referred to). Default: mask
* the whole match.
*/
valueGroup?: number;
}
/**
* Prefixes like `sk-` also occur inside ordinary English words ("task-…",
* "risk-…", "disk-…"), which caused confirmed false positives. Require the
* token prefix NOT to be preceded by a word/hyphen char, so a real key after
* "=", ":", space, or a quote still matches.
*/
const TOKEN_BOUNDARY = "(?<![A-Za-z0-9_-])";
export const BUILTIN_SECRET_PATTERNS: SecretPattern[] = [
{ id: "openai-key", source: `${TOKEN_BOUNDARY}sk-[A-Za-z0-9_-]{20,}` },
{
id: "openai-admin-key",
source: `${TOKEN_BOUNDARY}sk-admin-[A-Za-z0-9_-]{20,}`,
},
{
id: "openai-session-key",
source: `${TOKEN_BOUNDARY}sm_[A-Za-z0-9_-]{20,}`,
},
{ id: "github-pat", source: `${TOKEN_BOUNDARY}ghp_[A-Za-z0-9]{30,}` },
{ id: "github-oauth-token", source: `${TOKEN_BOUNDARY}gho_[A-Za-z0-9]{30,}` },
{ id: "github-user-token", source: `${TOKEN_BOUNDARY}ghu_[A-Za-z0-9]{30,}` },
{
id: "github-refresh-token",
source: `${TOKEN_BOUNDARY}ghr_[A-Za-z0-9]{30,}`,
},
{
id: "github-fine-grained-pat",
source: `${TOKEN_BOUNDARY}github_pat_[A-Za-z0-9_]{40,}`,
},
{ id: "aws-access-key-id", source: `${TOKEN_BOUNDARY}AKIA[0-9A-Z]{16}` },
{
id: "aws-secret-access-key",
source:
"(aws[_-]?secret[_-]?access[_-]?key)([\"']?\\s*[:=]\\s*[\"']?)([A-Za-z0-9/+=]{40})",
flags: "i",
valueGroup: 3,
},
{ id: "slack-token", source: `${TOKEN_BOUNDARY}xox[baprs]-[A-Za-z0-9-]{10,}` },
{ id: "google-api-key", source: `${TOKEN_BOUNDARY}AIza[0-9A-Za-z_-]{30,}` },
{ id: "npm-token", source: `${TOKEN_BOUNDARY}npm_[A-Za-z0-9]{30,}` },
{ id: "gitlab-pat", source: `${TOKEN_BOUNDARY}glpat-[A-Za-z0-9_-]{20,}` },
{
id: "stripe-restricted-key",
source: `${TOKEN_BOUNDARY}rk_(live|test)_[A-Za-z0-9]{20,}`,
},
{
id: "stripe-webhook-secret",
source: `${TOKEN_BOUNDARY}whsec_[A-Za-z0-9]{20,}`,
},
{
id: "private-key-block",
// Whole block: masking only the BEGIN header would leave the base64 body
// readable in the memory file. Non-greedy so two blocks mask separately.
source:
"-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----",
},
{
id: "private-key-truncated",
// No END marker: mask from the header to end of text. A header without a
// body is still key material; over-masking is the safe direction.
source: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*$",
},
{
id: "jwt",
source: `${TOKEN_BOUNDARY}eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}`,
},
{
id: "generic-secret-assignment",
source:
"(api[_-]?key|secret|passwd|password|auth[_-]?token|access[_-]?token)([\"']?\\s*[:=]\\s*[\"']?)([A-Za-z0-9_\\-./+=]{16,})([\"']?)",
flags: "i",
valueGroup: 3,
},
];
function compile(p: SecretPattern): RegExp | null {
try {
return new RegExp(p.source, p.flags ?? "");
} catch {
return null; // ignore malformed builtin (should never happen)
}
}
/** Returns the matched builtin pattern id, or null. */
export function findBuiltinSecret(text: string): string | null {
for (const p of BUILTIN_SECRET_PATTERNS) {
const re = compile(p);
if (re && re.test(text)) return p.id;
}
return null;
}
export function findSecret(text: string, patterns: string[]): string | null {
for (const p of patterns) {
try {
const re = new RegExp(p);
if (re.test(text)) return p;
} catch {
// ignore malformed regex
}
}
return null;
}
function shannonEntropy(s: string): number {
const freq = new Map<string, number>();
for (const c of s) freq.set(c, (freq.get(c) ?? 0) + 1);
let h = 0;
for (const n of freq.values()) {
const p = n / s.length;
h -= p * Math.log2(p);
}
return h;
}
// name = value assignments with a long token-ish value.
const ASSIGNMENT_RE =
/([A-Za-z_][A-Za-z0-9_]{1,63})\s*[:=]\s*["']?([A-Za-z0-9_\-./+=]{24,})["']?/g;
/**
* Heuristic last line of defense: a long, high-entropy value assigned to a
* name is almost certainly a credential, even when no provider pattern
* matches. Tuned conservatively (length >= 24, entropy >= 4.5 bits/char):
* hex digests (<= 4.0) and prose (~4.0) pass through; base64-ish randomness
* does not. URLs are skipped.
*/
export function findHighEntropySecret(text: string): string | null {
for (const m of text.matchAll(ASSIGNMENT_RE)) {
const name = m[1];
const value = m[2];
if (!isSuspectAssignmentValue(value, m[0], m.index ?? 0, text)) continue;
return `high-entropy-secret:${name}`;
}
return null;
}
/**
* Shared benign-value rule for detection AND masking: a URL (e.g. the value
* after "https:") or a low-entropy value must never be touched. Note the
* "://" check: ASSIGNMENT_RE consumes the colon of "https:" as the separator,
* so the captured value starts with "//..." — check for "://" in the
* original text around the match, not just inside the value.
*/
function isSuspectAssignmentValue(value: string, fullMatch: string, offset: number, text: string): boolean {
// Bare URL: ASSIGNMENT_RE consumed the scheme colon ("https:") as the
// separator, so the match itself starts with "scheme://".
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(fullMatch)) return false;
if (value.includes("://")) return false;
// Reconstruct what preceded the value inside the match (name + separator);
// if the text right before the value looks like scheme://, it is a URL.
const before = text.slice(Math.max(0, offset - 12), offset);
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:(\/\/)?$/.test(before.trim()) || before.includes("://")) return false;
return shannonEntropy(value) >= 4.5;
}
export function stripPrivate(text: string): string {
// Closed pairs first...
let out = text.replace(/<private>[\s\S]*?<\/private>/gi, "[REDACTED]");
// ...then an unclosed <private> redacts everything after it. A dangling
// tag almost always means the author intended the rest to be private.
out = out.replace(/<private>[\s\S]*$/gi, "[REDACTED]");
return out;
}
/**
* Mask a matched secret: keep the first 4 characters, replace the rest
* with 'x' (length-preserving). "sk-1234567890abcdefghij" → "sk-1xxxxxxxxxxxxx".
*/
function maskMatch(m: string): string {
return m.slice(0, 4) + "x".repeat(Math.max(0, m.length - 4));
}
/** Apply the builtin patterns as masking (global, all matches). */
function maskBuiltin(content: string): string {
let out = content;
for (const p of BUILTIN_SECRET_PATTERNS) {
const re = compile(p);
if (!re) continue;
const g = new RegExp(re.source, re.flags + "g");
if (p.valueGroup == null) {
out = out.replace(g, (m) => maskMatch(m));
} else {
// Mask only the secret-value group; the credential name stays readable.
out = out.replace(g, (...args: unknown[]) => {
const m = args[0] as string;
const val = args[p.valueGroup as number] as string | undefined;
if (!val) return m;
const idx = m.lastIndexOf(val);
if (idx < 0) return m;
return m.slice(0, idx) + maskMatch(val) + m.slice(idx + val.length);
});
}
}
return out;
}
/** Apply user patterns as masking. Invalid regexes are ignored. */
function maskUser(content: string, patterns: string[]): string {
let out = content;
for (const p of patterns) {
try {
out = out.replace(new RegExp(p, "g"), (m) => maskMatch(m));
} catch {
// ignore malformed regex
}
}
return out;
}
/** Mask the value side of high-entropy assignments (name stays readable).
* Uses the exact same benign-value rule as detection, so URLs and
* low-entropy values are never touched even when another secret triggers. */
function maskEntropy(content: string): string {
return content.replace(
ASSIGNMENT_RE,
(m, name: string, value: string, offset: number) => {
if (!isSuspectAssignmentValue(value, m, offset, content)) return m;
return m.split(value).join(maskMatch(value));
},
);
}
export function redact(
text: string,
patterns: string[],
): { content: string; hadSecret: boolean; matchedPattern: string | null } {
const stripped = stripPrivate(text);
// Detection (for reporting) runs in priority order: builtin → user → entropy.
const builtin = findBuiltinSecret(stripped);
const user = builtin ? null : findSecret(stripped, patterns);
const entropic =
builtin || user ? null : findHighEntropySecret(stripped);
const matched = builtin ?? user ?? entropic;
if (!matched) return { content: stripped, hadSecret: false, matchedPattern: null };
// Masking runs every family over the text (not just the reported one) so
// multiple credentials in one memory are all masked.
const masked = maskEntropy(maskUser(maskBuiltin(stripped), patterns));
return { content: masked, hadSecret: true, matchedPattern: matched };
}