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 CHANGED
@@ -13,7 +13,7 @@ npx prodlint
13
13
  ```
14
14
 
15
15
  ```
16
- prodlint v0.10.0
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@v4
37
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
38
38
  with:
39
- node-version: '20'
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
- OUTPUT=$(npx -y prodlint@latest "${CMD_ARGS[@]}" 2>&1) || true
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" > /tmp/prodlint-result.json
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
- env:
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: /tmp/prodlint-comment.md
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 PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
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
- if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4926
- const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4927
- if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
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 validateRedirectUrl(responseUrl) {
4984
+ async function resolvesToPrivateAddress(hostname) {
4941
4985
  try {
4942
- const parsed = new URL(responseUrl);
4943
- return !isPrivateHost(parsed.hostname);
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
- async function fetchText(url, timeout = 8e3) {
4949
- try {
4950
- const c = new AbortController();
4951
- const t = setTimeout(() => c.abort(), timeout);
4952
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4953
- clearTimeout(t);
4954
- if (r.url && !validateRedirectUrl(r.url)) return null;
4955
- return r.ok ? await r.text() : null;
4956
- } catch {
4957
- return null;
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
- async function fetchHeaders(url, timeout = 8e3) {
4961
- try {
4962
- const c = new AbortController();
4963
- const t = setTimeout(() => c.abort(), timeout);
4964
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4965
- clearTimeout(t);
4966
- if (r.url && !validateRedirectUrl(r.url)) return {};
4967
- const h = {};
4968
- r.headers.forEach((v, k) => {
4969
- h[k.toLowerCase()] = v;
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
- return h;
4972
- } catch {
4973
- return {};
4974
- }
5031
+ stream.on("end", done);
5032
+ stream.on("error", reject);
5033
+ res.on("error", reject);
5034
+ });
4975
5035
  }
4976
- async function fetchWithTiming(url, timeout = 15e3) {
4977
- try {
4978
- const c = new AbortController();
4979
- const t = setTimeout(() => c.abort(), timeout);
4980
- const start = Date.now();
4981
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4982
- const html = await r.text();
4983
- clearTimeout(t);
4984
- if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4985
- return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4986
- } catch {
4987
- return { html: null, loadTimeMs: null };
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, headers, pageData] = await Promise.all([
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
- fetchHeaders(targetUrl),
5009
- fetchWithTiming(targetUrl)
5101
+ fetchPage(targetUrl)
5010
5102
  ]);
5011
- const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
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 PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
4883
- function isPrivateHost(hostname) {
4884
- if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4885
- const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4886
- if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4887
- const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4888
- if (ipv4) {
4889
- const [, a, b] = ipv4.map(Number);
4890
- if (a === 10 || a === 127 || a === 0) return true;
4891
- if (a === 172 && b >= 16 && b <= 31) return true;
4892
- if (a === 192 && b === 168) return true;
4893
- if (a === 169 && b === 254) return true;
4894
- if (a === 100 && b >= 64 && b <= 127) return true;
4895
- if (a === 198 && (b === 18 || b === 19)) return true;
4896
- }
4897
- return false;
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 validateRedirectUrl(responseUrl) {
4900
- try {
4901
- const parsed = new URL(responseUrl);
4902
- return !isPrivateHost(parsed.hostname);
4903
- } catch {
4904
- return false;
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
- async function fetchText(url, timeout = 8e3) {
4908
- try {
4909
- const c = new AbortController();
4910
- const t = setTimeout(() => c.abort(), timeout);
4911
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4912
- clearTimeout(t);
4913
- if (r.url && !validateRedirectUrl(r.url)) return null;
4914
- return r.ok ? await r.text() : null;
4915
- } catch {
4916
- return null;
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
- async function fetchHeaders(url, timeout = 8e3) {
4920
- try {
4921
- const c = new AbortController();
4922
- const t = setTimeout(() => c.abort(), timeout);
4923
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4924
- clearTimeout(t);
4925
- if (r.url && !validateRedirectUrl(r.url)) return {};
4926
- const h = {};
4927
- r.headers.forEach((v, k) => {
4928
- h[k.toLowerCase()] = v;
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
- return h;
4931
- } catch {
4932
- return {};
4933
- }
4982
+ stream.on("end", done);
4983
+ stream.on("error", reject);
4984
+ res.on("error", reject);
4985
+ });
4934
4986
  }
4935
- async function fetchWithTiming(url, timeout = 15e3) {
4936
- try {
4937
- const c = new AbortController();
4938
- const t = setTimeout(() => c.abort(), timeout);
4939
- const start = Date.now();
4940
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4941
- const html = await r.text();
4942
- clearTimeout(t);
4943
- if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4944
- return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4945
- } catch {
4946
- return { html: null, loadTimeMs: null };
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, headers, pageData] = await Promise.all([
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
- fetchHeaders(targetUrl),
4960
- fetchWithTiming(targetUrl)
5044
+ fetchPage(targetUrl)
4961
5045
  ]);
4962
- const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
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 PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
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
- if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4701
- const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4702
- if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
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 validateRedirectUrl(responseUrl) {
4759
+ async function resolvesToPrivateAddress(hostname) {
4716
4760
  try {
4717
- const parsed = new URL(responseUrl);
4718
- return !isPrivateHost(parsed.hostname);
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
- async function fetchText(url, timeout = 8e3) {
4724
- try {
4725
- const c = new AbortController();
4726
- const t = setTimeout(() => c.abort(), timeout);
4727
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4728
- clearTimeout(t);
4729
- if (r.url && !validateRedirectUrl(r.url)) return null;
4730
- return r.ok ? await r.text() : null;
4731
- } catch {
4732
- return null;
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
- async function fetchHeaders(url, timeout = 8e3) {
4736
- try {
4737
- const c = new AbortController();
4738
- const t = setTimeout(() => c.abort(), timeout);
4739
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4740
- clearTimeout(t);
4741
- if (r.url && !validateRedirectUrl(r.url)) return {};
4742
- const h = {};
4743
- r.headers.forEach((v, k) => {
4744
- h[k.toLowerCase()] = v;
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
- return h;
4747
- } catch {
4748
- return {};
4749
- }
4806
+ stream.on("end", done);
4807
+ stream.on("error", reject);
4808
+ res.on("error", reject);
4809
+ });
4750
4810
  }
4751
- async function fetchWithTiming(url, timeout = 15e3) {
4752
- try {
4753
- const c = new AbortController();
4754
- const t = setTimeout(() => c.abort(), timeout);
4755
- const start = Date.now();
4756
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4757
- const html = await r.text();
4758
- clearTimeout(t);
4759
- if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4760
- return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4761
- } catch {
4762
- return { html: null, loadTimeMs: null };
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, headers, pageData] = await Promise.all([
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
- fetchHeaders(targetUrl),
4784
- fetchWithTiming(targetUrl)
4876
+ fetchPage(targetUrl)
4785
4877
  ]);
4786
- const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
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.0",
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": "^3.2.7"
61
+ "vitest": "^5.0.5"
62
62
  },
63
63
  "overrides": {
64
64
  "esbuild": "^0.28.1"