prodlint 0.10.0 → 0.10.3
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 +4 -2
- package/action.yml +13 -59
- package/dist/cli.js +236 -150
- package/dist/index.js +232 -154
- package/dist/mcp.js +236 -150
- package/package.json +2 -2
package/dist/mcp.js
CHANGED
|
@@ -2490,15 +2490,16 @@ var deadExportsRule = {
|
|
|
2490
2490
|
};
|
|
2491
2491
|
|
|
2492
2492
|
// src/rules/shallow-catch.ts
|
|
2493
|
-
function
|
|
2494
|
-
if (
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2493
|
+
function reportsError(value, param) {
|
|
2494
|
+
if (/error/i.test(value) || /\berr|Err|status|Response|json/.test(value)) return true;
|
|
2495
|
+
return param !== void 0 && new RegExp(`(?<![\\w$])${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(value);
|
|
2496
|
+
}
|
|
2497
|
+
function scoreCatchBody({ text: body, empty, returned, param }) {
|
|
2498
|
+
if (empty) return { score: 0, label: "empty catch" };
|
|
2498
2499
|
let score = 0;
|
|
2499
2500
|
if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
|
|
2500
2501
|
if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
|
|
2501
|
-
if (/\bthrow\b/.test(body) ||
|
|
2502
|
+
if (/\bthrow\b/.test(body) || returned.some((value) => reportsError(value, param)) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body) || // Toast notifications (react-toastify, sonner, shadcn)
|
|
2502
2503
|
/\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
|
|
2503
2504
|
/\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
|
|
2504
2505
|
/\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
|
|
@@ -2508,13 +2509,56 @@ function scoreCatchBody(bodyLines) {
|
|
|
2508
2509
|
score = 3;
|
|
2509
2510
|
}
|
|
2510
2511
|
const labels = {
|
|
2511
|
-
0: "
|
|
2512
|
+
0: "catch swallows the error",
|
|
2512
2513
|
1: "catch only logs (no recovery or propagation)",
|
|
2513
2514
|
2: "catch logs error but does not propagate or recover",
|
|
2514
2515
|
3: "catch handles error properly"
|
|
2515
2516
|
};
|
|
2516
2517
|
return { score, label: labels[score] };
|
|
2517
2518
|
}
|
|
2519
|
+
function catchFinding(file, catchLine, body) {
|
|
2520
|
+
const { score, label } = scoreCatchBody(body);
|
|
2521
|
+
if (score > 1) return null;
|
|
2522
|
+
return {
|
|
2523
|
+
ruleId: "shallow-catch",
|
|
2524
|
+
file: file.relativePath,
|
|
2525
|
+
line: catchLine + 1,
|
|
2526
|
+
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2527
|
+
message: body.empty ? "Empty catch block \u2014 errors are silently swallowed" : score === 0 ? "Catch block swallows the error \u2014 it isn't logged, rethrown or returned" : `Decorative error handler: ${label}`,
|
|
2528
|
+
severity: score === 0 ? "warning" : "info",
|
|
2529
|
+
category: "reliability",
|
|
2530
|
+
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
|
|
2534
|
+
function blankOut(source, strings) {
|
|
2535
|
+
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
2536
|
+
return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
|
|
2537
|
+
}
|
|
2538
|
+
function closingBrace(code, open) {
|
|
2539
|
+
let depth = 0;
|
|
2540
|
+
for (let i = open; i < code.length; i++) {
|
|
2541
|
+
if (code[i] === "{") depth++;
|
|
2542
|
+
else if (code[i] === "}" && --depth === 0) return i;
|
|
2543
|
+
}
|
|
2544
|
+
return -1;
|
|
2545
|
+
}
|
|
2546
|
+
function returnedValues(code, text) {
|
|
2547
|
+
const values = [];
|
|
2548
|
+
for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
|
|
2549
|
+
const start = m.index + "return".length;
|
|
2550
|
+
let end = start;
|
|
2551
|
+
for (let depth = 0; end < code.length; end++) {
|
|
2552
|
+
const ch = code[end];
|
|
2553
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
2554
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
2555
|
+
if (depth-- === 0) break;
|
|
2556
|
+
} else if (depth === 0 && (ch === ";" || ch === "\n")) break;
|
|
2557
|
+
}
|
|
2558
|
+
values.push(text.slice(start, end));
|
|
2559
|
+
}
|
|
2560
|
+
return values;
|
|
2561
|
+
}
|
|
2518
2562
|
var shallowCatchRule = {
|
|
2519
2563
|
id: "shallow-catch",
|
|
2520
2564
|
name: "Shallow Error Handler",
|
|
@@ -2531,95 +2575,45 @@ var shallowCatchRule = {
|
|
|
2531
2575
|
walkAST(file.ast.program, (node) => {
|
|
2532
2576
|
if (node.type !== "CatchClause") return;
|
|
2533
2577
|
if (!node.loc) return;
|
|
2534
|
-
const
|
|
2578
|
+
const catchLine2 = node.loc.start.line - 1;
|
|
2535
2579
|
const body = node.body;
|
|
2536
|
-
if (
|
|
2537
|
-
const
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2551
|
-
});
|
|
2552
|
-
}
|
|
2580
|
+
if (body?.start == null || body.end == null) return;
|
|
2581
|
+
const returned = [];
|
|
2582
|
+
walkAST(body, (n) => {
|
|
2583
|
+
if (n.type === "ReturnStatement" && n.argument?.start != null && n.argument.end != null) {
|
|
2584
|
+
returned.push(file.content.slice(n.argument.start, n.argument.end));
|
|
2585
|
+
}
|
|
2586
|
+
});
|
|
2587
|
+
const finding = catchFinding(file, catchLine2, {
|
|
2588
|
+
text: file.content.slice(body.start + 1, body.end - 1),
|
|
2589
|
+
empty: body.body.length === 0,
|
|
2590
|
+
returned,
|
|
2591
|
+
param: node.param?.type === "Identifier" ? node.param.name : void 0
|
|
2592
|
+
});
|
|
2593
|
+
if (finding) findings.push(finding);
|
|
2553
2594
|
});
|
|
2554
2595
|
return findings;
|
|
2555
2596
|
} catch {
|
|
2556
2597
|
}
|
|
2557
2598
|
}
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
const line = file.lines[j];
|
|
2577
|
-
const startPos = j === braceStart ? line.indexOf("{") : 0;
|
|
2578
|
-
for (let k = startPos; k < line.length; k++) {
|
|
2579
|
-
const ch = line[k];
|
|
2580
|
-
const prev = k > 0 ? line[k - 1] : "";
|
|
2581
|
-
const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
|
|
2582
|
-
if (!escaped) {
|
|
2583
|
-
if (ch === "'" && !inDouble && !inTemplate) {
|
|
2584
|
-
inSingle = !inSingle;
|
|
2585
|
-
continue;
|
|
2586
|
-
}
|
|
2587
|
-
if (ch === '"' && !inSingle && !inTemplate) {
|
|
2588
|
-
inDouble = !inDouble;
|
|
2589
|
-
continue;
|
|
2590
|
-
}
|
|
2591
|
-
if (ch === "`" && !inSingle && !inDouble) {
|
|
2592
|
-
inTemplate = !inTemplate;
|
|
2593
|
-
continue;
|
|
2594
|
-
}
|
|
2595
|
-
}
|
|
2596
|
-
if (inSingle || inDouble || inTemplate) continue;
|
|
2597
|
-
if (ch === "{") depth++;
|
|
2598
|
-
if (ch === "}") {
|
|
2599
|
-
depth--;
|
|
2600
|
-
if (depth === 0) {
|
|
2601
|
-
bodyEnd = j;
|
|
2602
|
-
break;
|
|
2603
|
-
}
|
|
2604
|
-
}
|
|
2605
|
-
}
|
|
2606
|
-
if (depth === 0) break;
|
|
2607
|
-
}
|
|
2608
|
-
const bodyLines = file.lines.slice(braceStart + 1, bodyEnd);
|
|
2609
|
-
const { score, label } = scoreCatchBody(bodyLines);
|
|
2610
|
-
if (score <= 1) {
|
|
2611
|
-
findings.push({
|
|
2612
|
-
ruleId: "shallow-catch",
|
|
2613
|
-
file: file.relativePath,
|
|
2614
|
-
line: i + 1,
|
|
2615
|
-
column: file.lines[i].indexOf("catch") + 1,
|
|
2616
|
-
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2617
|
-
severity: score === 0 ? "warning" : "info",
|
|
2618
|
-
category: "reliability",
|
|
2619
|
-
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2620
|
-
});
|
|
2621
|
-
}
|
|
2622
|
-
i = bodyEnd;
|
|
2599
|
+
const source = file.lines.join("\n");
|
|
2600
|
+
const code = blankOut(source, true);
|
|
2601
|
+
const noComments = blankOut(source, false);
|
|
2602
|
+
const catchRe = /(?<![\w$.])catch\s*(?:\(([^)]*)\)\s*)?\{/g;
|
|
2603
|
+
let catchLine = 0;
|
|
2604
|
+
let scanned = 0;
|
|
2605
|
+
for (let m = catchRe.exec(code); m; m = catchRe.exec(code)) {
|
|
2606
|
+
for (; scanned < m.index; scanned++) if (code[scanned] === "\n") catchLine++;
|
|
2607
|
+
const open = m.index + m[0].length - 1;
|
|
2608
|
+
const close = closingBrace(code, open);
|
|
2609
|
+
if (close === -1) continue;
|
|
2610
|
+
const finding = catchFinding(file, catchLine, {
|
|
2611
|
+
text: source.slice(open + 1, close),
|
|
2612
|
+
empty: code.slice(open + 1, close).trim() === "",
|
|
2613
|
+
returned: returnedValues(code.slice(open + 1, close), noComments.slice(open + 1, close)),
|
|
2614
|
+
param: m[1]?.match(/^\s*([A-Za-z_$][\w$]*)/)?.[1]
|
|
2615
|
+
});
|
|
2616
|
+
if (finding) findings.push(finding);
|
|
2623
2617
|
}
|
|
2624
2618
|
return findings;
|
|
2625
2619
|
}
|
|
@@ -4556,6 +4550,14 @@ async function scan(options) {
|
|
|
4556
4550
|
};
|
|
4557
4551
|
}
|
|
4558
4552
|
|
|
4553
|
+
// src/web-scanner/index.ts
|
|
4554
|
+
import { lookup as dnsLookup } from "dns";
|
|
4555
|
+
import { lookup as dnsLookupAll } from "dns/promises";
|
|
4556
|
+
import { request as httpRequest } from "http";
|
|
4557
|
+
import { request as httpsRequest } from "https";
|
|
4558
|
+
import { BlockList, isIP } from "net";
|
|
4559
|
+
import { createBrotliDecompress, createGunzip, createInflate } from "zlib";
|
|
4560
|
+
|
|
4559
4561
|
// src/web-scanner/checks.ts
|
|
4560
4562
|
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4561
4563
|
return {
|
|
@@ -4695,72 +4697,157 @@ function getDomain(url) {
|
|
|
4695
4697
|
return url;
|
|
4696
4698
|
}
|
|
4697
4699
|
}
|
|
4698
|
-
var
|
|
4700
|
+
var PRIVATE_RANGES = new BlockList();
|
|
4701
|
+
for (const [network, prefix] of [
|
|
4702
|
+
["0.0.0.0", 8],
|
|
4703
|
+
["10.0.0.0", 8],
|
|
4704
|
+
["100.64.0.0", 10],
|
|
4705
|
+
["127.0.0.0", 8],
|
|
4706
|
+
["169.254.0.0", 16],
|
|
4707
|
+
["172.16.0.0", 12],
|
|
4708
|
+
["192.0.0.0", 24],
|
|
4709
|
+
["192.168.0.0", 16],
|
|
4710
|
+
["198.18.0.0", 15],
|
|
4711
|
+
["224.0.0.0", 3]
|
|
4712
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv4");
|
|
4713
|
+
for (const [network, prefix] of [
|
|
4714
|
+
["::", 96],
|
|
4715
|
+
["64:ff9b:1::", 48],
|
|
4716
|
+
["2001::", 32],
|
|
4717
|
+
["fc00::", 7],
|
|
4718
|
+
["fe80::", 10],
|
|
4719
|
+
["fec0::", 10],
|
|
4720
|
+
["ff00::", 8]
|
|
4721
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv6");
|
|
4722
|
+
function ipv6Groups(address) {
|
|
4723
|
+
const quad = address.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
4724
|
+
const hex = quad ? `${quad[1]}${(+quad[2] << 8 | +quad[3]).toString(16)}:${(+quad[4] << 8 | +quad[5]).toString(16)}` : address;
|
|
4725
|
+
const [head, tail] = hex.split("::");
|
|
4726
|
+
const parse2 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
|
|
4727
|
+
const front = parse2(head);
|
|
4728
|
+
const back = parse2(tail);
|
|
4729
|
+
return tail === void 0 ? front : [...front, ...Array(8 - front.length - back.length).fill(0), ...back];
|
|
4730
|
+
}
|
|
4731
|
+
function embeddedIPv4(address) {
|
|
4732
|
+
const g = ipv6Groups(address);
|
|
4733
|
+
const quad = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
4734
|
+
const zero = (from, to) => g.slice(from, to).every((x) => x === 0);
|
|
4735
|
+
if (zero(0, 5) && g[5] === 65535) return quad(g[6], g[7]);
|
|
4736
|
+
if (g[0] === 100 && g[1] === 65435 && zero(2, 6)) return quad(g[6], g[7]);
|
|
4737
|
+
if (g[0] === 8194) return quad(g[1], g[2]);
|
|
4738
|
+
return null;
|
|
4739
|
+
}
|
|
4740
|
+
function isPrivateAddress(address) {
|
|
4741
|
+
const ip = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
4742
|
+
const family = isIP(ip);
|
|
4743
|
+
if (family === 4) return PRIVATE_RANGES.check(ip, "ipv4");
|
|
4744
|
+
if (family !== 6) return false;
|
|
4745
|
+
const v4 = embeddedIPv4(ip);
|
|
4746
|
+
return v4 !== null && PRIVATE_RANGES.check(v4, "ipv4") || PRIVATE_RANGES.check(ip, "ipv6");
|
|
4747
|
+
}
|
|
4699
4748
|
function isPrivateHost(hostname) {
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4704
|
-
if (ipv4) {
|
|
4705
|
-
const [, a, b] = ipv4.map(Number);
|
|
4706
|
-
if (a === 10 || a === 127 || a === 0) return true;
|
|
4707
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4708
|
-
if (a === 192 && b === 168) return true;
|
|
4709
|
-
if (a === 169 && b === 254) return true;
|
|
4710
|
-
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
4711
|
-
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
4712
|
-
}
|
|
4713
|
-
return false;
|
|
4749
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, "");
|
|
4750
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal")) return true;
|
|
4751
|
+
return isPrivateAddress(host);
|
|
4714
4752
|
}
|
|
4715
|
-
function
|
|
4753
|
+
async function resolvesToPrivateAddress(hostname) {
|
|
4716
4754
|
try {
|
|
4717
|
-
const
|
|
4718
|
-
return
|
|
4755
|
+
const addresses = await dnsLookupAll(hostname.replace(/^\[|\]$/g, ""), { all: true });
|
|
4756
|
+
return addresses.some((a) => isPrivateAddress(a.address));
|
|
4719
4757
|
} catch {
|
|
4720
4758
|
return false;
|
|
4721
4759
|
}
|
|
4722
4760
|
}
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
const
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4761
|
+
var guardedLookup = (hostname, options, callback) => {
|
|
4762
|
+
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
4763
|
+
if (err) return callback(err, "");
|
|
4764
|
+
const blocked = addresses.find((a) => isPrivateAddress(a.address));
|
|
4765
|
+
if (blocked) {
|
|
4766
|
+
return callback(Object.assign(new Error(`${hostname} resolves to private address ${blocked.address}`), { code: "EPRIVATEADDR" }), "");
|
|
4767
|
+
}
|
|
4768
|
+
if (options.all) return callback(null, addresses);
|
|
4769
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
4770
|
+
});
|
|
4771
|
+
};
|
|
4772
|
+
var USER_AGENT = "Prodlint-WebScanner/1.0";
|
|
4773
|
+
var MAX_REDIRECTS = 5;
|
|
4774
|
+
var MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
4775
|
+
function flattenHeaders(res) {
|
|
4776
|
+
const headers = {};
|
|
4777
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
4778
|
+
if (value !== void 0) headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
4733
4779
|
}
|
|
4780
|
+
return headers;
|
|
4734
4781
|
}
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
const
|
|
4743
|
-
|
|
4744
|
-
|
|
4782
|
+
function readBody(res) {
|
|
4783
|
+
const encoding = String(res.headers["content-encoding"] ?? "").trim().toLowerCase();
|
|
4784
|
+
const decoder = encoding === "gzip" || encoding === "x-gzip" ? createGunzip() : encoding === "deflate" ? createInflate() : encoding === "br" ? createBrotliDecompress() : null;
|
|
4785
|
+
const stream = decoder ? res.pipe(decoder) : res;
|
|
4786
|
+
return new Promise((resolve5, reject) => {
|
|
4787
|
+
const chunks = [];
|
|
4788
|
+
let size = 0;
|
|
4789
|
+
const done = () => resolve5(Buffer.concat(chunks).toString("utf8"));
|
|
4790
|
+
stream.on("data", (chunk) => {
|
|
4791
|
+
if (size >= MAX_BODY_BYTES) return;
|
|
4792
|
+
chunks.push(chunk.subarray(0, MAX_BODY_BYTES - size));
|
|
4793
|
+
size += chunk.length;
|
|
4794
|
+
if (size >= MAX_BODY_BYTES) {
|
|
4795
|
+
done();
|
|
4796
|
+
res.destroy();
|
|
4797
|
+
decoder?.destroy();
|
|
4798
|
+
}
|
|
4745
4799
|
});
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
}
|
|
4800
|
+
stream.on("end", done);
|
|
4801
|
+
stream.on("error", reject);
|
|
4802
|
+
res.on("error", reject);
|
|
4803
|
+
});
|
|
4750
4804
|
}
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
const
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4805
|
+
function guardedGet(url, timeoutMs, lookup = guardedLookup) {
|
|
4806
|
+
return new Promise((resolve5) => {
|
|
4807
|
+
let current;
|
|
4808
|
+
const finish = (result) => {
|
|
4809
|
+
clearTimeout(timer);
|
|
4810
|
+
current?.destroy();
|
|
4811
|
+
resolve5(result);
|
|
4812
|
+
};
|
|
4813
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
4814
|
+
const attempt = (target, redirectsLeft) => {
|
|
4815
|
+
if (target.protocol !== "http:" && target.protocol !== "https:" || isPrivateHost(target.hostname)) return finish(null);
|
|
4816
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
4817
|
+
current = send(target, { agent: false, lookup, headers: { "User-Agent": USER_AGENT, "Accept-Encoding": "gzip, deflate, br" } }, (res) => {
|
|
4818
|
+
const status = res.statusCode ?? 0;
|
|
4819
|
+
const location = res.headers.location;
|
|
4820
|
+
if (status >= 300 && status < 400 && location) {
|
|
4821
|
+
res.resume();
|
|
4822
|
+
let next;
|
|
4823
|
+
try {
|
|
4824
|
+
next = new URL(location, target);
|
|
4825
|
+
} catch {
|
|
4826
|
+
return finish(null);
|
|
4827
|
+
}
|
|
4828
|
+
return redirectsLeft > 0 ? attempt(next, redirectsLeft - 1) : finish(null);
|
|
4829
|
+
}
|
|
4830
|
+
readBody(res).then((body) => finish({ status, headers: flattenHeaders(res), body }), () => finish(null));
|
|
4831
|
+
});
|
|
4832
|
+
current.on("error", () => finish(null));
|
|
4833
|
+
current.end();
|
|
4834
|
+
};
|
|
4835
|
+
try {
|
|
4836
|
+
attempt(new URL(url), MAX_REDIRECTS);
|
|
4837
|
+
} catch {
|
|
4838
|
+
finish(null);
|
|
4839
|
+
}
|
|
4840
|
+
});
|
|
4841
|
+
}
|
|
4842
|
+
async function fetchText(url, timeout = 8e3) {
|
|
4843
|
+
const r = await guardedGet(url, timeout);
|
|
4844
|
+
return r && r.status >= 200 && r.status < 300 ? r.body : null;
|
|
4845
|
+
}
|
|
4846
|
+
async function fetchPage(url, timeout = 15e3) {
|
|
4847
|
+
const start = Date.now();
|
|
4848
|
+
const r = await guardedGet(url, timeout);
|
|
4849
|
+
const ok = r !== null && r.status >= 200 && r.status < 300;
|
|
4850
|
+
return { headers: r?.headers ?? {}, html: ok ? r.body : null, loadTimeMs: ok ? Date.now() - start : null };
|
|
4764
4851
|
}
|
|
4765
4852
|
function normalizeUrl(input) {
|
|
4766
4853
|
let url = input.trim();
|
|
@@ -4772,7 +4859,7 @@ function normalizeUrl(input) {
|
|
|
4772
4859
|
}
|
|
4773
4860
|
async function runWebScan(targetUrl) {
|
|
4774
4861
|
const domain = getDomain(targetUrl);
|
|
4775
|
-
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
4862
|
+
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, page] = await Promise.all([
|
|
4776
4863
|
fetchText(`${targetUrl}/robots.txt`),
|
|
4777
4864
|
fetchText(`${targetUrl}/llms.txt`),
|
|
4778
4865
|
fetchText(`${targetUrl}/ai.txt`),
|
|
@@ -4780,10 +4867,9 @@ async function runWebScan(targetUrl) {
|
|
|
4780
4867
|
fetchText(`${targetUrl}/.well-known/agent-card.json`),
|
|
4781
4868
|
fetchText(`${targetUrl}/sitemap.xml`),
|
|
4782
4869
|
fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
|
|
4783
|
-
|
|
4784
|
-
fetchWithTiming(targetUrl)
|
|
4870
|
+
fetchPage(targetUrl)
|
|
4785
4871
|
]);
|
|
4786
|
-
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
4872
|
+
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, ...page };
|
|
4787
4873
|
const checks = allChecks.map((fn) => fn(ctx));
|
|
4788
4874
|
const totalPoints = checks.reduce((s, c) => s + c.points, 0);
|
|
4789
4875
|
const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
|
|
@@ -4885,7 +4971,7 @@ server.tool(
|
|
|
4885
4971
|
};
|
|
4886
4972
|
}
|
|
4887
4973
|
const hostname = new URL(normalizedUrl).hostname;
|
|
4888
|
-
if (isPrivateHost(hostname)) {
|
|
4974
|
+
if (isPrivateHost(hostname) || await resolvesToPrivateAddress(hostname)) {
|
|
4889
4975
|
return {
|
|
4890
4976
|
content: [{ type: "text", text: "Error: Cannot scan private or internal hosts." }],
|
|
4891
4977
|
isError: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prodlint",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "Production readiness for vibe-coded apps — know your AI code is ready to ship",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "prodlint contributors",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"@types/node": "^26.1.2",
|
|
59
59
|
"tsup": "^8.4.0",
|
|
60
60
|
"typescript": "^5.9.3",
|
|
61
|
-
"vitest": "^
|
|
61
|
+
"vitest": "^5.0.5"
|
|
62
62
|
},
|
|
63
63
|
"overrides": {
|
|
64
64
|
"esbuild": "^0.28.1"
|