prodlint 0.10.0 → 0.10.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 +4 -2
- package/action.yml +13 -59
- package/dist/cli.js +152 -60
- package/dist/index.js +148 -64
- package/dist/mcp.js +152 -60
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ npx prodlint
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
```
|
|
16
|
-
prodlint v0.10.
|
|
16
|
+
prodlint v0.10.2
|
|
17
17
|
Scanned 148 files · 2 critical · 5 warnings · 1 info
|
|
18
18
|
|
|
19
19
|
src/app/api/checkout/route.ts
|
|
@@ -187,7 +187,9 @@ jobs:
|
|
|
187
187
|
threshold: 50
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
Posts a score breakdown as a PR comment and fails the build if below threshold.
|
|
190
|
+
Posts a score breakdown as a PR comment and fails the build if below threshold. Each
|
|
191
|
+
release of the action runs its matching prodlint version, so pinning the action (`@v1`,
|
|
192
|
+
a version tag, or a commit SHA) also pins the scanner.
|
|
191
193
|
|
|
192
194
|
| Input | Default | Description |
|
|
193
195
|
|-------|---------|-------------|
|
package/action.yml
CHANGED
|
@@ -34,9 +34,11 @@ runs:
|
|
|
34
34
|
using: 'composite'
|
|
35
35
|
steps:
|
|
36
36
|
- name: Setup Node.js
|
|
37
|
-
uses: actions/setup-node@
|
|
37
|
+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
|
38
38
|
with:
|
|
39
|
-
node-version: '
|
|
39
|
+
node-version: '22'
|
|
40
|
+
# Automatic caching would key off the scanned repo's package.json; npx needs none.
|
|
41
|
+
package-manager-cache: false
|
|
40
42
|
|
|
41
43
|
- name: Run prodlint
|
|
42
44
|
id: scan
|
|
@@ -54,7 +56,9 @@ runs:
|
|
|
54
56
|
done
|
|
55
57
|
fi
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
# The exact release this action ships with (scripts/sync-versions.cjs bumps it), so
|
|
60
|
+
# pinning the action — @v1, a version tag or a commit SHA — also pins the scanner.
|
|
61
|
+
OUTPUT=$(npx -y prodlint@0.10.2 "${CMD_ARGS[@]}" 2>&1) || true
|
|
58
62
|
|
|
59
63
|
SCORE=$(echo "$OUTPUT" | node -e "
|
|
60
64
|
const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
@@ -70,72 +74,22 @@ runs:
|
|
|
70
74
|
|
|
71
75
|
echo "score=${SCORE:-0}" >> "$GITHUB_OUTPUT"
|
|
72
76
|
echo "critical=${CRITICAL:-0}" >> "$GITHUB_OUTPUT"
|
|
73
|
-
echo "$OUTPUT" > /
|
|
77
|
+
echo "$OUTPUT" > "$RUNNER_TEMP/prodlint-result.json"
|
|
74
78
|
|
|
79
|
+
# Built by a script file rather than inline `node -e`: the comment carries file paths
|
|
80
|
+
# and messages from the scanned repo, and the script's escaping is unit-tested.
|
|
75
81
|
- name: Generate comment body
|
|
76
82
|
if: inputs.comment == 'true' && github.event_name == 'pull_request'
|
|
77
83
|
id: comment
|
|
78
84
|
shell: bash
|
|
79
|
-
|
|
80
|
-
SCAN_SCORE: ${{ steps.scan.outputs.score }}
|
|
81
|
-
INPUT_THRESHOLD: ${{ inputs.threshold }}
|
|
82
|
-
run: |
|
|
83
|
-
SCORE="$SCAN_SCORE"
|
|
84
|
-
THRESHOLD="$INPUT_THRESHOLD"
|
|
85
|
-
|
|
86
|
-
if [ "$SCORE" -ge 80 ]; then
|
|
87
|
-
EMOJI="✅"
|
|
88
|
-
COLOR="brightgreen"
|
|
89
|
-
elif [ "$SCORE" -ge 60 ]; then
|
|
90
|
-
EMOJI="⚠️"
|
|
91
|
-
COLOR="yellow"
|
|
92
|
-
else
|
|
93
|
-
EMOJI="🚨"
|
|
94
|
-
COLOR="red"
|
|
95
|
-
fi
|
|
96
|
-
|
|
97
|
-
# Build comment from JSON with markdown sanitization
|
|
98
|
-
TMPFILE=$(mktemp "${RUNNER_TEMP:-/tmp}/prodlint-comment-XXXXXX.md")
|
|
99
|
-
node -e "
|
|
100
|
-
const fs = require('fs');
|
|
101
|
-
const d = JSON.parse(fs.readFileSync('/tmp/prodlint-result.json', 'utf8'));
|
|
102
|
-
const esc = s => String(s).replace(/[[\]()\\*_\`<>]/g, c => '\\\\' + c);
|
|
103
|
-
const lines = [];
|
|
104
|
-
lines.push('## ' + '$EMOJI' + ' Production Readiness: **' + d.overallScore + '/100**');
|
|
105
|
-
lines.push('');
|
|
106
|
-
lines.push('| Category | Score | Issues |');
|
|
107
|
-
lines.push('|----------|-------|--------|');
|
|
108
|
-
for (const c of d.categoryScores) {
|
|
109
|
-
const icon = c.score >= 80 ? '🟢' : c.score >= 60 ? '🟡' : '🔴';
|
|
110
|
-
lines.push('| ' + icon + ' ' + esc(c.category) + ' | ' + c.score + '/100 | ' + c.findingCount + ' |');
|
|
111
|
-
}
|
|
112
|
-
if (d.summary.critical > 0) {
|
|
113
|
-
lines.push('');
|
|
114
|
-
lines.push('### Critical Issues');
|
|
115
|
-
lines.push('');
|
|
116
|
-
const crits = d.findings.filter(f => f.severity === 'critical');
|
|
117
|
-
for (const f of crits.slice(0, 10)) {
|
|
118
|
-
lines.push('- \`' + esc(f.ruleId) + '\` \`' + esc(f.file) + ':' + f.line + '\` — ' + esc(f.message));
|
|
119
|
-
}
|
|
120
|
-
if (crits.length > 10) {
|
|
121
|
-
lines.push('- ...and ' + (crits.length - 10) + ' more');
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
lines.push('');
|
|
125
|
-
lines.push('---');
|
|
126
|
-
lines.push('*Scanned ' + d.filesScanned + ' files in ' + d.scanDurationMs + 'ms · [prodlint](https://prodlint.com)*');
|
|
127
|
-
fs.writeFileSync('$TMPFILE', lines.join('\n'));
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
# Use the generated file for the comment step
|
|
131
|
-
cp "$TMPFILE" /tmp/prodlint-comment.md
|
|
85
|
+
run: node "$GITHUB_ACTION_PATH/scripts/action-comment.cjs" "$RUNNER_TEMP/prodlint-result.json" "$RUNNER_TEMP/prodlint-comment.md"
|
|
132
86
|
|
|
133
87
|
- name: Post PR comment
|
|
134
88
|
if: inputs.comment == 'true' && github.event_name == 'pull_request'
|
|
135
|
-
uses: marocchino/sticky-pull-request-comment@v3
|
|
89
|
+
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
|
136
90
|
with:
|
|
137
91
|
header: prodlint
|
|
138
|
-
path: /
|
|
92
|
+
path: ${{ runner.temp }}/prodlint-comment.md
|
|
139
93
|
|
|
140
94
|
- name: Check threshold
|
|
141
95
|
if: inputs.threshold != '0'
|
package/dist/cli.js
CHANGED
|
@@ -4781,6 +4781,14 @@ function reportWebPretty(result) {
|
|
|
4781
4781
|
return lines.join("\n");
|
|
4782
4782
|
}
|
|
4783
4783
|
|
|
4784
|
+
// src/web-scanner/index.ts
|
|
4785
|
+
import { lookup as dnsLookup } from "dns";
|
|
4786
|
+
import { lookup as dnsLookupAll } from "dns/promises";
|
|
4787
|
+
import { request as httpRequest } from "http";
|
|
4788
|
+
import { request as httpsRequest } from "https";
|
|
4789
|
+
import { BlockList, isIP } from "net";
|
|
4790
|
+
import { createBrotliDecompress, createGunzip, createInflate } from "zlib";
|
|
4791
|
+
|
|
4784
4792
|
// src/web-scanner/checks.ts
|
|
4785
4793
|
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4786
4794
|
return {
|
|
@@ -4920,72 +4928,157 @@ function getDomain(url) {
|
|
|
4920
4928
|
return url;
|
|
4921
4929
|
}
|
|
4922
4930
|
}
|
|
4923
|
-
var
|
|
4931
|
+
var PRIVATE_RANGES = new BlockList();
|
|
4932
|
+
for (const [network, prefix] of [
|
|
4933
|
+
["0.0.0.0", 8],
|
|
4934
|
+
["10.0.0.0", 8],
|
|
4935
|
+
["100.64.0.0", 10],
|
|
4936
|
+
["127.0.0.0", 8],
|
|
4937
|
+
["169.254.0.0", 16],
|
|
4938
|
+
["172.16.0.0", 12],
|
|
4939
|
+
["192.0.0.0", 24],
|
|
4940
|
+
["192.168.0.0", 16],
|
|
4941
|
+
["198.18.0.0", 15],
|
|
4942
|
+
["224.0.0.0", 3]
|
|
4943
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv4");
|
|
4944
|
+
for (const [network, prefix] of [
|
|
4945
|
+
["::", 96],
|
|
4946
|
+
["64:ff9b:1::", 48],
|
|
4947
|
+
["2001::", 32],
|
|
4948
|
+
["fc00::", 7],
|
|
4949
|
+
["fe80::", 10],
|
|
4950
|
+
["fec0::", 10],
|
|
4951
|
+
["ff00::", 8]
|
|
4952
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv6");
|
|
4953
|
+
function ipv6Groups(address) {
|
|
4954
|
+
const quad = address.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
4955
|
+
const hex = quad ? `${quad[1]}${(+quad[2] << 8 | +quad[3]).toString(16)}:${(+quad[4] << 8 | +quad[5]).toString(16)}` : address;
|
|
4956
|
+
const [head, tail] = hex.split("::");
|
|
4957
|
+
const parse2 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
|
|
4958
|
+
const front = parse2(head);
|
|
4959
|
+
const back = parse2(tail);
|
|
4960
|
+
return tail === void 0 ? front : [...front, ...Array(8 - front.length - back.length).fill(0), ...back];
|
|
4961
|
+
}
|
|
4962
|
+
function embeddedIPv4(address) {
|
|
4963
|
+
const g = ipv6Groups(address);
|
|
4964
|
+
const quad = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
4965
|
+
const zero = (from, to) => g.slice(from, to).every((x) => x === 0);
|
|
4966
|
+
if (zero(0, 5) && g[5] === 65535) return quad(g[6], g[7]);
|
|
4967
|
+
if (g[0] === 100 && g[1] === 65435 && zero(2, 6)) return quad(g[6], g[7]);
|
|
4968
|
+
if (g[0] === 8194) return quad(g[1], g[2]);
|
|
4969
|
+
return null;
|
|
4970
|
+
}
|
|
4971
|
+
function isPrivateAddress(address) {
|
|
4972
|
+
const ip = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
4973
|
+
const family = isIP(ip);
|
|
4974
|
+
if (family === 4) return PRIVATE_RANGES.check(ip, "ipv4");
|
|
4975
|
+
if (family !== 6) return false;
|
|
4976
|
+
const v4 = embeddedIPv4(ip);
|
|
4977
|
+
return v4 !== null && PRIVATE_RANGES.check(v4, "ipv4") || PRIVATE_RANGES.check(ip, "ipv6");
|
|
4978
|
+
}
|
|
4924
4979
|
function isPrivateHost(hostname) {
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
4929
|
-
if (ipv4) {
|
|
4930
|
-
const [, a, b] = ipv4.map(Number);
|
|
4931
|
-
if (a === 10 || a === 127 || a === 0) return true;
|
|
4932
|
-
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
4933
|
-
if (a === 192 && b === 168) return true;
|
|
4934
|
-
if (a === 169 && b === 254) return true;
|
|
4935
|
-
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
4936
|
-
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
4937
|
-
}
|
|
4938
|
-
return false;
|
|
4980
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, "");
|
|
4981
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal")) return true;
|
|
4982
|
+
return isPrivateAddress(host);
|
|
4939
4983
|
}
|
|
4940
|
-
function
|
|
4984
|
+
async function resolvesToPrivateAddress(hostname) {
|
|
4941
4985
|
try {
|
|
4942
|
-
const
|
|
4943
|
-
return
|
|
4986
|
+
const addresses = await dnsLookupAll(hostname.replace(/^\[|\]$/g, ""), { all: true });
|
|
4987
|
+
return addresses.some((a) => isPrivateAddress(a.address));
|
|
4944
4988
|
} catch {
|
|
4945
4989
|
return false;
|
|
4946
4990
|
}
|
|
4947
4991
|
}
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
const
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4992
|
+
var guardedLookup = (hostname, options, callback) => {
|
|
4993
|
+
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
4994
|
+
if (err) return callback(err, "");
|
|
4995
|
+
const blocked = addresses.find((a) => isPrivateAddress(a.address));
|
|
4996
|
+
if (blocked) {
|
|
4997
|
+
return callback(Object.assign(new Error(`${hostname} resolves to private address ${blocked.address}`), { code: "EPRIVATEADDR" }), "");
|
|
4998
|
+
}
|
|
4999
|
+
if (options.all) return callback(null, addresses);
|
|
5000
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
5001
|
+
});
|
|
5002
|
+
};
|
|
5003
|
+
var USER_AGENT = "Prodlint-WebScanner/1.0";
|
|
5004
|
+
var MAX_REDIRECTS = 5;
|
|
5005
|
+
var MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
5006
|
+
function flattenHeaders(res) {
|
|
5007
|
+
const headers = {};
|
|
5008
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
5009
|
+
if (value !== void 0) headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
4958
5010
|
}
|
|
5011
|
+
return headers;
|
|
4959
5012
|
}
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
const
|
|
4968
|
-
|
|
4969
|
-
|
|
5013
|
+
function readBody(res) {
|
|
5014
|
+
const encoding = String(res.headers["content-encoding"] ?? "").trim().toLowerCase();
|
|
5015
|
+
const decoder = encoding === "gzip" || encoding === "x-gzip" ? createGunzip() : encoding === "deflate" ? createInflate() : encoding === "br" ? createBrotliDecompress() : null;
|
|
5016
|
+
const stream = decoder ? res.pipe(decoder) : res;
|
|
5017
|
+
return new Promise((resolve4, reject) => {
|
|
5018
|
+
const chunks = [];
|
|
5019
|
+
let size = 0;
|
|
5020
|
+
const done = () => resolve4(Buffer.concat(chunks).toString("utf8"));
|
|
5021
|
+
stream.on("data", (chunk) => {
|
|
5022
|
+
if (size >= MAX_BODY_BYTES) return;
|
|
5023
|
+
chunks.push(chunk.subarray(0, MAX_BODY_BYTES - size));
|
|
5024
|
+
size += chunk.length;
|
|
5025
|
+
if (size >= MAX_BODY_BYTES) {
|
|
5026
|
+
done();
|
|
5027
|
+
res.destroy();
|
|
5028
|
+
decoder?.destroy();
|
|
5029
|
+
}
|
|
4970
5030
|
});
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
}
|
|
5031
|
+
stream.on("end", done);
|
|
5032
|
+
stream.on("error", reject);
|
|
5033
|
+
res.on("error", reject);
|
|
5034
|
+
});
|
|
4975
5035
|
}
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
const
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
5036
|
+
function guardedGet(url, timeoutMs, lookup = guardedLookup) {
|
|
5037
|
+
return new Promise((resolve4) => {
|
|
5038
|
+
let current;
|
|
5039
|
+
const finish = (result) => {
|
|
5040
|
+
clearTimeout(timer);
|
|
5041
|
+
current?.destroy();
|
|
5042
|
+
resolve4(result);
|
|
5043
|
+
};
|
|
5044
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
5045
|
+
const attempt = (target, redirectsLeft) => {
|
|
5046
|
+
if (target.protocol !== "http:" && target.protocol !== "https:" || isPrivateHost(target.hostname)) return finish(null);
|
|
5047
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
5048
|
+
current = send(target, { agent: false, lookup, headers: { "User-Agent": USER_AGENT, "Accept-Encoding": "gzip, deflate, br" } }, (res) => {
|
|
5049
|
+
const status = res.statusCode ?? 0;
|
|
5050
|
+
const location = res.headers.location;
|
|
5051
|
+
if (status >= 300 && status < 400 && location) {
|
|
5052
|
+
res.resume();
|
|
5053
|
+
let next;
|
|
5054
|
+
try {
|
|
5055
|
+
next = new URL(location, target);
|
|
5056
|
+
} catch {
|
|
5057
|
+
return finish(null);
|
|
5058
|
+
}
|
|
5059
|
+
return redirectsLeft > 0 ? attempt(next, redirectsLeft - 1) : finish(null);
|
|
5060
|
+
}
|
|
5061
|
+
readBody(res).then((body) => finish({ status, headers: flattenHeaders(res), body }), () => finish(null));
|
|
5062
|
+
});
|
|
5063
|
+
current.on("error", () => finish(null));
|
|
5064
|
+
current.end();
|
|
5065
|
+
};
|
|
5066
|
+
try {
|
|
5067
|
+
attempt(new URL(url), MAX_REDIRECTS);
|
|
5068
|
+
} catch {
|
|
5069
|
+
finish(null);
|
|
5070
|
+
}
|
|
5071
|
+
});
|
|
5072
|
+
}
|
|
5073
|
+
async function fetchText(url, timeout = 8e3) {
|
|
5074
|
+
const r = await guardedGet(url, timeout);
|
|
5075
|
+
return r && r.status >= 200 && r.status < 300 ? r.body : null;
|
|
5076
|
+
}
|
|
5077
|
+
async function fetchPage(url, timeout = 15e3) {
|
|
5078
|
+
const start = Date.now();
|
|
5079
|
+
const r = await guardedGet(url, timeout);
|
|
5080
|
+
const ok = r !== null && r.status >= 200 && r.status < 300;
|
|
5081
|
+
return { headers: r?.headers ?? {}, html: ok ? r.body : null, loadTimeMs: ok ? Date.now() - start : null };
|
|
4989
5082
|
}
|
|
4990
5083
|
function normalizeUrl(input) {
|
|
4991
5084
|
let url = input.trim();
|
|
@@ -4997,7 +5090,7 @@ function normalizeUrl(input) {
|
|
|
4997
5090
|
}
|
|
4998
5091
|
async function runWebScan(targetUrl) {
|
|
4999
5092
|
const domain = getDomain(targetUrl);
|
|
5000
|
-
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
5093
|
+
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, page] = await Promise.all([
|
|
5001
5094
|
fetchText(`${targetUrl}/robots.txt`),
|
|
5002
5095
|
fetchText(`${targetUrl}/llms.txt`),
|
|
5003
5096
|
fetchText(`${targetUrl}/ai.txt`),
|
|
@@ -5005,10 +5098,9 @@ async function runWebScan(targetUrl) {
|
|
|
5005
5098
|
fetchText(`${targetUrl}/.well-known/agent-card.json`),
|
|
5006
5099
|
fetchText(`${targetUrl}/sitemap.xml`),
|
|
5007
5100
|
fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
|
|
5008
|
-
|
|
5009
|
-
fetchWithTiming(targetUrl)
|
|
5101
|
+
fetchPage(targetUrl)
|
|
5010
5102
|
]);
|
|
5011
|
-
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
5103
|
+
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, ...page };
|
|
5012
5104
|
const checks = allChecks.map((fn) => fn(ctx));
|
|
5013
5105
|
const totalPoints = checks.reduce((s, c) => s + c.points, 0);
|
|
5014
5106
|
const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
|
|
@@ -5151,7 +5243,7 @@ async function runWebScan2(url, opts) {
|
|
|
5151
5243
|
process.exit(2);
|
|
5152
5244
|
}
|
|
5153
5245
|
const hostname = new URL(normalizedUrl).hostname;
|
|
5154
|
-
if (isPrivateHost(hostname)) {
|
|
5246
|
+
if (isPrivateHost(hostname) || await resolvesToPrivateAddress(hostname)) {
|
|
5155
5247
|
console.error("Cannot scan private/internal hosts.");
|
|
5156
5248
|
process.exit(2);
|
|
5157
5249
|
}
|
package/dist/index.js
CHANGED
|
@@ -4740,6 +4740,14 @@ var STATUS_ICONS = {
|
|
|
4740
4740
|
info: pc.blue
|
|
4741
4741
|
};
|
|
4742
4742
|
|
|
4743
|
+
// src/web-scanner/index.ts
|
|
4744
|
+
import { lookup as dnsLookup } from "dns";
|
|
4745
|
+
import { lookup as dnsLookupAll } from "dns/promises";
|
|
4746
|
+
import { request as httpRequest } from "http";
|
|
4747
|
+
import { request as httpsRequest } from "https";
|
|
4748
|
+
import { BlockList, isIP } from "net";
|
|
4749
|
+
import { createBrotliDecompress, createGunzip, createInflate } from "zlib";
|
|
4750
|
+
|
|
4743
4751
|
// src/web-scanner/checks.ts
|
|
4744
4752
|
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4745
4753
|
return {
|
|
@@ -4879,76 +4887,153 @@ function getDomain(url) {
|
|
|
4879
4887
|
return url;
|
|
4880
4888
|
}
|
|
4881
4889
|
}
|
|
4882
|
-
var
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4890
|
+
var PRIVATE_RANGES = new BlockList();
|
|
4891
|
+
for (const [network, prefix] of [
|
|
4892
|
+
["0.0.0.0", 8],
|
|
4893
|
+
["10.0.0.0", 8],
|
|
4894
|
+
["100.64.0.0", 10],
|
|
4895
|
+
["127.0.0.0", 8],
|
|
4896
|
+
["169.254.0.0", 16],
|
|
4897
|
+
["172.16.0.0", 12],
|
|
4898
|
+
["192.0.0.0", 24],
|
|
4899
|
+
["192.168.0.0", 16],
|
|
4900
|
+
["198.18.0.0", 15],
|
|
4901
|
+
["224.0.0.0", 3]
|
|
4902
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv4");
|
|
4903
|
+
for (const [network, prefix] of [
|
|
4904
|
+
["::", 96],
|
|
4905
|
+
["64:ff9b:1::", 48],
|
|
4906
|
+
["2001::", 32],
|
|
4907
|
+
["fc00::", 7],
|
|
4908
|
+
["fe80::", 10],
|
|
4909
|
+
["fec0::", 10],
|
|
4910
|
+
["ff00::", 8]
|
|
4911
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv6");
|
|
4912
|
+
function ipv6Groups(address) {
|
|
4913
|
+
const quad = address.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
4914
|
+
const hex = quad ? `${quad[1]}${(+quad[2] << 8 | +quad[3]).toString(16)}:${(+quad[4] << 8 | +quad[5]).toString(16)}` : address;
|
|
4915
|
+
const [head, tail] = hex.split("::");
|
|
4916
|
+
const parse2 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
|
|
4917
|
+
const front = parse2(head);
|
|
4918
|
+
const back = parse2(tail);
|
|
4919
|
+
return tail === void 0 ? front : [...front, ...Array(8 - front.length - back.length).fill(0), ...back];
|
|
4898
4920
|
}
|
|
4899
|
-
function
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4921
|
+
function embeddedIPv4(address) {
|
|
4922
|
+
const g = ipv6Groups(address);
|
|
4923
|
+
const quad = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
4924
|
+
const zero = (from, to) => g.slice(from, to).every((x) => x === 0);
|
|
4925
|
+
if (zero(0, 5) && g[5] === 65535) return quad(g[6], g[7]);
|
|
4926
|
+
if (g[0] === 100 && g[1] === 65435 && zero(2, 6)) return quad(g[6], g[7]);
|
|
4927
|
+
if (g[0] === 8194) return quad(g[1], g[2]);
|
|
4928
|
+
return null;
|
|
4906
4929
|
}
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4930
|
+
function isPrivateAddress(address) {
|
|
4931
|
+
const ip = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
4932
|
+
const family = isIP(ip);
|
|
4933
|
+
if (family === 4) return PRIVATE_RANGES.check(ip, "ipv4");
|
|
4934
|
+
if (family !== 6) return false;
|
|
4935
|
+
const v4 = embeddedIPv4(ip);
|
|
4936
|
+
return v4 !== null && PRIVATE_RANGES.check(v4, "ipv4") || PRIVATE_RANGES.check(ip, "ipv6");
|
|
4937
|
+
}
|
|
4938
|
+
function isPrivateHost(hostname) {
|
|
4939
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, "");
|
|
4940
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal")) return true;
|
|
4941
|
+
return isPrivateAddress(host);
|
|
4942
|
+
}
|
|
4943
|
+
var guardedLookup = (hostname, options, callback) => {
|
|
4944
|
+
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
4945
|
+
if (err) return callback(err, "");
|
|
4946
|
+
const blocked = addresses.find((a) => isPrivateAddress(a.address));
|
|
4947
|
+
if (blocked) {
|
|
4948
|
+
return callback(Object.assign(new Error(`${hostname} resolves to private address ${blocked.address}`), { code: "EPRIVATEADDR" }), "");
|
|
4949
|
+
}
|
|
4950
|
+
if (options.all) return callback(null, addresses);
|
|
4951
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
4952
|
+
});
|
|
4953
|
+
};
|
|
4954
|
+
var USER_AGENT = "Prodlint-WebScanner/1.0";
|
|
4955
|
+
var MAX_REDIRECTS = 5;
|
|
4956
|
+
var MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
4957
|
+
function flattenHeaders(res) {
|
|
4958
|
+
const headers = {};
|
|
4959
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
4960
|
+
if (value !== void 0) headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
4917
4961
|
}
|
|
4962
|
+
return headers;
|
|
4918
4963
|
}
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
const
|
|
4927
|
-
|
|
4928
|
-
|
|
4964
|
+
function readBody(res) {
|
|
4965
|
+
const encoding = String(res.headers["content-encoding"] ?? "").trim().toLowerCase();
|
|
4966
|
+
const decoder = encoding === "gzip" || encoding === "x-gzip" ? createGunzip() : encoding === "deflate" ? createInflate() : encoding === "br" ? createBrotliDecompress() : null;
|
|
4967
|
+
const stream = decoder ? res.pipe(decoder) : res;
|
|
4968
|
+
return new Promise((resolve4, reject) => {
|
|
4969
|
+
const chunks = [];
|
|
4970
|
+
let size = 0;
|
|
4971
|
+
const done = () => resolve4(Buffer.concat(chunks).toString("utf8"));
|
|
4972
|
+
stream.on("data", (chunk) => {
|
|
4973
|
+
if (size >= MAX_BODY_BYTES) return;
|
|
4974
|
+
chunks.push(chunk.subarray(0, MAX_BODY_BYTES - size));
|
|
4975
|
+
size += chunk.length;
|
|
4976
|
+
if (size >= MAX_BODY_BYTES) {
|
|
4977
|
+
done();
|
|
4978
|
+
res.destroy();
|
|
4979
|
+
decoder?.destroy();
|
|
4980
|
+
}
|
|
4929
4981
|
});
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
}
|
|
4982
|
+
stream.on("end", done);
|
|
4983
|
+
stream.on("error", reject);
|
|
4984
|
+
res.on("error", reject);
|
|
4985
|
+
});
|
|
4934
4986
|
}
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
const
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4987
|
+
function guardedGet(url, timeoutMs, lookup = guardedLookup) {
|
|
4988
|
+
return new Promise((resolve4) => {
|
|
4989
|
+
let current;
|
|
4990
|
+
const finish = (result) => {
|
|
4991
|
+
clearTimeout(timer);
|
|
4992
|
+
current?.destroy();
|
|
4993
|
+
resolve4(result);
|
|
4994
|
+
};
|
|
4995
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
4996
|
+
const attempt = (target, redirectsLeft) => {
|
|
4997
|
+
if (target.protocol !== "http:" && target.protocol !== "https:" || isPrivateHost(target.hostname)) return finish(null);
|
|
4998
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
4999
|
+
current = send(target, { agent: false, lookup, headers: { "User-Agent": USER_AGENT, "Accept-Encoding": "gzip, deflate, br" } }, (res) => {
|
|
5000
|
+
const status = res.statusCode ?? 0;
|
|
5001
|
+
const location = res.headers.location;
|
|
5002
|
+
if (status >= 300 && status < 400 && location) {
|
|
5003
|
+
res.resume();
|
|
5004
|
+
let next;
|
|
5005
|
+
try {
|
|
5006
|
+
next = new URL(location, target);
|
|
5007
|
+
} catch {
|
|
5008
|
+
return finish(null);
|
|
5009
|
+
}
|
|
5010
|
+
return redirectsLeft > 0 ? attempt(next, redirectsLeft - 1) : finish(null);
|
|
5011
|
+
}
|
|
5012
|
+
readBody(res).then((body) => finish({ status, headers: flattenHeaders(res), body }), () => finish(null));
|
|
5013
|
+
});
|
|
5014
|
+
current.on("error", () => finish(null));
|
|
5015
|
+
current.end();
|
|
5016
|
+
};
|
|
5017
|
+
try {
|
|
5018
|
+
attempt(new URL(url), MAX_REDIRECTS);
|
|
5019
|
+
} catch {
|
|
5020
|
+
finish(null);
|
|
5021
|
+
}
|
|
5022
|
+
});
|
|
5023
|
+
}
|
|
5024
|
+
async function fetchText(url, timeout = 8e3) {
|
|
5025
|
+
const r = await guardedGet(url, timeout);
|
|
5026
|
+
return r && r.status >= 200 && r.status < 300 ? r.body : null;
|
|
5027
|
+
}
|
|
5028
|
+
async function fetchPage(url, timeout = 15e3) {
|
|
5029
|
+
const start = Date.now();
|
|
5030
|
+
const r = await guardedGet(url, timeout);
|
|
5031
|
+
const ok = r !== null && r.status >= 200 && r.status < 300;
|
|
5032
|
+
return { headers: r?.headers ?? {}, html: ok ? r.body : null, loadTimeMs: ok ? Date.now() - start : null };
|
|
4948
5033
|
}
|
|
4949
5034
|
async function runWebScan(targetUrl) {
|
|
4950
5035
|
const domain = getDomain(targetUrl);
|
|
4951
|
-
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
5036
|
+
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, page] = await Promise.all([
|
|
4952
5037
|
fetchText(`${targetUrl}/robots.txt`),
|
|
4953
5038
|
fetchText(`${targetUrl}/llms.txt`),
|
|
4954
5039
|
fetchText(`${targetUrl}/ai.txt`),
|
|
@@ -4956,10 +5041,9 @@ async function runWebScan(targetUrl) {
|
|
|
4956
5041
|
fetchText(`${targetUrl}/.well-known/agent-card.json`),
|
|
4957
5042
|
fetchText(`${targetUrl}/sitemap.xml`),
|
|
4958
5043
|
fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
|
|
4959
|
-
|
|
4960
|
-
fetchWithTiming(targetUrl)
|
|
5044
|
+
fetchPage(targetUrl)
|
|
4961
5045
|
]);
|
|
4962
|
-
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
5046
|
+
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, ...page };
|
|
4963
5047
|
const checks = allChecks.map((fn) => fn(ctx));
|
|
4964
5048
|
const totalPoints = checks.reduce((s, c) => s + c.points, 0);
|
|
4965
5049
|
const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
|
package/dist/mcp.js
CHANGED
|
@@ -4556,6 +4556,14 @@ async function scan(options) {
|
|
|
4556
4556
|
};
|
|
4557
4557
|
}
|
|
4558
4558
|
|
|
4559
|
+
// src/web-scanner/index.ts
|
|
4560
|
+
import { lookup as dnsLookup } from "dns";
|
|
4561
|
+
import { lookup as dnsLookupAll } from "dns/promises";
|
|
4562
|
+
import { request as httpRequest } from "http";
|
|
4563
|
+
import { request as httpsRequest } from "https";
|
|
4564
|
+
import { BlockList, isIP } from "net";
|
|
4565
|
+
import { createBrotliDecompress, createGunzip, createInflate } from "zlib";
|
|
4566
|
+
|
|
4559
4567
|
// src/web-scanner/checks.ts
|
|
4560
4568
|
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4561
4569
|
return {
|
|
@@ -4695,72 +4703,157 @@ function getDomain(url) {
|
|
|
4695
4703
|
return url;
|
|
4696
4704
|
}
|
|
4697
4705
|
}
|
|
4698
|
-
var
|
|
4706
|
+
var PRIVATE_RANGES = new BlockList();
|
|
4707
|
+
for (const [network, prefix] of [
|
|
4708
|
+
["0.0.0.0", 8],
|
|
4709
|
+
["10.0.0.0", 8],
|
|
4710
|
+
["100.64.0.0", 10],
|
|
4711
|
+
["127.0.0.0", 8],
|
|
4712
|
+
["169.254.0.0", 16],
|
|
4713
|
+
["172.16.0.0", 12],
|
|
4714
|
+
["192.0.0.0", 24],
|
|
4715
|
+
["192.168.0.0", 16],
|
|
4716
|
+
["198.18.0.0", 15],
|
|
4717
|
+
["224.0.0.0", 3]
|
|
4718
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv4");
|
|
4719
|
+
for (const [network, prefix] of [
|
|
4720
|
+
["::", 96],
|
|
4721
|
+
["64:ff9b:1::", 48],
|
|
4722
|
+
["2001::", 32],
|
|
4723
|
+
["fc00::", 7],
|
|
4724
|
+
["fe80::", 10],
|
|
4725
|
+
["fec0::", 10],
|
|
4726
|
+
["ff00::", 8]
|
|
4727
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv6");
|
|
4728
|
+
function ipv6Groups(address) {
|
|
4729
|
+
const quad = address.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
4730
|
+
const hex = quad ? `${quad[1]}${(+quad[2] << 8 | +quad[3]).toString(16)}:${(+quad[4] << 8 | +quad[5]).toString(16)}` : address;
|
|
4731
|
+
const [head, tail] = hex.split("::");
|
|
4732
|
+
const parse2 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
|
|
4733
|
+
const front = parse2(head);
|
|
4734
|
+
const back = parse2(tail);
|
|
4735
|
+
return tail === void 0 ? front : [...front, ...Array(8 - front.length - back.length).fill(0), ...back];
|
|
4736
|
+
}
|
|
4737
|
+
function embeddedIPv4(address) {
|
|
4738
|
+
const g = ipv6Groups(address);
|
|
4739
|
+
const quad = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
4740
|
+
const zero = (from, to) => g.slice(from, to).every((x) => x === 0);
|
|
4741
|
+
if (zero(0, 5) && g[5] === 65535) return quad(g[6], g[7]);
|
|
4742
|
+
if (g[0] === 100 && g[1] === 65435 && zero(2, 6)) return quad(g[6], g[7]);
|
|
4743
|
+
if (g[0] === 8194) return quad(g[1], g[2]);
|
|
4744
|
+
return null;
|
|
4745
|
+
}
|
|
4746
|
+
function isPrivateAddress(address) {
|
|
4747
|
+
const ip = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
4748
|
+
const family = isIP(ip);
|
|
4749
|
+
if (family === 4) return PRIVATE_RANGES.check(ip, "ipv4");
|
|
4750
|
+
if (family !== 6) return false;
|
|
4751
|
+
const v4 = embeddedIPv4(ip);
|
|
4752
|
+
return v4 !== null && PRIVATE_RANGES.check(v4, "ipv4") || PRIVATE_RANGES.check(ip, "ipv6");
|
|
4753
|
+
}
|
|
4699
4754
|
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;
|
|
4755
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, "");
|
|
4756
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal")) return true;
|
|
4757
|
+
return isPrivateAddress(host);
|
|
4714
4758
|
}
|
|
4715
|
-
function
|
|
4759
|
+
async function resolvesToPrivateAddress(hostname) {
|
|
4716
4760
|
try {
|
|
4717
|
-
const
|
|
4718
|
-
return
|
|
4761
|
+
const addresses = await dnsLookupAll(hostname.replace(/^\[|\]$/g, ""), { all: true });
|
|
4762
|
+
return addresses.some((a) => isPrivateAddress(a.address));
|
|
4719
4763
|
} catch {
|
|
4720
4764
|
return false;
|
|
4721
4765
|
}
|
|
4722
4766
|
}
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
const
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4767
|
+
var guardedLookup = (hostname, options, callback) => {
|
|
4768
|
+
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
4769
|
+
if (err) return callback(err, "");
|
|
4770
|
+
const blocked = addresses.find((a) => isPrivateAddress(a.address));
|
|
4771
|
+
if (blocked) {
|
|
4772
|
+
return callback(Object.assign(new Error(`${hostname} resolves to private address ${blocked.address}`), { code: "EPRIVATEADDR" }), "");
|
|
4773
|
+
}
|
|
4774
|
+
if (options.all) return callback(null, addresses);
|
|
4775
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
4776
|
+
});
|
|
4777
|
+
};
|
|
4778
|
+
var USER_AGENT = "Prodlint-WebScanner/1.0";
|
|
4779
|
+
var MAX_REDIRECTS = 5;
|
|
4780
|
+
var MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
4781
|
+
function flattenHeaders(res) {
|
|
4782
|
+
const headers = {};
|
|
4783
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
4784
|
+
if (value !== void 0) headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
4733
4785
|
}
|
|
4786
|
+
return headers;
|
|
4734
4787
|
}
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
const
|
|
4743
|
-
|
|
4744
|
-
|
|
4788
|
+
function readBody(res) {
|
|
4789
|
+
const encoding = String(res.headers["content-encoding"] ?? "").trim().toLowerCase();
|
|
4790
|
+
const decoder = encoding === "gzip" || encoding === "x-gzip" ? createGunzip() : encoding === "deflate" ? createInflate() : encoding === "br" ? createBrotliDecompress() : null;
|
|
4791
|
+
const stream = decoder ? res.pipe(decoder) : res;
|
|
4792
|
+
return new Promise((resolve5, reject) => {
|
|
4793
|
+
const chunks = [];
|
|
4794
|
+
let size = 0;
|
|
4795
|
+
const done = () => resolve5(Buffer.concat(chunks).toString("utf8"));
|
|
4796
|
+
stream.on("data", (chunk) => {
|
|
4797
|
+
if (size >= MAX_BODY_BYTES) return;
|
|
4798
|
+
chunks.push(chunk.subarray(0, MAX_BODY_BYTES - size));
|
|
4799
|
+
size += chunk.length;
|
|
4800
|
+
if (size >= MAX_BODY_BYTES) {
|
|
4801
|
+
done();
|
|
4802
|
+
res.destroy();
|
|
4803
|
+
decoder?.destroy();
|
|
4804
|
+
}
|
|
4745
4805
|
});
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
}
|
|
4806
|
+
stream.on("end", done);
|
|
4807
|
+
stream.on("error", reject);
|
|
4808
|
+
res.on("error", reject);
|
|
4809
|
+
});
|
|
4750
4810
|
}
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
const
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4811
|
+
function guardedGet(url, timeoutMs, lookup = guardedLookup) {
|
|
4812
|
+
return new Promise((resolve5) => {
|
|
4813
|
+
let current;
|
|
4814
|
+
const finish = (result) => {
|
|
4815
|
+
clearTimeout(timer);
|
|
4816
|
+
current?.destroy();
|
|
4817
|
+
resolve5(result);
|
|
4818
|
+
};
|
|
4819
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
4820
|
+
const attempt = (target, redirectsLeft) => {
|
|
4821
|
+
if (target.protocol !== "http:" && target.protocol !== "https:" || isPrivateHost(target.hostname)) return finish(null);
|
|
4822
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
4823
|
+
current = send(target, { agent: false, lookup, headers: { "User-Agent": USER_AGENT, "Accept-Encoding": "gzip, deflate, br" } }, (res) => {
|
|
4824
|
+
const status = res.statusCode ?? 0;
|
|
4825
|
+
const location = res.headers.location;
|
|
4826
|
+
if (status >= 300 && status < 400 && location) {
|
|
4827
|
+
res.resume();
|
|
4828
|
+
let next;
|
|
4829
|
+
try {
|
|
4830
|
+
next = new URL(location, target);
|
|
4831
|
+
} catch {
|
|
4832
|
+
return finish(null);
|
|
4833
|
+
}
|
|
4834
|
+
return redirectsLeft > 0 ? attempt(next, redirectsLeft - 1) : finish(null);
|
|
4835
|
+
}
|
|
4836
|
+
readBody(res).then((body) => finish({ status, headers: flattenHeaders(res), body }), () => finish(null));
|
|
4837
|
+
});
|
|
4838
|
+
current.on("error", () => finish(null));
|
|
4839
|
+
current.end();
|
|
4840
|
+
};
|
|
4841
|
+
try {
|
|
4842
|
+
attempt(new URL(url), MAX_REDIRECTS);
|
|
4843
|
+
} catch {
|
|
4844
|
+
finish(null);
|
|
4845
|
+
}
|
|
4846
|
+
});
|
|
4847
|
+
}
|
|
4848
|
+
async function fetchText(url, timeout = 8e3) {
|
|
4849
|
+
const r = await guardedGet(url, timeout);
|
|
4850
|
+
return r && r.status >= 200 && r.status < 300 ? r.body : null;
|
|
4851
|
+
}
|
|
4852
|
+
async function fetchPage(url, timeout = 15e3) {
|
|
4853
|
+
const start = Date.now();
|
|
4854
|
+
const r = await guardedGet(url, timeout);
|
|
4855
|
+
const ok = r !== null && r.status >= 200 && r.status < 300;
|
|
4856
|
+
return { headers: r?.headers ?? {}, html: ok ? r.body : null, loadTimeMs: ok ? Date.now() - start : null };
|
|
4764
4857
|
}
|
|
4765
4858
|
function normalizeUrl(input) {
|
|
4766
4859
|
let url = input.trim();
|
|
@@ -4772,7 +4865,7 @@ function normalizeUrl(input) {
|
|
|
4772
4865
|
}
|
|
4773
4866
|
async function runWebScan(targetUrl) {
|
|
4774
4867
|
const domain = getDomain(targetUrl);
|
|
4775
|
-
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
4868
|
+
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, page] = await Promise.all([
|
|
4776
4869
|
fetchText(`${targetUrl}/robots.txt`),
|
|
4777
4870
|
fetchText(`${targetUrl}/llms.txt`),
|
|
4778
4871
|
fetchText(`${targetUrl}/ai.txt`),
|
|
@@ -4780,10 +4873,9 @@ async function runWebScan(targetUrl) {
|
|
|
4780
4873
|
fetchText(`${targetUrl}/.well-known/agent-card.json`),
|
|
4781
4874
|
fetchText(`${targetUrl}/sitemap.xml`),
|
|
4782
4875
|
fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
|
|
4783
|
-
|
|
4784
|
-
fetchWithTiming(targetUrl)
|
|
4876
|
+
fetchPage(targetUrl)
|
|
4785
4877
|
]);
|
|
4786
|
-
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
4878
|
+
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, ...page };
|
|
4787
4879
|
const checks = allChecks.map((fn) => fn(ctx));
|
|
4788
4880
|
const totalPoints = checks.reduce((s, c) => s + c.points, 0);
|
|
4789
4881
|
const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
|
|
@@ -4885,7 +4977,7 @@ server.tool(
|
|
|
4885
4977
|
};
|
|
4886
4978
|
}
|
|
4887
4979
|
const hostname = new URL(normalizedUrl).hostname;
|
|
4888
|
-
if (isPrivateHost(hostname)) {
|
|
4980
|
+
if (isPrivateHost(hostname) || await resolvesToPrivateAddress(hostname)) {
|
|
4889
4981
|
return {
|
|
4890
4982
|
content: [{ type: "text", text: "Error: Cannot scan private or internal hosts." }],
|
|
4891
4983
|
isError: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prodlint",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
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"
|