crawlforge-mcp-server 6.22.3 → 6.23.1
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 +10 -1
- package/package.json +6 -6
- package/server.js +14 -7
- package/src/core/ActionExecutor.js +117 -12
- package/src/core/AgentOrchestrator.js +71 -14
- package/src/core/AuthManager.js +7 -1
- package/src/core/ChangeTracker.js +52 -4
- package/src/core/LLMsTxtAnalyzer.js +21 -9
- package/src/core/LocalizationManager.js +20 -4
- package/src/core/ResearchOrchestrator.js +8 -1
- package/src/core/StealthBrowserManager.js +21 -8
- package/src/core/analysis/ContentAnalyzer.js +157 -29
- package/src/core/browser/guardedProxy.js +291 -0
- package/src/core/browser/snapshot.js +22 -0
- package/src/core/crawlers/BFSCrawler.js +10 -4
- package/src/core/processing/BrowserProcessor.js +5 -3
- package/src/core/processing/ContentProcessor.js +2 -1
- package/src/core/workers/worker.js +2 -0
- package/src/schemas/toolOutputSchemas.js +3 -2
- package/src/server/fallbackHints.js +5 -1
- package/src/server/inlineThreshold.js +15 -9
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
- package/src/tools/agent/sourceChecks.js +103 -0
- package/src/tools/basic/extractMetadata.js +9 -4
- package/src/tools/basic/extractText.js +6 -4
- package/src/tools/basic/fetchUrl.js +3 -0
- package/src/tools/basic/scrapeStructured.js +66 -21
- package/src/tools/crawl/mapSite.js +18 -3
- package/src/tools/extract/extractStructured.js +134 -1
- package/src/tools/extract/processDocument.js +48 -5
- package/src/tools/extract/summarizeContent.js +123 -5
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
- package/src/tools/result/readResult.js +79 -10
- package/src/tools/scrape/_mainContent.js +97 -6
- package/src/tools/search/adapters/dataforseoSearch.js +4 -1
- package/src/tools/search/searchWeb.js +46 -15
- package/src/tools/search/serpRank.js +1 -1
- package/src/tools/tracking/trackChanges/index.js +1 -0
- package/src/tools/tracking/trackChanges/schema.js +2 -1
- package/src/utils/htmlToMarkdown.js +77 -1
- package/src/utils/localFilePolicy.js +65 -0
- package/src/utils/provenance.js +102 -1
- package/src/utils/ssrfGuard.js +4 -3
- package/src/utils/ssrfProtection.js +3 -0
|
@@ -179,7 +179,7 @@ export class SerpRankTool {
|
|
|
179
179
|
device: meta.device,
|
|
180
180
|
depthScanned: meta.depth,
|
|
181
181
|
organicResults: meta.organicCount,
|
|
182
|
-
seResultsCount: meta.seResultsCount,
|
|
182
|
+
seResultsCount: meta.seResultsCount ?? undefined, // omitted when unknown (see the adapter)
|
|
183
183
|
checkUrl: meta.checkUrl, // link to view the real SERP
|
|
184
184
|
cost: meta.cost, // USD charged by DataForSEO for this lookup
|
|
185
185
|
checkedAt: new Date().toISOString(),
|
|
@@ -304,6 +304,7 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
304
304
|
details: comparisonResult.details,
|
|
305
305
|
metrics: comparisonResult.metrics,
|
|
306
306
|
recommendations: comparisonResult.recommendations,
|
|
307
|
+
...(comparisonResult.ignoredOptions ? { ignoredOptions: comparisonResult.ignoredOptions } : {}),
|
|
307
308
|
...(comparisonResult.warnings || fetchWarnings.length
|
|
308
309
|
? { warnings: [...(comparisonResult.warnings || []), ...fetchWarnings] }
|
|
309
310
|
: {}),
|
|
@@ -41,7 +41,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
|
|
|
41
41
|
// filled in here is indistinguishable from a value the caller passed, so
|
|
42
42
|
// it overrode the template (R24 2.7). ChangeTracker applies these same
|
|
43
43
|
// defaults when the baseline is created.
|
|
44
|
-
granularity: z.enum(['page', 'section', 'element', 'text']).optional().describe("Default: section"),
|
|
44
|
+
granularity: z.enum(['page', 'section', 'element', 'text']).optional().describe("Default: section. Fixed when the baseline is created: a compare always diffs at the baseline's granularity (as with customSelectors and excludeSelectors), and one that asks for a different value gets ignoredOptions:[{option, requested, applied}] plus a warnings entry. Recreate the baseline to change it"),
|
|
45
45
|
trackText: z.boolean().optional().describe("Default: true"),
|
|
46
46
|
trackStructure: z.boolean().optional().describe("Default: true"),
|
|
47
47
|
trackAttributes: z.boolean().default(false),
|
|
@@ -49,6 +49,7 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
|
|
|
49
49
|
trackLinks: z.boolean().optional().describe("Default: true"),
|
|
50
50
|
ignoreWhitespace: z.boolean().optional().describe("Default: true"),
|
|
51
51
|
ignoreCase: z.boolean().optional().describe("Default: false"),
|
|
52
|
+
includeMarkupChanges: z.boolean().optional().describe("Also return details.markupChanges, a diff of the raw HTML (tags, classes, attributes). details.textChanges always diffs the visible text only. Default: false"),
|
|
52
53
|
customSelectors: z.array(z.string()).optional(),
|
|
53
54
|
excludeSelectors: z.array(z.string()).optional().default([
|
|
54
55
|
'script', 'style', 'noscript', '.advertisement', '.ad', '#comments'
|
|
@@ -52,6 +52,63 @@ export function promoteCornerHeaderCells(html) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const SKIP_LANGUAGES = new Set(['default', 'none']);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find a code block's language in the markup that names it: `data-language`,
|
|
59
|
+
* a `language-x`/`lang-x` class, MDN's `brush: x` class, a Sphinx
|
|
60
|
+
* `highlight-x` wrapper, or MDN's `.example-header .language-name` label.
|
|
61
|
+
* @param {import('cheerio').Cheerio<any>} pre
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function codeLanguage(pre) {
|
|
65
|
+
const code = pre.children('code').first();
|
|
66
|
+
const fromData = pre.attr('data-language') || code.attr('data-language');
|
|
67
|
+
if (fromData) return fromData;
|
|
68
|
+
for (const cls of [code.attr('class'), pre.attr('class')]) {
|
|
69
|
+
const m = /(?:^|\s)(?:language|lang)-([\w+#.-]+)/.exec(cls || '') || /brush:\s*([\w+#.-]+)/.exec(cls || '');
|
|
70
|
+
if (m) return m[1];
|
|
71
|
+
}
|
|
72
|
+
const wrapper = pre.closest('[class*="highlight-"]');
|
|
73
|
+
const m = /(?:^|\s)highlight-([\w+#.-]+)/.exec(wrapper.attr('class') || '');
|
|
74
|
+
if (m && !SKIP_LANGUAGES.has(m[1])) return m[1];
|
|
75
|
+
return pre.closest('.code-example').find('.example-header .language-name').first().text().trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sphinx (docs.python.org) highlights code as `<pre>` full of `<span>`s with
|
|
80
|
+
* no `<code>`, so Turndown treated it as prose and escaped it (`\# comment`,
|
|
81
|
+
* `spam \= 1`); MDN puts a "js" language label and copy button in a header
|
|
82
|
+
* before each `<pre>`, which came out as a stray `js` line (live sweep
|
|
83
|
+
* 2026-10-20, 2.2). Rewrite every `<pre>` as `<pre><code>` holding its plain
|
|
84
|
+
* text with the language in `data-language` (Readability strips classes but
|
|
85
|
+
* keeps data attributes), and drop MDN's example headers. Run it before
|
|
86
|
+
* Readability as well as here, while the classes that name the language exist.
|
|
87
|
+
* @param {string} html
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function normalizeCodeBlocks(html) {
|
|
91
|
+
if (!/<pre[\s>]/i.test(html)) return html;
|
|
92
|
+
try {
|
|
93
|
+
const $ = load(html);
|
|
94
|
+
$('pre').each((_, el) => {
|
|
95
|
+
const pre = $(el);
|
|
96
|
+
const language = codeLanguage(pre);
|
|
97
|
+
pre.find('br').replaceWith('\n');
|
|
98
|
+
const code = $('<code></code>').text(pre.text());
|
|
99
|
+
if (language) {
|
|
100
|
+
pre.attr('data-language', language);
|
|
101
|
+
code.attr('data-language', language);
|
|
102
|
+
}
|
|
103
|
+
pre.empty().append(code);
|
|
104
|
+
});
|
|
105
|
+
$('.code-example > .example-header').remove();
|
|
106
|
+
return $.html();
|
|
107
|
+
} catch {
|
|
108
|
+
return html;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
55
112
|
const ROW_CONTAINER = /^(table|thead|tbody|tfoot)$/;
|
|
56
113
|
|
|
57
114
|
/**
|
|
@@ -152,6 +209,25 @@ function getTurndown() {
|
|
|
152
209
|
replacement: content => (content.trim() ? content.trim() + '\n' : '')
|
|
153
210
|
});
|
|
154
211
|
|
|
212
|
+
// Every <pre> is a fenced block of its plain text, never escaped prose;
|
|
213
|
+
// the language comes from normalizeCodeBlocks' data-language.
|
|
214
|
+
_td.addRule('codeBlock', {
|
|
215
|
+
filter: 'pre',
|
|
216
|
+
replacement: (_content, node) => {
|
|
217
|
+
const text = node.textContent.replace(/\n$/, '');
|
|
218
|
+
if (!text.trim()) return '';
|
|
219
|
+
const code = node.firstChild && node.firstChild.nodeName === 'CODE' ? node.firstChild : null;
|
|
220
|
+
const language =
|
|
221
|
+
node.getAttribute('data-language') ||
|
|
222
|
+
(code && (code.getAttribute('data-language') ||
|
|
223
|
+
(/(?:^|\s)language-(\S+)/.exec(code.getAttribute('class') || '') || [])[1])) ||
|
|
224
|
+
'';
|
|
225
|
+
const longestRun = Math.max(0, ...(text.match(/`+/g) || []).map(r => r.length));
|
|
226
|
+
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
227
|
+
return '\n\n' + fence + language + '\n' + text + '\n' + fence + '\n\n';
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
155
231
|
// Remove boilerplate elements before converting
|
|
156
232
|
_td.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
|
|
157
233
|
}
|
|
@@ -178,7 +254,7 @@ export function htmlToMarkdown(html, options = {}) {
|
|
|
178
254
|
const visible = options.keepHiddenContent
|
|
179
255
|
? rows
|
|
180
256
|
: stripHiddenHtml(rows, { css: options.css });
|
|
181
|
-
return getTurndown().turndown(promoteCornerHeaderCells(visible)).trim();
|
|
257
|
+
return getTurndown().turndown(promoteCornerHeaderCells(normalizeCodeBlocks(visible))).trim();
|
|
182
258
|
} catch {
|
|
183
259
|
// Fallback: strip tags, return plain text
|
|
184
260
|
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which local files process_document may read (sourceType 'file' / 'pdf_file').
|
|
3
|
+
*
|
|
4
|
+
* A prompt-injected page can talk an agent into reading any path it names, so
|
|
5
|
+
* a local read is held to two rules, both checked on the path's realpath (a
|
|
6
|
+
* symlink is judged by where it points):
|
|
7
|
+
* - the extension must be one of the document types the tool reads
|
|
8
|
+
* (pdf, docx, txt, md, html/htm);
|
|
9
|
+
* - no path segment may start with "." (~/.ssh, ~/.aws, ~/.config,
|
|
10
|
+
* ~/.crawlforge, ...).
|
|
11
|
+
*
|
|
12
|
+
* CRAWLFORGE_ALLOWED_DIRS (a path.delimiter-separated list of directories)
|
|
13
|
+
* narrows reads to those directories instead: when set, a path is allowed only
|
|
14
|
+
* if its realpath sits inside one of them, and inside them the dot-segment rule
|
|
15
|
+
* does not apply. The extension rule always applies.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
export const ALLOWED_EXTENSIONS = ['.pdf', '.docx', '.txt', '.md', '.html', '.htm'];
|
|
21
|
+
|
|
22
|
+
async function realpathOr(p) {
|
|
23
|
+
try {
|
|
24
|
+
return await fs.realpath(p);
|
|
25
|
+
} catch {
|
|
26
|
+
return p; // a missing path is judged as given; the read itself reports it
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves `source` and refuses it when the policy above does not allow it.
|
|
32
|
+
* @param {string} source the path the caller passed
|
|
33
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
34
|
+
* @returns {Promise<string>} the realpath to read
|
|
35
|
+
*/
|
|
36
|
+
export async function assertLocalFileAllowed(source, env = process.env) {
|
|
37
|
+
const real = await realpathOr(path.resolve(source));
|
|
38
|
+
|
|
39
|
+
const dirs = (env.CRAWLFORGE_ALLOWED_DIRS || '').split(path.delimiter).filter((d) => d.trim());
|
|
40
|
+
if (dirs.length > 0) {
|
|
41
|
+
const roots = await Promise.all(dirs.map((dir) => realpathOr(path.resolve(dir.trim()))));
|
|
42
|
+
if (!roots.some((root) => real.startsWith(root.endsWith(path.sep) ? root : root + path.sep))) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Local file refused: '${source}' is outside CRAWLFORGE_ALLOWED_DIRS (${dirs.join(path.delimiter)}). ` +
|
|
45
|
+
'Add its directory to CRAWLFORGE_ALLOWED_DIRS to allow it.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
} else if (real.split(path.sep).some((segment) => segment.startsWith('.'))) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Local file refused: '${source}' resolves to a path inside a dot-directory (${real}), ` +
|
|
51
|
+
'which process_document does not read (they hold credentials and config such as ~/.ssh and ~/.crawlforge). ' +
|
|
52
|
+
'To allow it, list its directory in CRAWLFORGE_ALLOWED_DIRS.'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ext = path.extname(real).toLowerCase();
|
|
57
|
+
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Local file refused: '${source}' resolves to a ${ext ? `'${ext}'` : 'extensionless'} file, and ` +
|
|
60
|
+
`process_document only reads ${ALLOWED_EXTENSIONS.join(', ')} files from disk.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return real;
|
|
65
|
+
}
|
package/src/utils/provenance.js
CHANGED
|
@@ -91,6 +91,20 @@ const VERSION_PREFIX = /^v(?=\d)/i;
|
|
|
91
91
|
const MARKUP_BETWEEN_DIGITS = /(\d)(?:(?:\s*<[^>]{0,120}>)+\s*)([\d.,])/g;
|
|
92
92
|
const MAX_WELD_PASSES = 3;
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Whether `sep` can be a digit-grouping separator in `t`: Western 3-digit
|
|
96
|
+
* groups ("1,234,567") or Indian lakh grouping ("12,34,567").
|
|
97
|
+
*
|
|
98
|
+
* @param {string} t
|
|
99
|
+
* @param {string} sep - "." or ","
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
function isGrouped(t, sep) {
|
|
103
|
+
const s = sep === '.' ? '\\.' : sep;
|
|
104
|
+
return new RegExp(`^-?\\d{1,3}(?:${s}\\d{3})+$`).test(t)
|
|
105
|
+
|| new RegExp(`^-?\\d{1,2}(?:${s}\\d{2})*${s}\\d{3}$`).test(t);
|
|
106
|
+
}
|
|
107
|
+
|
|
94
108
|
/**
|
|
95
109
|
* Every numeric reading of one token, canonicalised.
|
|
96
110
|
*
|
|
@@ -114,7 +128,9 @@ function readings(token) {
|
|
|
114
128
|
raw.push(t.split(grouping).join('').replace(decimal, '.'));
|
|
115
129
|
} else if (hasDot || hasComma) {
|
|
116
130
|
const sep = hasDot ? '.' : ',';
|
|
117
|
-
|
|
131
|
+
// A grouping reading only when the groups are real thousands groups, so
|
|
132
|
+
// "12.34" is never read as 1234 (nor 1234 found in a page's "12.34").
|
|
133
|
+
if (isGrouped(t, sep)) raw.push(t.split(sep).join('')); // grouping reading
|
|
118
134
|
if (t.split(sep).length === 2) raw.push(t.replace(sep, '.')); // decimal reading
|
|
119
135
|
} else {
|
|
120
136
|
raw.push(t);
|
|
@@ -250,6 +266,79 @@ function embeddedVersionReadings(value) {
|
|
|
250
266
|
});
|
|
251
267
|
}
|
|
252
268
|
|
|
269
|
+
/** An ISO calendar date, the form a model is asked to convert dates into. */
|
|
270
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
271
|
+
|
|
272
|
+
/** Lowercase, strip accents and fold NBSPs, so "Mär" and "mar" compare equal. */
|
|
273
|
+
const foldText = (s) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Month name -> month number (1-12), long and short forms, from Intl for the
|
|
277
|
+
* languages a page is most often written in. A name two languages assign to
|
|
278
|
+
* different months would be ambiguous and is dropped.
|
|
279
|
+
*/
|
|
280
|
+
const MONTHS = (() => {
|
|
281
|
+
const map = new Map();
|
|
282
|
+
const clash = new Set();
|
|
283
|
+
for (const locale of ['en', 'fr', 'de', 'es', 'it', 'pt', 'nl']) {
|
|
284
|
+
for (const month of ['long', 'short']) {
|
|
285
|
+
const fmt = new Intl.DateTimeFormat(locale, { month, timeZone: 'UTC' });
|
|
286
|
+
for (let m = 1; m <= 12; m++) {
|
|
287
|
+
const name = foldText(fmt.format(new Date(Date.UTC(2026, m - 1, 15)))).replace(/\.$/, '');
|
|
288
|
+
if (map.has(name) && map.get(name) !== m) clash.add(name);
|
|
289
|
+
map.set(name, m);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
map.set('sept', 9); // "Sept 14" in English
|
|
294
|
+
for (const name of clash) map.delete(name);
|
|
295
|
+
return map;
|
|
296
|
+
})();
|
|
297
|
+
|
|
298
|
+
/** "2026-10-14" when y/m/d is a real calendar date, else null. */
|
|
299
|
+
function isoDate(y, m, d) {
|
|
300
|
+
const date = new Date(Date.UTC(y, m - 1, d));
|
|
301
|
+
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null;
|
|
302
|
+
return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** "14 octobre 2026", "14. März 2026", "1er octobre 2026", "14 de octubre de 2026". */
|
|
306
|
+
const DAY_MONTH_YEAR = /(?<![\p{L}\d])(\d{1,2})(?:er|st|nd|rd|th|\.)?\s*(?:de\s+)?(\p{L}{3,})\.?,?\s+(?:de\s+)?(\d{4})(?!\d)/gu;
|
|
307
|
+
/** "October 14, 2026", "Oct 14th 2026". */
|
|
308
|
+
const MONTH_DAY_YEAR = /(?<![\p{L}\d])(\p{L}{3,})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})(?!\d)/gu;
|
|
309
|
+
/** "14.10.2026", "14/10/2026", "10/14/2026" — day/month order read both ways. */
|
|
310
|
+
const NUMERIC_DATE = /(?<!\d)(\d{1,2})[./-](\d{1,2})[./-](\d{4})(?!\d)/g;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Every calendar date the source writes in words or as d/m/y digits, as ISO
|
|
314
|
+
* strings. Only real dates are admitted, and day, month and year must come
|
|
315
|
+
* from one expression, so a date is never assembled from scattered numbers.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} source
|
|
318
|
+
* @returns {Set<string>}
|
|
319
|
+
*/
|
|
320
|
+
function datesInSource(source) {
|
|
321
|
+
const found = new Set();
|
|
322
|
+
const add = (y, m, d) => {
|
|
323
|
+
const iso = isoDate(Number(y), Number(m), Number(d));
|
|
324
|
+
if (iso) found.add(iso);
|
|
325
|
+
};
|
|
326
|
+
for (const variant of sourceVariants(source)) {
|
|
327
|
+
const text = foldText(variant);
|
|
328
|
+
for (const [, d, name, y] of text.matchAll(DAY_MONTH_YEAR)) {
|
|
329
|
+
if (MONTHS.has(name)) add(y, MONTHS.get(name), d);
|
|
330
|
+
}
|
|
331
|
+
for (const [, name, d, y] of text.matchAll(MONTH_DAY_YEAR)) {
|
|
332
|
+
if (MONTHS.has(name)) add(y, MONTHS.get(name), d);
|
|
333
|
+
}
|
|
334
|
+
for (const [, a, b, y] of text.matchAll(NUMERIC_DATE)) {
|
|
335
|
+
add(y, b, a);
|
|
336
|
+
add(y, a, b);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return found;
|
|
340
|
+
}
|
|
341
|
+
|
|
253
342
|
/**
|
|
254
343
|
* Replace every numeric value that is not present in the source with null.
|
|
255
344
|
*
|
|
@@ -278,6 +367,7 @@ export function verifyNumericProvenance(data, source) {
|
|
|
278
367
|
// Built lazily: most extractions are all numbers and never need it, and
|
|
279
368
|
// lowercasing a multi-megabyte page is not free.
|
|
280
369
|
let haystacks = null;
|
|
370
|
+
let dates = null;
|
|
281
371
|
const inSource = (spellings) => {
|
|
282
372
|
if (haystacks === null) haystacks = literalHaystacks(source);
|
|
283
373
|
return spellings.some((s) => haystacks.some((h) => h.includes(s)));
|
|
@@ -313,6 +403,17 @@ export function verifyNumericProvenance(data, source) {
|
|
|
313
403
|
verified++;
|
|
314
404
|
return node;
|
|
315
405
|
}
|
|
406
|
+
// An ISO date the model converted from "14 octobre 2026" is not on the
|
|
407
|
+
// page literally; it is when the page's own dates are read. A date that
|
|
408
|
+
// cannot exist ("2027-03-00") never matches.
|
|
409
|
+
const iso = typeof node === 'string' && node.trim().match(ISO_DATE);
|
|
410
|
+
if (iso) {
|
|
411
|
+
if (dates === null) dates = datesInSource(source);
|
|
412
|
+
if (dates.has(isoDate(Number(iso[1]), Number(iso[2]), Number(iso[3])))) {
|
|
413
|
+
verified++;
|
|
414
|
+
return node;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
316
417
|
unverified.push({ path: path || '(root)', value: node, reason: 'not_found_in_source' });
|
|
317
418
|
return null;
|
|
318
419
|
}
|
package/src/utils/ssrfGuard.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Two levels:
|
|
12
12
|
* - Stage 1 (default): blocks connections to loopback, link-local /
|
|
13
|
-
* cloud-metadata (169.254.0.0/16, incl. 169.254.169.254),
|
|
13
|
+
* cloud-metadata (169.254.0.0/16, incl. 169.254.169.254), 0.0.0.0 and `::`.
|
|
14
14
|
* These are never legitimate public-scrape targets, so impact is ~zero.
|
|
15
15
|
* - Stage 2 (SSRF_STRICT=true): full private-range enforcement (RFC1918, ULA,
|
|
16
16
|
* multicast, CGNAT, etc.) via the existing SSRFProtection range logic.
|
|
@@ -42,7 +42,8 @@ import { withFetchCause } from './fetchError.js';
|
|
|
42
42
|
const _ssrf = new SSRFProtection();
|
|
43
43
|
|
|
44
44
|
// Narrow Stage-1 ranges: things no legitimate public scrape ever targets.
|
|
45
|
-
|
|
45
|
+
// `::` (the unspecified address) connects to the local host, like 0.0.0.0.
|
|
46
|
+
const STAGE1_RANGES = ['127.0.0.0/8', '169.254.0.0/16', '0.0.0.0/8', '::/128', '::1/128', 'fe80::/10'];
|
|
46
47
|
|
|
47
48
|
// Literal cloud-metadata / service-discovery hostnames blocked even before DNS.
|
|
48
49
|
const METADATA_HOSTS = new Set(['metadata.google.internal', 'metadata.azure.com', 'metadata']);
|
|
@@ -179,7 +180,7 @@ function preflightHostCheck(u, sec) {
|
|
|
179
180
|
* connects to exactly what we checked (rebinding-safe). Allowlisted hostnames
|
|
180
181
|
* are resolved normally, with no address filtering.
|
|
181
182
|
*/
|
|
182
|
-
function ssrfLookup(hostname, opts, callback) {
|
|
183
|
+
export function ssrfLookup(hostname, opts, callback) {
|
|
183
184
|
const sec = config.security?.ssrfProtection;
|
|
184
185
|
if (isAllowlisted(hostname.toLowerCase(), sec?.allowedDomains)) {
|
|
185
186
|
return dns.lookup(hostname, opts, callback);
|
|
@@ -29,7 +29,10 @@ const SSRF_CONFIG = {
|
|
|
29
29
|
'0.0.0.0/8', // This network
|
|
30
30
|
'100.64.0.0/10', // Carrier-grade NAT
|
|
31
31
|
'198.18.0.0/15', // Benchmark testing
|
|
32
|
+
'::/128', // IPv6 unspecified (connects to the local host)
|
|
32
33
|
'::1/128', // IPv6 localhost
|
|
34
|
+
'::/96', // IPv4-compatible IPv6 (deprecated)
|
|
35
|
+
'64:ff9b::/96', // NAT64 (embeds an IPv4 address)
|
|
33
36
|
'fc00::/7', // IPv6 private network
|
|
34
37
|
'fe80::/10', // IPv6 link-local
|
|
35
38
|
'ff00::/8', // IPv6 multicast
|