prodlint 0.9.5 → 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/action.yml CHANGED
@@ -1,152 +1,106 @@
1
- name: 'Prodlint'
2
- description: 'Production readiness for vibe-coded apps — check your AI code before you ship'
3
- branding:
4
- icon: 'shield'
5
- color: 'green'
6
-
7
- inputs:
8
- path:
9
- description: 'Path to scan (default: current directory)'
10
- required: false
11
- default: '.'
12
- threshold:
13
- description: 'Minimum score to pass (0-100). Fails the check if score is below this.'
14
- required: false
15
- default: '0'
16
- ignore:
17
- description: 'Glob patterns to ignore (comma-separated)'
18
- required: false
19
- default: ''
20
- comment:
21
- description: 'Post a PR comment with results (true/false)'
22
- required: false
23
- default: 'true'
24
-
25
- outputs:
26
- score:
27
- description: 'Overall prodlint score (0-100)'
28
- value: ${{ steps.scan.outputs.score }}
29
- critical:
30
- description: 'Number of critical findings'
31
- value: ${{ steps.scan.outputs.critical }}
32
-
33
- runs:
34
- using: 'composite'
35
- steps:
36
- - name: Setup Node.js
37
- uses: actions/setup-node@v4
38
- with:
39
- node-version: '20'
40
-
41
- - name: Run prodlint
42
- id: scan
43
- shell: bash
44
- env:
45
- INPUT_PATH: ${{ inputs.path }}
46
- INPUT_IGNORE: ${{ inputs.ignore }}
47
- run: |
48
- CMD_ARGS=("$INPUT_PATH" "--json")
49
- if [ -n "$INPUT_IGNORE" ]; then
50
- IFS=',' read -ra PATTERNS <<< "$INPUT_IGNORE"
51
- for p in "${PATTERNS[@]}"; do
52
- trimmed=$(echo "$p" | xargs)
53
- CMD_ARGS+=("--ignore" "$trimmed")
54
- done
55
- fi
56
-
57
- OUTPUT=$(npx -y prodlint@latest "${CMD_ARGS[@]}" 2>&1) || true
58
-
59
- SCORE=$(echo "$OUTPUT" | node -e "
60
- const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
61
- console.log(Number(d.overallScore) || 0);
62
- " 2>/dev/null || echo "0")
63
- SCORE=$(echo "$SCORE" | tr -cd '0-9')
64
-
65
- CRITICAL=$(echo "$OUTPUT" | node -e "
66
- const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
67
- console.log(Number(d.summary.critical) || 0);
68
- " 2>/dev/null || echo "0")
69
- CRITICAL=$(echo "$CRITICAL" | tr -cd '0-9')
70
-
71
- echo "score=${SCORE:-0}" >> "$GITHUB_OUTPUT"
72
- echo "critical=${CRITICAL:-0}" >> "$GITHUB_OUTPUT"
73
- echo "$OUTPUT" > /tmp/prodlint-result.json
74
-
75
- - name: Generate comment body
76
- if: inputs.comment == 'true' && github.event_name == 'pull_request'
77
- id: comment
78
- 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
132
-
133
- - name: Post PR comment
134
- if: inputs.comment == 'true' && github.event_name == 'pull_request'
135
- uses: marocchino/sticky-pull-request-comment@v3
136
- with:
137
- header: prodlint
138
- path: /tmp/prodlint-comment.md
139
-
140
- - name: Check threshold
141
- if: inputs.threshold != '0'
142
- shell: bash
143
- env:
144
- SCAN_SCORE: ${{ steps.scan.outputs.score }}
145
- INPUT_THRESHOLD: ${{ inputs.threshold }}
146
- run: |
147
- SCORE="$SCAN_SCORE"
148
- THRESHOLD="$INPUT_THRESHOLD"
149
- if [ "$SCORE" -lt "$THRESHOLD" ]; then
150
- echo "::error::Prodlint score ($SCORE) is below threshold ($THRESHOLD)"
151
- exit 1
152
- fi
1
+ name: 'Prodlint'
2
+ description: 'Production readiness for vibe-coded apps — check your AI code before you ship'
3
+ branding:
4
+ icon: 'shield'
5
+ color: 'green'
6
+
7
+ inputs:
8
+ path:
9
+ description: 'Path to scan (default: current directory)'
10
+ required: false
11
+ default: '.'
12
+ threshold:
13
+ description: 'Minimum score to pass (0-100). Fails the check if score is below this.'
14
+ required: false
15
+ default: '0'
16
+ ignore:
17
+ description: 'Glob patterns to ignore (comma-separated)'
18
+ required: false
19
+ default: ''
20
+ comment:
21
+ description: 'Post a PR comment with results (true/false)'
22
+ required: false
23
+ default: 'true'
24
+
25
+ outputs:
26
+ score:
27
+ description: 'Overall prodlint score (0-100)'
28
+ value: ${{ steps.scan.outputs.score }}
29
+ critical:
30
+ description: 'Number of critical findings'
31
+ value: ${{ steps.scan.outputs.critical }}
32
+
33
+ runs:
34
+ using: 'composite'
35
+ steps:
36
+ - name: Setup Node.js
37
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
38
+ with:
39
+ node-version: '22'
40
+ # Automatic caching would key off the scanned repo's package.json; npx needs none.
41
+ package-manager-cache: false
42
+
43
+ - name: Run prodlint
44
+ id: scan
45
+ shell: bash
46
+ env:
47
+ INPUT_PATH: ${{ inputs.path }}
48
+ INPUT_IGNORE: ${{ inputs.ignore }}
49
+ run: |
50
+ CMD_ARGS=("$INPUT_PATH" "--json")
51
+ if [ -n "$INPUT_IGNORE" ]; then
52
+ IFS=',' read -ra PATTERNS <<< "$INPUT_IGNORE"
53
+ for p in "${PATTERNS[@]}"; do
54
+ trimmed=$(echo "$p" | xargs)
55
+ CMD_ARGS+=("--ignore" "$trimmed")
56
+ done
57
+ fi
58
+
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
62
+
63
+ SCORE=$(echo "$OUTPUT" | node -e "
64
+ const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
65
+ console.log(Number(d.overallScore) || 0);
66
+ " 2>/dev/null || echo "0")
67
+ SCORE=$(echo "$SCORE" | tr -cd '0-9')
68
+
69
+ CRITICAL=$(echo "$OUTPUT" | node -e "
70
+ const d = JSON.parse(require('fs').readFileSync(0, 'utf8'));
71
+ console.log(Number(d.summary.critical) || 0);
72
+ " 2>/dev/null || echo "0")
73
+ CRITICAL=$(echo "$CRITICAL" | tr -cd '0-9')
74
+
75
+ echo "score=${SCORE:-0}" >> "$GITHUB_OUTPUT"
76
+ echo "critical=${CRITICAL:-0}" >> "$GITHUB_OUTPUT"
77
+ echo "$OUTPUT" > "$RUNNER_TEMP/prodlint-result.json"
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.
81
+ - name: Generate comment body
82
+ if: inputs.comment == 'true' && github.event_name == 'pull_request'
83
+ id: comment
84
+ shell: bash
85
+ run: node "$GITHUB_ACTION_PATH/scripts/action-comment.cjs" "$RUNNER_TEMP/prodlint-result.json" "$RUNNER_TEMP/prodlint-comment.md"
86
+
87
+ - name: Post PR comment
88
+ if: inputs.comment == 'true' && github.event_name == 'pull_request'
89
+ uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
90
+ with:
91
+ header: prodlint
92
+ path: ${{ runner.temp }}/prodlint-comment.md
93
+
94
+ - name: Check threshold
95
+ if: inputs.threshold != '0'
96
+ shell: bash
97
+ env:
98
+ SCAN_SCORE: ${{ steps.scan.outputs.score }}
99
+ INPUT_THRESHOLD: ${{ inputs.threshold }}
100
+ run: |
101
+ SCORE="$SCAN_SCORE"
102
+ THRESHOLD="$INPUT_THRESHOLD"
103
+ if [ "$SCORE" -lt "$THRESHOLD" ]; then
104
+ echo "::error::Prodlint score ($SCORE) is below threshold ($THRESHOLD)"
105
+ exit 1
106
+ fi
package/dist/cli.js CHANGED
@@ -522,6 +522,33 @@ async function collectWorkspaceDependencies(root, patterns) {
522
522
  }
523
523
  return deps;
524
524
  }
525
+ async function collectNestedPackageDependencies(root) {
526
+ const deps = /* @__PURE__ */ new Set();
527
+ try {
528
+ const pkgFiles = await fg(["**/package.json"], {
529
+ cwd: root,
530
+ absolute: false,
531
+ ignore: ["**/node_modules/**", "package.json"]
532
+ });
533
+ for (const pkgFile of pkgFiles) {
534
+ try {
535
+ const raw = await readFile(resolve(root, pkgFile), "utf-8");
536
+ const pkg = JSON.parse(raw);
537
+ if (pkg.name) deps.add(pkg.name);
538
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
539
+ if (pkg[key] && typeof pkg[key] === "object") {
540
+ for (const dep of Object.keys(pkg[key])) {
541
+ deps.add(dep);
542
+ }
543
+ }
544
+ }
545
+ } catch {
546
+ }
547
+ }
548
+ } catch {
549
+ }
550
+ return deps;
551
+ }
525
552
  async function buildProjectContext(root, files) {
526
553
  let packageJson = null;
527
554
  let declaredDependencies = /* @__PURE__ */ new Set();
@@ -549,6 +576,10 @@ async function buildProjectContext(root, files) {
549
576
  declaredDependencies.add(dep);
550
577
  }
551
578
  }
579
+ const nestedDeps = await collectNestedPackageDependencies(root);
580
+ for (const dep of nestedDeps) {
581
+ declaredDependencies.add(dep);
582
+ }
552
583
  for (const dep of declaredDependencies) {
553
584
  const framework = DEPENDENCY_TO_FRAMEWORK[dep];
554
585
  if (framework) {
@@ -4750,6 +4781,14 @@ function reportWebPretty(result) {
4750
4781
  return lines.join("\n");
4751
4782
  }
4752
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
+
4753
4792
  // src/web-scanner/checks.ts
4754
4793
  function make(id, name, description, maxPoints, severity, status, details) {
4755
4794
  return {
@@ -4889,72 +4928,157 @@ function getDomain(url) {
4889
4928
  return url;
4890
4929
  }
4891
4930
  }
4892
- 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
+ }
4893
4979
  function isPrivateHost(hostname) {
4894
- if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4895
- const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4896
- if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4897
- const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4898
- if (ipv4) {
4899
- const [, a, b] = ipv4.map(Number);
4900
- if (a === 10 || a === 127 || a === 0) return true;
4901
- if (a === 172 && b >= 16 && b <= 31) return true;
4902
- if (a === 192 && b === 168) return true;
4903
- if (a === 169 && b === 254) return true;
4904
- if (a === 100 && b >= 64 && b <= 127) return true;
4905
- if (a === 198 && (b === 18 || b === 19)) return true;
4906
- }
4907
- 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);
4908
4983
  }
4909
- function validateRedirectUrl(responseUrl) {
4984
+ async function resolvesToPrivateAddress(hostname) {
4910
4985
  try {
4911
- const parsed = new URL(responseUrl);
4912
- return !isPrivateHost(parsed.hostname);
4986
+ const addresses = await dnsLookupAll(hostname.replace(/^\[|\]$/g, ""), { all: true });
4987
+ return addresses.some((a) => isPrivateAddress(a.address));
4913
4988
  } catch {
4914
4989
  return false;
4915
4990
  }
4916
4991
  }
4917
- async function fetchText(url, timeout = 8e3) {
4918
- try {
4919
- const c = new AbortController();
4920
- const t = setTimeout(() => c.abort(), timeout);
4921
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4922
- clearTimeout(t);
4923
- if (r.url && !validateRedirectUrl(r.url)) return null;
4924
- return r.ok ? await r.text() : null;
4925
- } catch {
4926
- 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;
4927
5010
  }
5011
+ return headers;
4928
5012
  }
4929
- async function fetchHeaders(url, timeout = 8e3) {
4930
- try {
4931
- const c = new AbortController();
4932
- const t = setTimeout(() => c.abort(), timeout);
4933
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4934
- clearTimeout(t);
4935
- if (r.url && !validateRedirectUrl(r.url)) return {};
4936
- const h = {};
4937
- r.headers.forEach((v, k) => {
4938
- 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
+ }
4939
5030
  });
4940
- return h;
4941
- } catch {
4942
- return {};
4943
- }
5031
+ stream.on("end", done);
5032
+ stream.on("error", reject);
5033
+ res.on("error", reject);
5034
+ });
4944
5035
  }
4945
- async function fetchWithTiming(url, timeout = 15e3) {
4946
- try {
4947
- const c = new AbortController();
4948
- const t = setTimeout(() => c.abort(), timeout);
4949
- const start = Date.now();
4950
- const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4951
- const html = await r.text();
4952
- clearTimeout(t);
4953
- if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4954
- return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4955
- } catch {
4956
- return { html: null, loadTimeMs: null };
4957
- }
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 };
4958
5082
  }
