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/LICENSE +21 -21
- package/README.md +334 -329
- package/action.yml +106 -152
- package/dist/cli.js +183 -60
- package/dist/index.js +179 -64
- package/dist/mcp.js +183 -60
- package/package.json +97 -97
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@
|
|
38
|
-
with:
|
|
39
|
-
node-version: '
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
SCORE=$(echo "$
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
CRITICAL=$(echo "$
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
echo "
|
|
73
|
-
echo "$
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
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
|
|
4984
|
+
async function resolvesToPrivateAddress(hostname) {
|
|
4910
4985
|
try {
|
|
4911
|
-
const
|
|
4912
|
-
return
|
|
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
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
const
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
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
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
const
|
|
4937
|
-
|
|
4938
|
-
|
|
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
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
}
|
|
5031
|
+
stream.on("end", done);
|
|
5032
|
+
stream.on("error", reject);
|
|
5033
|
+
res.on("error", reject);
|
|
5034
|
+
});
|
|
4944
5035
|
}
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
const
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
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,
|
|
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
|
-
|
|
4978
|
-
fetchWithTiming(targetUrl)
|
|
5101
|
+
fetchPage(targetUrl)
|
|
4979
5102
|
]);
|
|
4980
|
-
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 };
|
|
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
|
}
|