@cristobalbn/chaski 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,148 @@
1
+ // Filtro de secretos.
2
+ //
3
+ // Corre ANTES de escribir, no solo antes de enviar: un token en
4
+ // .tambo/relevo.md ya es una fuga aunque nunca salga de la máquina.
5
+ //
6
+ // Lo redactado deja marca visible para que nadie confunda una redacción con
7
+ // un dato ausente.
8
+
9
+ const PATTERNS = [
10
+ // Credenciales con prefijo reconocible
11
+ [/\bsk-[A-Za-z0-9_-]{16,}\b/g, 'clave-api'],
12
+ [/\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, 'clave-anthropic'],
13
+ [/\bAIza[0-9A-Za-z_-]{30,}\b/g, 'clave-google'],
14
+ [/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, 'token-github'],
15
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, 'token-github'],
16
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, 'token-slack'],
17
+ [/\bAKIA[0-9A-Z]{16}\b/g, 'clave-aws'],
18
+ [/\bASIA[0-9A-Z]{16}\b/g, 'clave-aws-temporal'],
19
+ [/\bglpat-[A-Za-z0-9_-]{16,}\b/g, 'token-gitlab'],
20
+ [/\bnpm_[A-Za-z0-9]{30,}\b/g, 'token-npm'],
21
+ [/\bdop_v1_[a-f0-9]{32,}\b/g, 'token-digitalocean'],
22
+ [/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/g, 'clave-sendgrid'],
23
+ [/\bsk_live_[A-Za-z0-9]{16,}\b/g, 'clave-stripe'],
24
+ [/\brk_live_[A-Za-z0-9]{16,}\b/g, 'clave-stripe'],
25
+
26
+ // Formatos estructurados
27
+ [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, 'jwt'],
28
+ [/-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g, 'clave-privada'],
29
+
30
+ // Asignaciones con name sensible: TOKEN=..., api_secret: "...", PASSWORD = '...'
31
+ [/\b([A-Za-z_][A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|APIKEY|API_KEY|PRIVATE_KEY|CREDENTIAL|ACCESS_KEY)[A-Za-z0-9_]*)\s*[:=]\s*["']?([^\s"',;]{8,})["']?/gi, 'asignacion'],
32
+
33
+ // URL con credenciales embebidas
34
+ [/\b([a-z][a-z0-9+.-]*:\/\/)([^\s:/@]+):([^\s:/@]+)@/gi, 'url-con-credencial'],
35
+
36
+ // Cabecera Authorization
37
+ [/\b(Authorization\s*:\s*(?:Bearer|Basic|token)\s+)([A-Za-z0-9._~+/=-]{12,})/gi, 'cabecera-auth'],
38
+ ];
39
+
40
+ // Patrones de usuario largos no aportan nada que uno corto no haga, y sí
41
+ // alargan el tiempo de cualquier retroceso que la heurística de abajo no
42
+ // atrape. chaski.json se versiona: esto no es para un usuario que se
43
+ // equivoca, es un techo contra un patrón que nadie miró con cuidado.
44
+ const MAX_USER_PATTERN_LEN = 200;
45
+
46
+ /**
47
+ * ¿Esta forma de patrón puede producir retroceso catastrófico?
48
+ *
49
+ * Heurística, no un verificador — no existe uno sin dependencias, y escribir
50
+ * uno sería el motor de regex que ya tenemos reimplementado peor. Cubre la
51
+ * forma confirmada en esta máquina: `redact('a'.repeat(35) + '!', { redact:
52
+ * ['(a+)+$'] })` se cuelga indefinidamente (8+ s antes de matarlo a mano).
53
+ * La familia que causa esto es siempre la misma: un grupo que ya repite algo
54
+ * por dentro (`+`, `*` o alternancia `|`) y que además se repite por fuera
55
+ * — `(a+)+`, `(a*)*`, `(a|aa)+`. Eso es lo que se rechaza aquí.
56
+ *
57
+ * Lo que esto NO atrapa, a propósito, porque cubrirlo pide un parser de
58
+ * regex completo y este archivo no lo tiene: cuantificadores anidados a
59
+ * tres niveles (`((a+)+)+`), alternancia superpuesta sin agrupar
60
+ * (`a*a*a*...`), y cualquier patrón que sea lento por razones que no son
61
+ * retroceso exponencial (p. ej. una alternancia con cientos de ramas). El
62
+ * tope de longitud de abajo y el techo de `chaski.json` versionado — un
63
+ * patrón ajeno pasa por code review antes de ejecutarse — son la segunda
64
+ * línea de defensa para lo que se escapa de la heurística.
65
+ */
66
+ function isCatastrophicShape(source) {
67
+ return /\([^()]*[+*|][^()]*\)(?:[*+]|\{\d+,\})/.test(source);
68
+ }
69
+
70
+ /** Patrones extra del usuario, desde chaski.json → redact: ["..."] */
71
+ function userPatterns(config) {
72
+ const extra = [];
73
+ for (const p of (config?.redact ?? [])) {
74
+ if (typeof p !== 'string' || !p || p.length > MAX_USER_PATTERN_LEN) continue;
75
+ if (isCatastrophicShape(p)) {
76
+ process.stderr.write(`chaski: patrón de redact ignorado, puede colgar el hook: ${p}\n`);
77
+ continue;
78
+ }
79
+ try { extra.push([new RegExp(p, 'g'), 'patron-usuario']); } catch {}
80
+ }
81
+ return extra;
82
+ }
83
+
84
+ /**
85
+ * Redacta el texto. Devuelve { texto, redactions } — el conteo sirve para
86
+ * que el relevo declare que hubo redactions en lugar de callarlas.
87
+ */
88
+ export function redact(texto, config = null) {
89
+ if (!texto) return { texto: '', redactions: 0 };
90
+ let out = String(texto);
91
+ let n = 0;
92
+
93
+ for (const [re, etiqueta] of [...PATTERNS, ...userPatterns(config)]) {
94
+ out = out.replace(re, (match, ...grupos) => {
95
+ n++;
96
+ // En asignaciones, URLs y cabeceras se conserva la parte no secreta
97
+ // para que el texto siga siendo legible.
98
+ if (etiqueta === 'asignacion') return `${grupos[0]}=[redactado: secreto]`;
99
+ if (etiqueta === 'url-con-credencial') return `${grupos[0]}${grupos[1]}:[redactado]@`;
100
+ if (etiqueta === 'cabecera-auth') return `${grupos[0]}[redactado]`;
101
+ return `[redactado: ${etiqueta}]`;
102
+ });
103
+ }
104
+
105
+ // Cadenas largas de alta entropía que ningún patrón nombró.
106
+ out = out.replace(/\b[A-Za-z0-9+/=_-]{40,}\b/g, (m) => {
107
+ if (!hasHighEntropy(m)) return m;
108
+ n++;
109
+ return '[redactado: cadena-de-alta-entropia]';
110
+ });
111
+
112
+ return { texto: out, redactions: n };
113
+ }
114
+
115
+ /**
116
+ * Entropía de Shannon por carácter. Un hash o un token supera ~3.5 bits;
117
+ * una palabra o un identificador en código queda por debajo.
118
+ */
119
+ function hasHighEntropy(s, umbral = 3.6) {
120
+ const freq = Object.create(null);
121
+ for (const c of s) freq[c] = (freq[c] || 0) + 1;
122
+ let h = 0;
123
+ for (const c in freq) {
124
+ const p = freq[c] / s.length;
125
+ h -= p * Math.log2(p);
126
+ }
127
+ return h >= umbral;
128
+ }
129
+
130
+ /** ¿Esta ruta no debe aparecer nunca en el relevo? */
131
+ export function isExcludedPath(ruta, config = null) {
132
+ const defecto = [
133
+ // Lo que chaski mismo escribe: nunca es parte del trabajo del usuario.
134
+ /(^|[\\/])\.tambo([\\/]|$)/i,
135
+ /(^|[\\/])chaski\.local\.json$/i,
136
+ /(^|[\\/])\.env($|\.|[\\/])/i,
137
+ /(^|[\\/])\.?(credentials|secrets?)($|[\\/.])/i,
138
+ /\.(pem|key|pfx|p12|keystore|jks)$/i,
139
+ /(^|[\\/])id_(rsa|dsa|ecdsa|ed25519)($|\.)/i,
140
+ /(^|[\\/])\.npmrc$/i,
141
+ /(^|[\\/])\.aws[\\/]/i,
142
+ ];
143
+ const extra = (config?.excludePaths ?? []).map((p) => {
144
+ try { return new RegExp(p, 'i'); } catch { return null; }
145
+ }).filter(Boolean);
146
+
147
+ return [...defecto, ...extra].some((re) => re.test(ruta));
148
+ }