auxilo-mcp 0.7.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/bin/auxilo-cli.js +447 -0
- package/lib/installer.js +647 -0
- package/lib/review.js +124 -0
- package/lib/sensitivity-filter.js +388 -0
- package/mcp-server.js +230 -64
- package/package.json +16 -4
- package/prompts/auxilo-learning-claude-code.md +250 -0
- package/prompts/auxilo-learning-generic.md +327 -0
- package/prompts/auxilo-learning-schema.json +98 -0
- package/scripts/hooks/auxilo-extract.sh +49 -0
- package/scripts/runner.js +843 -0
- package/scripts/sources/claude-code.js +182 -0
- package/scripts/sources/openclaw.js +171 -0
- package/scripts/sources/source.interface.js +85 -0
package/lib/review.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* lib/review.js — LW-15 `auxilo review` network layer
|
|
5
|
+
*
|
|
6
|
+
* Pure network functions for the contributor self-review queue. Mirrors the
|
|
7
|
+
* `recordConsent` shape in lib/installer.js: X-API-Key auth, injectable
|
|
8
|
+
* fetchImpl for hermetic tests, throws on non-2xx. No stdin, no home-directory
|
|
9
|
+
* access, no filesystem — the binding layer (bin/auxilo-cli.js) supplies the
|
|
10
|
+
* real home, fetch, and prompts.
|
|
11
|
+
*
|
|
12
|
+
* extract -> pending_review -> `auxilo review` (this) -> approve/reject
|
|
13
|
+
*
|
|
14
|
+
* That loop is what makes background extraction safe to re-enable: nothing the
|
|
15
|
+
* contributor extracted goes public until they personally approve it here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BASE_URL = 'https://api.auxilo.io';
|
|
19
|
+
|
|
20
|
+
function normBase(baseUrl) {
|
|
21
|
+
return (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* GET /account/pending — fetch the caller's own pending_review learnings.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} opts
|
|
28
|
+
* @param {string} opts.apiKey
|
|
29
|
+
* @param {string} [opts.baseUrl]
|
|
30
|
+
* @param {number} [opts.limit]
|
|
31
|
+
* @param {number} [opts.offset]
|
|
32
|
+
* @param {Function} [opts.fetchImpl]
|
|
33
|
+
* @returns {Promise<{account_id:string, pending_count:number, limit:number, offset:number, learnings:object[]}>}
|
|
34
|
+
*/
|
|
35
|
+
async function fetchPending(opts = {}) {
|
|
36
|
+
const { apiKey } = opts;
|
|
37
|
+
if (!apiKey) throw new Error('fetchPending: apiKey is required');
|
|
38
|
+
const baseUrl = normBase(opts.baseUrl);
|
|
39
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
40
|
+
|
|
41
|
+
const qs = new URLSearchParams();
|
|
42
|
+
if (opts.limit != null) qs.set('limit', String(opts.limit));
|
|
43
|
+
if (opts.offset != null) qs.set('offset', String(opts.offset));
|
|
44
|
+
const suffix = qs.toString() ? `?${qs}` : '';
|
|
45
|
+
|
|
46
|
+
const res = await fetchImpl(`${baseUrl}/account/pending${suffix}`, {
|
|
47
|
+
method: 'GET',
|
|
48
|
+
headers: { 'X-API-Key': apiKey },
|
|
49
|
+
});
|
|
50
|
+
let body = {};
|
|
51
|
+
try { body = await res.json(); } catch { /* non-JSON error body */ }
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
throw new Error(`Fetch pending failed (HTTP ${res.status}): ${body.error || 'unknown error'}`);
|
|
54
|
+
}
|
|
55
|
+
return body;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* POST /account/pending/:id/approve|reject — submit a self-review decision.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} opts
|
|
62
|
+
* @param {string} opts.apiKey
|
|
63
|
+
* @param {string} opts.id
|
|
64
|
+
* @param {'approve'|'reject'} opts.decision
|
|
65
|
+
* @param {string} [opts.reason] only sent on reject
|
|
66
|
+
* @param {string} [opts.baseUrl]
|
|
67
|
+
* @param {Function} [opts.fetchImpl]
|
|
68
|
+
* @returns {Promise<object>} server response body
|
|
69
|
+
*/
|
|
70
|
+
async function submitDecision(opts = {}) {
|
|
71
|
+
const { apiKey, id, decision, reason } = opts;
|
|
72
|
+
if (!apiKey) throw new Error('submitDecision: apiKey is required');
|
|
73
|
+
if (!id) throw new Error('submitDecision: id is required');
|
|
74
|
+
if (decision !== 'approve' && decision !== 'reject') {
|
|
75
|
+
throw new Error('submitDecision: decision must be "approve" or "reject"');
|
|
76
|
+
}
|
|
77
|
+
const baseUrl = normBase(opts.baseUrl);
|
|
78
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
79
|
+
|
|
80
|
+
const init = {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
|
83
|
+
};
|
|
84
|
+
// reject accepts an optional reason; approve takes no body.
|
|
85
|
+
if (decision === 'reject') {
|
|
86
|
+
init.body = JSON.stringify(reason ? { reason } : {});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const res = await fetchImpl(`${baseUrl}/account/pending/${encodeURIComponent(id)}/${decision}`, init);
|
|
90
|
+
let body = {};
|
|
91
|
+
try { body = await res.json(); } catch { /* non-JSON error body */ }
|
|
92
|
+
if (!res.ok) {
|
|
93
|
+
throw new Error(`${decision} failed for ${id} (HTTP ${res.status}): ${body.error || 'unknown error'}`);
|
|
94
|
+
}
|
|
95
|
+
return body;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Render a one-line safety-flag summary for a pending learning, or '' if clean.
|
|
100
|
+
* Shared by the CLI's interactive and --list views so the human always sees the
|
|
101
|
+
* injection / near-duplicate signals before deciding.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} l a projected pending learning
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
function formatFlags(l) {
|
|
107
|
+
const parts = [];
|
|
108
|
+
if (Array.isArray(l.injection_flags) && l.injection_flags.length > 0) {
|
|
109
|
+
const ids = l.injection_flags.map((f) => f.pattern_id || f.pattern || 'flag').join(', ');
|
|
110
|
+
parts.push(`injection: ${ids}`);
|
|
111
|
+
}
|
|
112
|
+
if (l.possible_duplicate_of) {
|
|
113
|
+
const sim = l.possible_duplicate_similarity != null ? ` (${l.possible_duplicate_similarity})` : '';
|
|
114
|
+
parts.push(`possible duplicate of ${l.possible_duplicate_of}${sim}`);
|
|
115
|
+
}
|
|
116
|
+
return parts.join(' · ');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
DEFAULT_BASE_URL,
|
|
121
|
+
fetchPending,
|
|
122
|
+
submitDecision,
|
|
123
|
+
formatFlags,
|
|
124
|
+
};
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/sensitivity-filter.js
|
|
3
|
+
*
|
|
4
|
+
* Sensitivity filter for agent learning submissions.
|
|
5
|
+
* Scans title, body, task_context, and tags for patterns that
|
|
6
|
+
* indicate private/secret data (keys, tokens, credentials, IPs).
|
|
7
|
+
*
|
|
8
|
+
* Two-mode operation:
|
|
9
|
+
* - Supervised mode: human reviews all learnings (filter is advisory)
|
|
10
|
+
* - Autonomous mode: filter gates submission — if triggered, learning
|
|
11
|
+
* is rejected with details so the agent can redact and resubmit
|
|
12
|
+
*
|
|
13
|
+
* Returns { clean: true } or { clean: false, matches: [...] }
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
// ─── Version ─────────────────────────────────────────────────────────────────
|
|
19
|
+
// Bumped when patterns are added or behavior changes. Server rejects extractions
|
|
20
|
+
// from clients older than N-1 (§7.6).
|
|
21
|
+
const SENSITIVITY_FILTER_VERSION = '0.4.0';
|
|
22
|
+
|
|
23
|
+
// ─── Patterns ────────────────────────────────────────────────────────────────
|
|
24
|
+
// Each pattern has a name, regex, and description for the rejection message.
|
|
25
|
+
|
|
26
|
+
const PATTERNS = [
|
|
27
|
+
{
|
|
28
|
+
name: 'private_key',
|
|
29
|
+
regex: /0x[a-fA-F0-9]{64}/g,
|
|
30
|
+
description: 'Blockchain private key (64-char hex)',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'api_token',
|
|
34
|
+
regex: /(Bearer\s+|sk-|cnwy_k|ghp_|gho_|AKIA)[A-Za-z0-9_\-]{8,}/g,
|
|
35
|
+
description: 'API token or credential (Bearer, sk-, ghp_, AKIA, etc.)',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'jwt_token',
|
|
39
|
+
regex: /eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+/g,
|
|
40
|
+
description: 'JWT token',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'internal_ip',
|
|
44
|
+
regex: /(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2[0-9]|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})/g,
|
|
45
|
+
description: 'Private/internal IP address (RFC 1918)',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
// M-4: Negative lookahead to suppress common placeholder/null false positives.
|
|
49
|
+
// Skips: password=null, =undefined, =<anything>, =***, =xxx, ={...}
|
|
50
|
+
name: 'password_pair',
|
|
51
|
+
regex: /password[=:]\s*(?!null\b|undefined\b|<[^>]+>|\*+|xxx+|\{[^}]+\})\S+/gi,
|
|
52
|
+
description: 'Password value in plaintext',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'connection_string',
|
|
56
|
+
regex: /(mongodb|postgres|mysql|redis):\/\/[^\s]+@/g,
|
|
57
|
+
description: 'Database connection string with credentials',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'env_secret',
|
|
61
|
+
regex: /[A-Z_]+=(sk-|ghp_|Bearer |0x[a-fA-F0-9]{64})/g,
|
|
62
|
+
description: 'Environment variable containing secret value',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
// M-3: Word-boundary assertions on aws_secret to prevent matching substrings
|
|
66
|
+
// of larger base64 strings. contextRequired keeps it quiet without a keyword.
|
|
67
|
+
name: 'aws_secret',
|
|
68
|
+
regex: /(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/g,
|
|
69
|
+
description: 'Potential AWS secret access key (40-char base64)',
|
|
70
|
+
contextRequired: true,
|
|
71
|
+
},
|
|
72
|
+
// ── New patterns added in SPEC-SF (C-1, C-2) ──────────────────────────────
|
|
73
|
+
{
|
|
74
|
+
// C-1: SSH/RSA/EC/OPENSSH/DSA/ED25519 private key headers
|
|
75
|
+
name: 'ssh_private_key',
|
|
76
|
+
regex: /-----BEGIN\s+(RSA|EC|OPENSSH|DSA|ED25519)?\s*PRIVATE KEY-----/g,
|
|
77
|
+
description: 'SSH/TLS private key header (PEM format)',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'slack_token',
|
|
81
|
+
regex: /(xoxb-|xoxp-|xoxs-|xoxa-|xoxo-)[A-Za-z0-9\-]{10,}/g,
|
|
82
|
+
description: 'Slack API token (bot, user, or workspace)',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
// C-2: Dedicated Stripe pattern — catches sk_live_, pk_live_, rk_live_, sk_test_, pk_test_
|
|
86
|
+
// that the generic sk- prefix in api_token missed for live keys.
|
|
87
|
+
name: 'stripe_key',
|
|
88
|
+
regex: /(sk_live_|pk_live_|rk_live_|sk_test_|pk_test_)[A-Za-z0-9]{10,}/g,
|
|
89
|
+
description: 'Stripe API key (live or test)',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'google_api_key',
|
|
93
|
+
regex: /AIza[A-Za-z0-9_\-]{35}/g,
|
|
94
|
+
description: 'Google API key (AIza... format)',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'npm_token',
|
|
98
|
+
regex: /npm_[A-Za-z0-9]{36}/g,
|
|
99
|
+
description: 'npm access token',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
// pem_block complements ssh_private_key — catches CERTIFICATE, PUBLIC KEY,
|
|
103
|
+
// PRIVATE KEY (generic), and ENCRYPTED PRIVATE KEY blocks.
|
|
104
|
+
name: 'pem_block',
|
|
105
|
+
regex: /-----BEGIN\s+(CERTIFICATE|PUBLIC KEY|PRIVATE KEY|ENCRYPTED PRIVATE KEY)-----/g,
|
|
106
|
+
description: 'PEM block header (certificate or key)',
|
|
107
|
+
},
|
|
108
|
+
// ── P2.1a new patterns (§7.6 — 10 additions) ─────────────────────────────
|
|
109
|
+
{
|
|
110
|
+
name: 'email_address',
|
|
111
|
+
// Linearly-bounded form (D-1 ReDoS fix). The previous
|
|
112
|
+
// /[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/ backtracked O(n^2) on a long run
|
|
113
|
+
// of dash/digit chars with no valid TLD, pinning the single-process event
|
|
114
|
+
// loop for minutes. Each quantifier here is explicitly bounded and the
|
|
115
|
+
// domain labels do not overlap with the dot separators, so there is no
|
|
116
|
+
// catastrophic-backtracking path.
|
|
117
|
+
regex: /[a-z0-9._%+\-]{1,64}@[a-z0-9](?:[a-z0-9\-]{0,253}[a-z0-9])?(?:\.[a-z]{2,24})+/gi,
|
|
118
|
+
description: 'Email address',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
// E.164 (+1234567890) + common US formats (123-456-7890, (123) 456-7890)
|
|
122
|
+
name: 'phone_number',
|
|
123
|
+
regex: /(\+\d{1,3}[\s.-]?)?(\(?\d{3}\)?[\s.-]?)?\d{3}[\s.-]?\d{4}(?!\d)/g,
|
|
124
|
+
description: 'Phone number (E.164 or US format)',
|
|
125
|
+
contextRequired: true, // suppress false positives on port numbers, timestamps
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'http_cookie',
|
|
129
|
+
regex: /(Cookie|Set-Cookie):\s*\S+/gi,
|
|
130
|
+
description: 'HTTP Cookie header',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'authorization_header',
|
|
134
|
+
regex: /Authorization:\s*\S+/gi,
|
|
135
|
+
description: 'HTTP Authorization header',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'gcp_service_account',
|
|
139
|
+
regex: /"type"\s*:\s*"service_account"/g,
|
|
140
|
+
description: 'GCP service account JSON key',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'azure_sas_token',
|
|
144
|
+
regex: /sig=[A-Za-z0-9%]{20,}/g,
|
|
145
|
+
description: 'Azure Storage SAS token',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'github_pat',
|
|
149
|
+
regex: /github_pat_[A-Za-z0-9_]{80,}/g,
|
|
150
|
+
description: 'GitHub fine-grained personal access token',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'openai_project_key',
|
|
154
|
+
regex: /sk-proj-[A-Za-z0-9]{20,}/g,
|
|
155
|
+
description: 'OpenAI project API key',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
// sk-ant-* — we host the marketplace; leaking these is a reputational nuke
|
|
159
|
+
name: 'anthropic_key',
|
|
160
|
+
regex: /sk-ant-[A-Za-z0-9_\-]{20,}/g,
|
|
161
|
+
description: 'Anthropic API key',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'discord_bot_token',
|
|
165
|
+
regex: /[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g,
|
|
166
|
+
description: 'Discord bot token',
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
// ─── M-2: /g flag invariant assertion at module load ────────────────────────
|
|
171
|
+
// Fail loudly at startup if any pattern is accidentally written without /g.
|
|
172
|
+
for (const p of PATTERNS) {
|
|
173
|
+
if (!p.regex.global) throw new Error(`[sensitivity-filter] Pattern "${p.name}" is missing /g flag`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Context keywords that make ambiguous patterns more suspicious ───────────
|
|
177
|
+
const CONTEXT_KEYWORDS = [
|
|
178
|
+
'secret', 'key', 'token', 'credential', 'password', 'auth',
|
|
179
|
+
'aws_secret', 'access_key', 'private', 'apikey', 'api_key',
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Scan text fields of a learning for sensitive patterns.
|
|
184
|
+
*
|
|
185
|
+
* @param {Object} learning - The learning object to scan
|
|
186
|
+
* @param {string} learning.title
|
|
187
|
+
* @param {string} learning.body
|
|
188
|
+
* @param {string} learning.task_context
|
|
189
|
+
* @param {string[]} learning.tags
|
|
190
|
+
* @returns {{ clean: boolean, matches?: Array<{ pattern: string, field: string, match: string, description: string }> }}
|
|
191
|
+
*/
|
|
192
|
+
function scanLearning(learning) {
|
|
193
|
+
const fields = {
|
|
194
|
+
title: learning.title || '',
|
|
195
|
+
body: learning.body || '',
|
|
196
|
+
task_context: learning.task_context || '',
|
|
197
|
+
tags: Array.isArray(learning.tags) ? learning.tags.join(' ') : '',
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const allText = Object.values(fields).join(' ');
|
|
201
|
+
const hasContextKeyword = CONTEXT_KEYWORDS.some(kw =>
|
|
202
|
+
allText.toLowerCase().includes(kw)
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const matches = [];
|
|
206
|
+
|
|
207
|
+
for (const pattern of PATTERNS) {
|
|
208
|
+
// Skip context-required patterns unless context keywords are present
|
|
209
|
+
if (pattern.contextRequired && !hasContextKeyword) continue;
|
|
210
|
+
|
|
211
|
+
for (const [fieldName, fieldValue] of Object.entries(fields)) {
|
|
212
|
+
// Reset regex lastIndex for global patterns
|
|
213
|
+
pattern.regex.lastIndex = 0;
|
|
214
|
+
let m;
|
|
215
|
+
while ((m = pattern.regex.exec(fieldValue)) !== null) {
|
|
216
|
+
// For private keys, skip if it's a well-known contract/wallet address (42 chars = address, not 64-char key)
|
|
217
|
+
if (pattern.name === 'private_key') {
|
|
218
|
+
// 0x + 64 hex = 66 chars total — this IS a key
|
|
219
|
+
// 0x + 40 hex = 42 chars total — this is an address, not a key
|
|
220
|
+
if (m[0].length <= 42) continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
matches.push({
|
|
224
|
+
pattern: pattern.name,
|
|
225
|
+
field: fieldName,
|
|
226
|
+
match: redactMatch(m[0]),
|
|
227
|
+
description: pattern.description,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (matches.length === 0) {
|
|
234
|
+
return { clean: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { clean: false, matches };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Redact a matched value for safe display in error messages.
|
|
242
|
+
* H-2 fix: shows first 3 and last 2 characters (5 total visible),
|
|
243
|
+
* replacing the middle with *** to reduce information leakage.
|
|
244
|
+
* For very short values (<=8 chars), shows first 2 chars + ***.
|
|
245
|
+
*/
|
|
246
|
+
function redactMatch(value) {
|
|
247
|
+
if (value.length <= 8) return value.substring(0, 2) + '***';
|
|
248
|
+
return value.substring(0, 3) + '***' + value.substring(value.length - 2);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Generate redaction suggestions for the agent.
|
|
253
|
+
* Maps pattern names to placeholder formats.
|
|
254
|
+
*/
|
|
255
|
+
function getRedactionHint(patternName) {
|
|
256
|
+
const hints = {
|
|
257
|
+
private_key: '0x{PRIVATE_KEY}',
|
|
258
|
+
api_token: '{API_TOKEN}',
|
|
259
|
+
jwt_token: '{JWT_TOKEN}',
|
|
260
|
+
internal_ip: '{PRIVATE_IP}',
|
|
261
|
+
password_pair: 'password={REDACTED}',
|
|
262
|
+
connection_string: '{protocol}://{CREDENTIALS}@{host}',
|
|
263
|
+
env_secret: '{ENV_VAR}={REDACTED}',
|
|
264
|
+
aws_secret: '{AWS_SECRET}',
|
|
265
|
+
// New patterns (SPEC-SF)
|
|
266
|
+
ssh_private_key: '-----BEGIN {KEY_TYPE} PRIVATE KEY-----',
|
|
267
|
+
slack_token: '{SLACK_TOKEN}',
|
|
268
|
+
stripe_key: '{STRIPE_KEY}',
|
|
269
|
+
google_api_key: '{GOOGLE_API_KEY}',
|
|
270
|
+
npm_token: '{NPM_TOKEN}',
|
|
271
|
+
pem_block: '-----BEGIN {PEM_TYPE}-----',
|
|
272
|
+
// P2.1a patterns (§7.6)
|
|
273
|
+
email_address: '{EMAIL}',
|
|
274
|
+
phone_number: '{PHONE}',
|
|
275
|
+
http_cookie: 'Cookie: {REDACTED}',
|
|
276
|
+
authorization_header: 'Authorization: {REDACTED}',
|
|
277
|
+
gcp_service_account: '{GCP_SERVICE_ACCOUNT}',
|
|
278
|
+
azure_sas_token: 'sig={REDACTED}',
|
|
279
|
+
github_pat: '{GITHUB_PAT}',
|
|
280
|
+
openai_project_key: '{OPENAI_KEY}',
|
|
281
|
+
anthropic_key: '{ANTHROPIC_KEY}',
|
|
282
|
+
discord_bot_token: '{DISCORD_TOKEN}',
|
|
283
|
+
};
|
|
284
|
+
return hints[patternName] || '{REDACTED}';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Normalization (§7.6) ────────────────────────────────────────────────────
|
|
288
|
+
// URL-decode + strip whitespace inside likely-token windows before regex matching.
|
|
289
|
+
// Applied before pattern scan to defeat simple obfuscation.
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Normalize text for sensitivity scanning.
|
|
293
|
+
* Decodes URL-encoded characters and strips whitespace within likely token sequences.
|
|
294
|
+
* @param {string} text
|
|
295
|
+
* @returns {string}
|
|
296
|
+
*/
|
|
297
|
+
function normalizeText(text) {
|
|
298
|
+
if (!text) return '';
|
|
299
|
+
// URL-decode
|
|
300
|
+
let normalized = text;
|
|
301
|
+
try {
|
|
302
|
+
normalized = decodeURIComponent(normalized);
|
|
303
|
+
} catch {
|
|
304
|
+
// If decode fails (malformed %), continue with original
|
|
305
|
+
}
|
|
306
|
+
// Strip whitespace inside sequences that look like they're breaking up tokens:
|
|
307
|
+
// e.g. "sk - ant - api_12" → "sk-ant-api_12"
|
|
308
|
+
normalized = normalized.replace(/([A-Za-z0-9_])\s+([\-_])\s+([A-Za-z0-9])/g, '$1$2$3');
|
|
309
|
+
return normalized;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── scanText (P2.1a §7.5) ──────────────────────────────────────────────────
|
|
313
|
+
// Raw text scanner for the runner. Cleaner seam than abusing scanLearning({body}).
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Scan raw text for sensitive patterns.
|
|
317
|
+
* Used by the client-side runner (§7.5) to scrub transcripts before upload.
|
|
318
|
+
*
|
|
319
|
+
* Returns { clean, matches, redacted } where:
|
|
320
|
+
* - clean: true if no matches found
|
|
321
|
+
* - matches: array of { pattern, match, description, offset }
|
|
322
|
+
* - redacted: the text with matched values replaced by redaction hints
|
|
323
|
+
*
|
|
324
|
+
* @param {string} text - Raw text to scan
|
|
325
|
+
* @returns {{ clean: boolean, matches: Array<{pattern: string, match: string, description: string, offset: number}>, redacted: string }}
|
|
326
|
+
*/
|
|
327
|
+
function scanText(text) {
|
|
328
|
+
if (!text || typeof text !== 'string') {
|
|
329
|
+
return { clean: true, matches: [], redacted: text || '' };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Normalize before scanning
|
|
333
|
+
const normalized = normalizeText(text);
|
|
334
|
+
const allTextLower = normalized.toLowerCase();
|
|
335
|
+
const hasContextKeyword = CONTEXT_KEYWORDS.some(kw => allTextLower.includes(kw));
|
|
336
|
+
|
|
337
|
+
const matchList = [];
|
|
338
|
+
|
|
339
|
+
for (const pattern of PATTERNS) {
|
|
340
|
+
if (pattern.contextRequired && !hasContextKeyword) continue;
|
|
341
|
+
|
|
342
|
+
pattern.regex.lastIndex = 0;
|
|
343
|
+
let m;
|
|
344
|
+
while ((m = pattern.regex.exec(normalized)) !== null) {
|
|
345
|
+
// Private key length check (same as scanLearning)
|
|
346
|
+
if (pattern.name === 'private_key' && m[0].length <= 42) continue;
|
|
347
|
+
|
|
348
|
+
matchList.push({
|
|
349
|
+
pattern: pattern.name,
|
|
350
|
+
match: redactMatch(m[0]),
|
|
351
|
+
description: pattern.description,
|
|
352
|
+
offset: m.index,
|
|
353
|
+
_rawMatch: m[0], // internal, for redaction — never exposed to API
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (matchList.length === 0) {
|
|
359
|
+
return { clean: true, matches: [], redacted: text };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Build redacted text by replacing each match with its redaction hint
|
|
363
|
+
let redacted = text;
|
|
364
|
+
// Sort matches by offset descending to replace from end to start (preserves offsets)
|
|
365
|
+
const sorted = [...matchList].sort((a, b) => b.offset - a.offset);
|
|
366
|
+
for (const m of sorted) {
|
|
367
|
+
const hint = getRedactionHint(m.pattern);
|
|
368
|
+
// Find the raw match in the original text (accounting for normalization drift)
|
|
369
|
+
const idx = redacted.indexOf(m._rawMatch);
|
|
370
|
+
if (idx >= 0) {
|
|
371
|
+
redacted = redacted.slice(0, idx) + hint + redacted.slice(idx + m._rawMatch.length);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Strip internal _rawMatch before returning
|
|
376
|
+
const cleanMatches = matchList.map(({ _rawMatch, ...rest }) => rest);
|
|
377
|
+
|
|
378
|
+
return { clean: false, matches: cleanMatches, redacted };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
module.exports = {
|
|
382
|
+
scanLearning,
|
|
383
|
+
scanText,
|
|
384
|
+
getRedactionHint,
|
|
385
|
+
normalizeText,
|
|
386
|
+
PATTERNS,
|
|
387
|
+
SENSITIVITY_FILTER_VERSION,
|
|
388
|
+
};
|