4959
5083
  function normalizeUrl(input) {
4960
5084
  let url = input.trim();
@@ -4966,7 +5090,7 @@ function normalizeUrl(input) {
4966
5090
  }
4967
5091
  async function runWebScan(targetUrl) {
4968
5092
  const domain = getDomain(targetUrl);
4969
- 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([
4970
5094
  fetchText(`${targetUrl}/robots.txt`),
4971
5095
  fetchText(`${targetUrl}/llms.txt`),
4972
5096
  fetchText(`${targetUrl}/ai.txt`),
@@ -4974,10 +5098,9 @@ async function runWebScan(targetUrl) {
4974
5098
  fetchText(`${targetUrl}/.well-known/agent-card.json`),
4975
5099
  fetchText(`${targetUrl}/sitemap.xml`),
4976
5100
  fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
4977
- fetchHeaders(targetUrl),
4978
- fetchWithTiming(targetUrl)
5101
+ fetchPage(targetUrl)
4979
5102
  ]);
4980
- 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 };
4981
5104
  const checks = allChecks.map((fn) => fn(ctx));
4982
5105
  const totalPoints = checks.reduce((s, c) => s + c.points, 0);
4983
5106
  const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
@@ -5120,7 +5243,7 @@ async function runWebScan2(url, opts) {
5120
5243
  process.exit(2);
5121
5244
  }
5122
5245
  const hostname = new URL(normalizedUrl).hostname;
5123
- if (isPrivateHost(hostname)) {
5246
+ if (isPrivateHost(hostname) || await resolvesToPrivateAddress(hostname)) {
5124
5247
  console.error("Cannot scan private/internal hosts.");
5125
5248
  process.exit(2);
5126
5249
  }