beast-agent 2.30.6 → 2.32.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.
- package/package.json +1 -1
- package/src/agent/fsguard.js +46 -0
- package/src/agent/mcp.js +98 -18
- package/src/agent/tools.js +114 -4
- package/src/main.js +163 -22
- package/src/preload.js +5 -0
- package/src/renderer/i18n.js +121 -30
- package/src/renderer/index.html +40 -0
- package/src/renderer/renderer.js +293 -11
- package/src/renderer/style.css +13 -0
package/package.json
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* Dışa gönderim güvenlik kapısı: ajanın send_file aracı sır/anahtar
|
|
4
|
+
dosyalarını asla WhatsApp/sohbete göndermesin. Saf fonksiyon —
|
|
5
|
+
electron'a bağımlı değildir, test edilebilir. */
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
function norm(p) {
|
|
11
|
+
return String(p || '').replace(/\//g, '\\').toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* true → bu dosya hassas, dışa gönderilmez */
|
|
15
|
+
function isSensitiveSendPath(abs, opts = {}) {
|
|
16
|
+
const appData = opts.appData || process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
17
|
+
const home = opts.home || os.homedir();
|
|
18
|
+
let full;
|
|
19
|
+
try {
|
|
20
|
+
full = norm(path.resolve(String(abs || '')));
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (!full) return false;
|
|
25
|
+
|
|
26
|
+
/* Beast veri dizini: settings.json (API key'leri), wa-auth, mcp.json,
|
|
27
|
+
sessions, botlar, tools — tek komutla sızamaz */
|
|
28
|
+
const beastDir = norm(path.join(appData, 'beast'));
|
|
29
|
+
if (full === beastDir || full.startsWith(beastDir + '\\')) return true;
|
|
30
|
+
|
|
31
|
+
/* Kimlik/anahtar klasörleri */
|
|
32
|
+
for (const d of ['.ssh', '.aws', '.gnupg', '.docker', '.kube', '.config']) {
|
|
33
|
+
const p = norm(path.join(home, d));
|
|
34
|
+
if (full === p || full.startsWith(p + '\\')) return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const base = path.basename(full);
|
|
38
|
+
if (/^(id_rsa|id_ed25519|id_ecdsa|id_dsa|authorized_keys)(\.pub)?$/.test(base)) return true;
|
|
39
|
+
if (/^\.env(\.|$)/.test(base)) return true;
|
|
40
|
+
if (/\.(pem|key|pfx|p12|keystore|jks|ppk)$/.test(base)) return true;
|
|
41
|
+
if (/^(credentials|creds|secrets?)\.(json|ya?ml|txt)$/.test(base)) return true;
|
|
42
|
+
if (/^(\.npmrc|\.netrc|\.git-credentials|\.htpasswd)$/.test(base)) return true;
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { isSensitiveSendPath };
|
package/src/agent/mcp.js
CHANGED
|
@@ -42,6 +42,23 @@ function configPath() {
|
|
|
42
42
|
|
|
43
43
|
let cfgCache = { mtime: -1, cfg: { servers: {} } };
|
|
44
44
|
|
|
45
|
+
/* Sunucu kaydını normalize eder + token güvenliğini doğrular.
|
|
46
|
+
Komut yoksa null döner; satır sonu/NUL varsa fırlatır (kayıt atlanır/reddedilir). */
|
|
47
|
+
function normalizeServer(s) {
|
|
48
|
+
if (!s || typeof s !== 'object' || !s.command) return null;
|
|
49
|
+
const command = String(s.command);
|
|
50
|
+
assertSafeToken(command, 'MCP komutu');
|
|
51
|
+
const args = (Array.isArray(s.args) ? s.args : []).map((a) => assertSafeToken(a, 'MCP argümanı'));
|
|
52
|
+
return {
|
|
53
|
+
command,
|
|
54
|
+
args,
|
|
55
|
+
env: s.env && typeof s.env === 'object' ? Object.fromEntries(Object.entries(s.env).map(([k, v]) => [String(k), String(v)])) : {},
|
|
56
|
+
enabled: s.enabled !== false,
|
|
57
|
+
tools: Array.isArray(s.tools) ? s.tools.map(String) : null,
|
|
58
|
+
timeoutMs: Number(s.timeoutMs) > 1000 ? Math.min(Number(s.timeoutMs), 600000) : CALL_TIMEOUT_MS,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
function readConfig(force) {
|
|
46
63
|
try {
|
|
47
64
|
const p = configPath();
|
|
@@ -51,15 +68,12 @@ function readConfig(force) {
|
|
|
51
68
|
const cfg = { servers: {} };
|
|
52
69
|
if (parsed && typeof parsed.servers === 'object') {
|
|
53
70
|
for (const [name, s] of Object.entries(parsed.servers)) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
tools: Array.isArray(s.tools) ? s.tools.map(String) : null,
|
|
61
|
-
timeoutMs: Number(s.timeoutMs) > 1000 ? Math.min(Number(s.timeoutMs), 600000) : CALL_TIMEOUT_MS,
|
|
62
|
-
};
|
|
71
|
+
try {
|
|
72
|
+
const srv = normalizeServer(s);
|
|
73
|
+
if (srv) cfg.servers[sanitizeName(name)] = srv;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
log.warn(`[mcp] '${name}' geçersiz, atlandı: ${String((e && e.message) || e)}`);
|
|
76
|
+
}
|
|
63
77
|
}
|
|
64
78
|
}
|
|
65
79
|
cfgCache = { mtime: st.mtimeMs, cfg };
|
|
@@ -76,9 +90,15 @@ function readConfig(force) {
|
|
|
76
90
|
|
|
77
91
|
function saveConfig(cfg) {
|
|
78
92
|
const p = configPath();
|
|
93
|
+
/* DİSKE YAZMADAN doğrula: güvensiz/yapıbozuk kayıt asla mcp.json'a düşmez */
|
|
94
|
+
const clean = { servers: {} };
|
|
95
|
+
for (const [name, s] of Object.entries((cfg && cfg.servers) || {})) {
|
|
96
|
+
const srv = normalizeServer(s);
|
|
97
|
+
if (srv) clean.servers[sanitizeName(name)] = srv;
|
|
98
|
+
}
|
|
79
99
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
80
|
-
fs.writeFileSync(p, JSON.stringify(
|
|
81
|
-
cfgCache = { mtime: -1, cfg }; /* sonraki okuma diskten gelsin */
|
|
100
|
+
fs.writeFileSync(p, JSON.stringify(clean, null, 2) + '\n');
|
|
101
|
+
cfgCache = { mtime: -1, cfg: clean }; /* sonraki okuma diskten gelsin */
|
|
82
102
|
}
|
|
83
103
|
|
|
84
104
|
function sanitizeName(name) {
|
|
@@ -89,20 +109,77 @@ function sanitizeName(name) {
|
|
|
89
109
|
.slice(0, 32) || 'server';
|
|
90
110
|
}
|
|
91
111
|
|
|
112
|
+
/* UI'a maskeli ('***') giden env sırlarını diskteki mevcut değerlerle geri koyar.
|
|
113
|
+
Böylece kullanıcı formu kaydedince sırlar '***' ile ezilmez. */
|
|
114
|
+
function restoreMaskedSecrets(cfg) {
|
|
115
|
+
let cur;
|
|
116
|
+
try { cur = readConfig(true); } catch { return cfg; }
|
|
117
|
+
for (const [name, s] of Object.entries((cfg && cfg.servers) || {})) {
|
|
118
|
+
if (!s || typeof s !== 'object' || !s.env || typeof s.env !== 'object') continue;
|
|
119
|
+
const old = cur.servers[sanitizeName(name)];
|
|
120
|
+
if (!old || !old.env) continue;
|
|
121
|
+
for (const [k, v] of Object.entries(s.env)) {
|
|
122
|
+
if (String(v) === '***' && old.env[k] !== undefined) s.env[k] = old.env[k];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return cfg;
|
|
126
|
+
}
|
|
127
|
+
|
|
92
128
|
/* ---------- süreç yaşam döngüsü ---------- */
|
|
93
129
|
|
|
130
|
+
/* Token doğrulama: satır sonu/NUL her platformda enjeksiyon vektörüdür */
|
|
131
|
+
function assertSafeToken(t, what) {
|
|
132
|
+
const s = String(t == null ? '' : t);
|
|
133
|
+
if (/[\0\r\n]/.test(s)) throw new Error(what + ' içinde satır sonu/NUL karakteri olamaz');
|
|
134
|
+
return s;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const CMD_META_RE = /[\s"&|<>^()]/;
|
|
138
|
+
|
|
139
|
+
/* KOMUT token'ı: yalnız gerekiyorsa tırnaklanır — aksi halde npm/npx gibi
|
|
140
|
+
.cmd sarmalayıcılarının %~dp0 çözümü bozulur. %/! reddedilir. */
|
|
141
|
+
function quoteCmdCommand(t) {
|
|
142
|
+
const s = assertSafeToken(t, 'MCP komutu');
|
|
143
|
+
if (/[%!]/.test(s)) throw new Error('MCP komutunda % veya ! kullanılamaz (güvenlik)');
|
|
144
|
+
if (!CMD_META_RE.test(s)) return s;
|
|
145
|
+
return '"' + s.replace(/"/g, '""') + '"';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* ARGÜMAN token'ı: HER ZAMAN tırnak içinde → & | < > gibi metakarakterler
|
|
149
|
+
komut ayıramaz. %/! genişletilebildiği için reddedilir; kapanış tırnağı
|
|
150
|
+
öncesi ters bölü CRT kuralı gereği çiftlenir. */
|
|
151
|
+
function quoteCmdArg(t) {
|
|
152
|
+
const s = assertSafeToken(t, 'MCP argümanı');
|
|
153
|
+
if (/[%!]/.test(s)) throw new Error('MCP argümanında % veya ! kullanılamaz (güvenlik)');
|
|
154
|
+
const out = s.replace(/"/g, '""').replace(/(\\+)$/, '$1$1');
|
|
155
|
+
return '"' + out + '"';
|
|
156
|
+
}
|
|
157
|
+
|
|
94
158
|
function spawnServer(cfg) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const child = spawn(line, {
|
|
100
|
-
shell: true,
|
|
159
|
+
const cmd = assertSafeToken(cfg.command, 'MCP komutu');
|
|
160
|
+
if (!cmd.trim()) throw new Error('MCP komutu boş');
|
|
161
|
+
const args = (Array.isArray(cfg.args) ? cfg.args : []).map((a) => assertSafeToken(a, 'MCP argümanı'));
|
|
162
|
+
const opts = {
|
|
101
163
|
windowsHide: true,
|
|
102
164
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
103
165
|
env: { ...process.env, ...(cfg.env || {}) },
|
|
166
|
+
};
|
|
167
|
+
if (process.platform !== 'win32') {
|
|
168
|
+
/* POSIX: shell YOK — & | ; $() gibi metakarakterler enjekte edilemez */
|
|
169
|
+
return spawn(cmd, args, { ...opts, shell: false });
|
|
170
|
+
}
|
|
171
|
+
/* Windows: npx/uvx .cmd sarmalayıcıları düz spawn edilemez — cmd.exe'ye
|
|
172
|
+
tırnaklı/tırnaksız DOĞRU komut satırı elle kurulur; shell KAPALI,
|
|
173
|
+
windowsVerbatimArguments ile libuv müdahalesi kapatılır. */
|
|
174
|
+
const first = quoteCmdCommand(cmd);
|
|
175
|
+
const line = [first, ...args.map(quoteCmdArg)].join(' ');
|
|
176
|
+
/* İlk token tırnaklıysa cmd /s ilk ve son tırnağı KIRPAR — dış sarmal şart */
|
|
177
|
+
const full = first.startsWith('"') ? '"' + line + '"' : line;
|
|
178
|
+
return spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', full], {
|
|
179
|
+
...opts,
|
|
180
|
+
shell: false,
|
|
181
|
+
windowsVerbatimArguments: true,
|
|
104
182
|
});
|
|
105
|
-
return child;
|
|
106
183
|
}
|
|
107
184
|
|
|
108
185
|
function connFor(name) {
|
|
@@ -414,6 +491,9 @@ module.exports = {
|
|
|
414
491
|
configPath,
|
|
415
492
|
readConfig,
|
|
416
493
|
saveConfig,
|
|
494
|
+
restoreMaskedSecrets,
|
|
495
|
+
normalizeServer,
|
|
496
|
+
_spawnServer: spawnServer,
|
|
417
497
|
mergeTools,
|
|
418
498
|
toolSchemas,
|
|
419
499
|
call,
|
package/src/agent/tools.js
CHANGED
|
@@ -4,6 +4,8 @@ const fs = require('fs');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const https = require('https');
|
|
7
|
+
const dns = require('dns');
|
|
8
|
+
const net = require('net');
|
|
7
9
|
const { execFile } = require('child_process');
|
|
8
10
|
const { spawn } = require('child_process');
|
|
9
11
|
const research = require('./research');
|
|
@@ -877,6 +879,111 @@ function assertPublicHttpUrl(raw) {
|
|
|
877
879
|
return u.toString();
|
|
878
880
|
}
|
|
879
881
|
|
|
882
|
+
/* Özel/ayrılmış IP aralığı mı? (IPv4 + IPv6 + v4-mapped) */
|
|
883
|
+
function isPrivateIp(ip) {
|
|
884
|
+
const v = String(ip || '').toLowerCase().replace(/^\[|\]$/g, '');
|
|
885
|
+
if (v.includes(':')) {
|
|
886
|
+
if (v === '::1' || v === '::') return true;
|
|
887
|
+
if (/^f[cd]/.test(v)) return true; /* fc00::/7 unique-local */
|
|
888
|
+
if (/^fe[89ab]/.test(v)) return true; /* fe80::/10 link-local */
|
|
889
|
+
if (/^ff/.test(v)) return true; /* multicast */
|
|
890
|
+
if (v.startsWith('2001:db8')) return true; /* dokümantasyon */
|
|
891
|
+
const m = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); /* v4-mapped */
|
|
892
|
+
if (m) return isPrivateIp(m[1]);
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
const p = v.split('.').map(Number);
|
|
896
|
+
if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; /* çözümlenemeyen → güvensiz */
|
|
897
|
+
if (p[0] === 0 || p[0] === 10 || p[0] === 127 || p[0] >= 224) return true;
|
|
898
|
+
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true; /* CGNAT */
|
|
899
|
+
if (p[0] === 169 && p[1] === 254) return true; /* link-local */
|
|
900
|
+
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
|
|
901
|
+
if (p[0] === 192 && (p[1] === 168 || (p[1] === 0 && (p[2] === 0 || p[2] === 2)) || (p[1] === 88 && p[2] === 99))) return true;
|
|
902
|
+
if (p[0] === 198 && (p[1] === 18 || p[1] === 19 || (p[1] === 51 && p[2] === 100))) return true;
|
|
903
|
+
if (p[0] === 203 && p[1] === 0 && p[2] === 113) return true;
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/* DNS çözümlemesi: alan adı ÖZEL bir IP'ye çözülüyorsa (localtest.me vb.) engelle.
|
|
908
|
+
TOCTOU/DNS-rebinding için tam çözüm connection pinning gerektirir; bu katman
|
|
909
|
+
metin kontrolü + gerçek çözümleme + redirect doğrulamasıyla açığı kapatır. */
|
|
910
|
+
async function assertPublicHostResolved(hostname) {
|
|
911
|
+
const h = String(hostname || '').replace(/^\[|\]$/g, '');
|
|
912
|
+
if (!h) throw new Error('geçersiz adres');
|
|
913
|
+
if (net.isIP(h)) {
|
|
914
|
+
if (isPrivateIp(h)) throw new Error('yerel/ağ içi adreslere erişim engellendi');
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
let addrs;
|
|
918
|
+
try {
|
|
919
|
+
addrs = await dns.promises.lookup(h, { all: true, verbatim: true });
|
|
920
|
+
} catch {
|
|
921
|
+
throw new Error('alan adı çözümlenemedi: ' + h);
|
|
922
|
+
}
|
|
923
|
+
if (!addrs || !addrs.length) throw new Error('alan adı çözümlenemedi: ' + h);
|
|
924
|
+
for (const a of addrs) {
|
|
925
|
+
if (isPrivateIp(a.address)) throw new Error('yerel/ağ içi adreslere erişim engellendi (' + h + ')');
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/* http_fetch için: yönlendirmeleri ELLE takip eder ve HER adımı doğrular
|
|
930
|
+
(302 → 127.0.0.1 gibi bypass'ları kapatır). Döner: { res, url } */
|
|
931
|
+
async function fetchPublicFollow(rawUrl, opts = {}, timeoutMs = 30000, signal) {
|
|
932
|
+
let current = assertPublicHttpUrl(rawUrl);
|
|
933
|
+
for (let hop = 0; hop <= 5; hop++) {
|
|
934
|
+
const u = new URL(current);
|
|
935
|
+
await assertPublicHostResolved(u.hostname);
|
|
936
|
+
const ctrl = new AbortController();
|
|
937
|
+
const timer = setTimeout(() => ctrl.abort(new Error('zaman aşımı')), timeoutMs);
|
|
938
|
+
const onAbort = () => ctrl.abort(new Error('iptal'));
|
|
939
|
+
if (signal) {
|
|
940
|
+
if (signal.aborted) throw new Error('iptal');
|
|
941
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
942
|
+
}
|
|
943
|
+
let res;
|
|
944
|
+
try {
|
|
945
|
+
res = await fetch(current, { ...opts, signal: ctrl.signal, redirect: 'manual' });
|
|
946
|
+
} finally {
|
|
947
|
+
clearTimeout(timer);
|
|
948
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
949
|
+
}
|
|
950
|
+
const loc = res.headers.get('location');
|
|
951
|
+
if (res.status >= 300 && res.status < 400 && loc) {
|
|
952
|
+
if (hop === 5) throw new Error('çok fazla yönlendirme');
|
|
953
|
+
current = assertPublicHttpUrl(new URL(loc, current).toString());
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
return { res, url: current };
|
|
957
|
+
}
|
|
958
|
+
throw new Error('çok fazla yönlendirme');
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/* Gövdeyi SINIRLI oku — kötü niyetli sunucu sınırsız veri akıtıp belleği şişirmesin */
|
|
962
|
+
async function readTextCapped(res, byteCap = 1024 * 1024) {
|
|
963
|
+
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
964
|
+
const t = await res.text();
|
|
965
|
+
return t.length > byteCap ? t.slice(0, byteCap) : t;
|
|
966
|
+
}
|
|
967
|
+
const reader = res.body.getReader();
|
|
968
|
+
const chunks = [];
|
|
969
|
+
let size = 0;
|
|
970
|
+
try {
|
|
971
|
+
while (true) {
|
|
972
|
+
const { done, value } = await reader.read();
|
|
973
|
+
if (done) break;
|
|
974
|
+
if (!value || !value.length) continue;
|
|
975
|
+
const room = byteCap - size;
|
|
976
|
+
if (room <= 0) break;
|
|
977
|
+
chunks.push(value.length > room ? value.slice(0, room) : value);
|
|
978
|
+
size += Math.min(value.length, room);
|
|
979
|
+
if (size >= byteCap) break;
|
|
980
|
+
}
|
|
981
|
+
} finally {
|
|
982
|
+
try { await reader.cancel(); } catch {}
|
|
983
|
+
}
|
|
984
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
|
985
|
+
}
|
|
986
|
+
|
|
880
987
|
const ENTITIES = {
|
|
881
988
|
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–',
|
|
882
989
|
hellip: '…', rsquo: '\u2019', lsquo: '\u2018', ldquo: '\u201c', rdquo: '\u201d',
|
|
@@ -1004,10 +1111,10 @@ async function webSearch(query, { maxResults = 8, signal } = {}) {
|
|
|
1004
1111
|
const MAX_FETCH_CHARS = 9000;
|
|
1005
1112
|
|
|
1006
1113
|
async function httpFetch(url, { maxChars = MAX_FETCH_CHARS, format = 'text', timeoutMs = 30000, signal } = {}) {
|
|
1007
|
-
const safe = assertPublicHttpUrl(url);
|
|
1008
1114
|
const t = Math.min(Math.max(Number(timeoutMs) || 30000, 1000), 120000); // opencode: default 30s, max 120s
|
|
1009
|
-
|
|
1010
|
-
|
|
1115
|
+
/* SSRF: metin + DNS çözümlemesi + her redirect adımı ayrı doğrulanır */
|
|
1116
|
+
const { res, url: safe } = await fetchPublicFollow(
|
|
1117
|
+
url,
|
|
1011
1118
|
{ headers: { 'User-Agent': UA, Accept: 'text/html,text/plain,application/json;q=0.9,*/*;q=0.5' } },
|
|
1012
1119
|
t,
|
|
1013
1120
|
signal
|
|
@@ -1020,7 +1127,7 @@ async function httpFetch(url, { maxChars = MAX_FETCH_CHARS, format = 'text', tim
|
|
|
1020
1127
|
return { ok: true, url: safe, status: res.status, contentType: ctype, note: 'ikili/metin olmayan içerik indirilmedi' };
|
|
1021
1128
|
}
|
|
1022
1129
|
const cap = 400000;
|
|
1023
|
-
let body = await res
|
|
1130
|
+
let body = await readTextCapped(res, 1024 * 1024);
|
|
1024
1131
|
if (body.length > cap) body = body.slice(0, cap);
|
|
1025
1132
|
let content;
|
|
1026
1133
|
if (/html/i.test(ctype)) {
|
|
@@ -2357,6 +2464,9 @@ module.exports = {
|
|
|
2357
2464
|
globToRegExp,
|
|
2358
2465
|
walkFiles,
|
|
2359
2466
|
assertPublicHttpUrl,
|
|
2467
|
+
isPrivateIp,
|
|
2468
|
+
assertPublicHostResolved,
|
|
2469
|
+
fetchPublicFollow,
|
|
2360
2470
|
parseDdgResults,
|
|
2361
2471
|
htmlToText,
|
|
2362
2472
|
webSearch,
|