prodlint 0.10.0 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/action.yml +13 -59
- package/dist/cli.js +236 -150
- package/dist/index.js +232 -154
- package/dist/mcp.js +236 -150
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ npx prodlint
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
```
|
|
16
|
-
prodlint v0.10.
|
|
16
|
+
prodlint v0.10.3
|
|
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@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.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.3 "${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
|
@@ -2487,15 +2487,16 @@ var deadExportsRule = {
|
|
|
2487
2487
|
};
|
|
2488
2488
|
|
|
2489
2489
|
// src/rules/shallow-catch.ts
|
|
2490
|
-
function
|
|
2491
|
-
if (
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2490
|
+
function reportsError(value, param) {
|
|
2491
|
+
if (/error/i.test(value) || /\berr|Err|status|Response|json/.test(value)) return true;
|
|
2492
|
+
return param !== void 0 && new RegExp(`(?<![\\w$])${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(value);
|
|
2493
|
+
}
|
|
2494
|
+
function scoreCatchBody({ text: body, empty, returned, param }) {
|
|
2495
|
+
if (empty) return { score: 0, label: "empty catch" };
|
|
2495
2496
|
let score = 0;
|
|
2496
2497
|
if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
|
|
2497
2498
|
if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
|
|
2498
|
-
if (/\bthrow\b/.test(body) ||
|
|
2499
|
+
if (/\bthrow\b/.test(body) || returned.some((value) => reportsError(value, param)) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body) || // Toast notifications (react-toastify, sonner, shadcn)
|
|
2499
2500
|
/\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
|
|
2500
2501
|
/\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
|
|
2501
2502
|
/\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
|
|
@@ -2505,13 +2506,56 @@ function scoreCatchBody(bodyLines) {
|
|
|
2505
2506
|
score = 3;
|
|
2506
2507
|
}
|
|
2507
2508
|
const labels = {
|
|
2508
|
-
0: "
|
|
2509
|
+
0: "catch swallows the error",
|
|
2509
2510
|
1: "catch only logs (no recovery or propagation)",
|
|
2510
2511
|
2: "catch logs error but does not propagate or recover",
|
|
2511
2512
|
3: "catch handles error properly"
|
|
2512
2513
|
};
|
|
2513
2514
|
return { score, label: labels[score] };
|
|
2514
2515
|
}
|
|
2516
|
+
function catchFinding(file, catchLine, body) {
|
|
2517
|
+
const { score, label } = scoreCatchBody(body);
|
|
2518
|
+
if (score > 1) return null;
|
|
2519
|
+
return {
|
|
2520
|
+
ruleId: "shallow-catch",
|
|
2521
|
+
file: file.relativePath,
|
|
2522
|
+
line: catchLine + 1,
|
|
2523
|
+
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2524
|
+
message: body.empty ? "Empty catch block \u2014 errors are silently swallowed" : score === 0 ? "Catch block swallows the error \u2014 it isn't logged, rethrown or returned" : `Decorative error handler: ${label}`,
|
|
2525
|
+
severity: score === 0 ? "warning" : "info",
|
|
2526
|
+
category: "reliability",
|
|
2527
|
+
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
|
|
2531
|
+
function blankOut(source, strings) {
|
|
2532
|
+
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
2533
|
+
return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
|
|
2534
|
+
}
|
|
2535
|
+
function closingBrace(code, open) {
|
|
2536
|
+
let depth = 0;
|
|
2537
|
+
for (let i = open; i < code.length; i++) {
|
|
2538
|
+
if (code[i] === "{") depth++;
|
|
2539
|
+
else if (code[i] === "}" && --depth === 0) return i;
|
|
2540
|
+
}
|
|
2541
|
+
return -1;
|
|
2542
|
+
}
|
|
2543
|
+
function returnedValues(code, text) {
|
|
2544
|
+
const values = [];
|
|
2545
|
+
for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
|
|
2546
|
+
const start = m.index + "return".length;
|
|
2547
|
+
let end = start;
|
|
2548
|
+
for (let depth = 0; end < code.length; end++) {
|
|
2549
|
+
const ch = code[end];
|
|
2550
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
2551
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
2552
|
+
if (depth-- === 0) break;
|
|
2553
|
+
} else if (depth === 0 && (ch === ";" || ch === "\n")) break;
|
|
2554
|
+
}
|
|
2555
|
+
values.push(text.slice(start, end));
|
|
2556
|
+
}
|
|
2557
|
+
return values;
|
|
2558
|
+
}
|
|
2515
2559
|
var shallowCatchRule = {
|
|
2516
2560
|
id: "shallow-catch",
|
|
2517
2561
|
name: "Shallow Error Handler",
|
|
@@ -2528,95 +2572,45 @@ var shallowCatchRule = {
|
|
|
2528
2572
|
walkAST(file.ast.program, (node) => {
|
|
2529
2573
|
if (node.type !== "CatchClause") return;
|
|
2530
2574
|
if (!node.loc) return;
|
|
2531
|
-
const
|
|
2575
|
+
const catchLine2 = node.loc.start.line - 1;
|
|
2532
2576
|
const body = node.body;
|
|
2533
|
-
if (
|
|
2534
|
-
const
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2548
|
-
});
|
|
2549
|
-
}
|
|
2577
|
+
if (body?.start == null || body.end == null) return;
|
|
2578
|
+
const returned = [];
|
|
2579
|
+
walkAST(body, (n) => {
|
|
2580
|
+
if (n.type === "ReturnStatement" && n.argument?.start != null && n.argument.end != null) {
|
|
2581
|
+
returned.push(file.content.slice(n.argument.start, n.argument.end));
|
|
2582
|
+
}
|
|
2583
|
+
});
|
|
2584
|
+
const finding = catchFinding(file, catchLine2, {
|
|
2585
|
+
text: file.content.slice(body.start + 1, body.end - 1),
|
|
2586
|
+
empty: body.body.length === 0,
|
|
2587
|
+
returned,
|
|
2588
|
+
param: node.param?.type === "Identifier" ? node.param.name : void 0
|
|
2589
|
+
});
|
|
2590
|
+
if (finding) findings.push(finding);
|
|
2550
2591
|
});
|
|
2551
2592
|
return findings;
|
|
2552
2593
|
} catch {
|
|
2553
2594
|
}
|
|
2554
2595
|
}
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
const line = file.lines[j];
|
|
2574
|
-
const startPos = j === braceStart ? line.indexOf("{") : 0;
|
|
2575
|
-
for (let k = startPos; k < line.length; k++) {
|
|
2576
|
-
const ch = line[k];
|
|
2577
|
-
const prev = k > 0 ? line[k - 1] : "";
|
|
2578
|
-
const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
|
|
2579
|
-
if (!escaped) {
|
|
2580
|
-
if (ch === "'" && !inDouble && !inTemplate) {
|
|
2581
|
-
inSingle = !inSingle;
|
|
2582
|
-
continue;
|
|
2583
|
-
}
|
|
2584
|
-
if (ch === '"' && !inSingle && !inTemplate) {
|
|
2585
|
-
inDouble = !inDouble;
|
|
2586
|
-
continue;
|
|
2587
|
-
}
|
|
2588
|
-
if (ch === "`" && !inSingle && !inDouble) {
|
|
2589
|
-
inTemplate = !inTemplate;
|
|
2590
|
-
continue;
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2593
|
-
if (inSingle || inDouble || inTemplate) continue;
|
|
2594
|
-
if (ch === "{") depth++;
|
|
2595
|
-
if (ch === "}") {
|
|
2596
|
-
depth--;
|
|
2597
|
-
if (depth === 0) {
|
|
2598
|
-
bodyEnd = j;
|
|
2599
|
-
break;
|
|
2600
|
-
}
|
|
2601
|
-
}
|
|
2602
|
-
}
|
|
2603
|
-
if (depth === 0) break;
|
|
2604
|
-
}
|
|
2605
|
-
const bodyLines = file.lines.slice(braceStart + 1, bodyEnd);
|
|
2606
|
-
const { score, label } = scoreCatchBody(bodyLines);
|
|
2607
|
-
if (score <= 1) {
|
|
2608
|
-
findings.push({
|
|
2609
|
-
ruleId: "shallow-catch",
|
|
2610
|
-
file: file.relativePath,
|
|
2611
|
-
line: i + 1,
|
|
2612
|
-
column: file.lines[i].indexOf("catch") + 1,
|
|
2613
|
-
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2614
|
-
severity: score === 0 ? "warning" : "info",
|
|
2615
|
-
category: "reliability",
|
|
2616
|
-
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2617
|
-
});
|
|
2618
|
-
}
|
|
2619
|
-
i = bodyEnd;
|
|
2596
|
+
const source = file.lines.join("\n");
|
|
2597
|
+
const code = blankOut(source, true);
|
|
2598
|
+
const noComments = blankOut(source, false);
|
|
2599
|
+
const catchRe = /(?<![\w$.])catch\s*(?:\(([^)]*)\)\s*)?\{/g;
|
|
2600
|
+
let catchLine = 0;
|
|
2601
|
+
let scanned = 0;
|
|
2602
|
+
for (let m = catchRe.exec(code); m; m = catchRe.exec(code)) {
|
|
2603
|
+
for (; scanned < m.index; scanned++) if (code[scanned] === "\n") catchLine++;
|
|
2604
|
+
const open = m.index + m[0].length - 1;
|
|
2605
|
+
const close = closingBrace(code, open);
|
|
2606
|
+
if (close === -1) continue;
|
|
2607
|
+
const finding = catchFinding(file, catchLine, {
|
|
2608
|
+
text: source.slice(open + 1, close),
|
|
2609
|
+
empty: code.slice(open + 1, close).trim() === "",
|
|
2610
|
+
returned: returnedValues(code.slice(open + 1, close), noComments.slice(open + 1, close)),
|
|
2611
|
+
param: m[1]?.match(/^\s*([A-Za-z_$][\w$]*)/)?.[1]
|
|
2612
|
+
});
|
|
2613
|
+
if (finding) findings.push(finding);
|
|
2620
2614
|
}
|
|
2621
2615
|
return findings;
|
|
2622
2616
|
}
|
|
@@ -4781,6 +4775,14 @@ function reportWebPretty(result) {
|
|
|
4781
4775
|
return lines.join("\n");
|
|
4782
4776
|
}
|
|
4783
4777
|
|
|
4778
|
+
// src/web-scanner/index.ts
|
|
4779
|
+
import { lookup as dnsLookup } from "dns";
|
|
4780
|
+
import { lookup as dnsLookupAll } from "dns/promises";
|
|
4781
|
+
import { request as httpRequest } from "http";
|
|
4782
|
+
import { request as httpsRequest } from "https";
|
|
4783
|
+
import { BlockList, isIP } from "net";
|
|
4784
|
+
import { createBrotliDecompress, createGunzip, createInflate } from "zlib";
|
|
4785
|
+
|
|
4784
4786
|
// src/web-scanner/checks.ts
|
|
4785
4787
|
function make(id, name, description, maxPoints, severity, status, details) {
|
|
4786
4788
|
return {
|
|
@@ -4920,72 +4922,157 @@ function getDomain(url) {
|
|
|
4920
4922
|
return url;
|
|
4921
4923
|
}
|
|
4922
4924
|
}
|
|
4923
|
-
var
|
|
4925
|
+
var PRIVATE_RANGES = new BlockList();
|
|
4926
|
+
for (const [network, prefix] of [
|
|
4927
|
+
["0.0.0.0", 8],
|
|
4928
|
+
["10.0.0.0", 8],
|
|
4929
|
+
["100.64.0.0", 10],
|
|
4930
|
+
["127.0.0.0", 8],
|
|
4931
|
+
["169.254.0.0", 16],
|
|
4932
|
+
["172.16.0.0", 12],
|
|
4933
|
+
["192.0.0.0", 24],
|
|
4934
|
+
["192.168.0.0", 16],
|
|
4935
|
+
["198.18.0.0", 15],
|
|
4936
|
+
["224.0.0.0", 3]
|
|
4937
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv4");
|
|
4938
|
+
for (const [network, prefix] of [
|
|
4939
|
+
["::", 96],
|
|
4940
|
+
["64:ff9b:1::", 48],
|
|
4941
|
+
["2001::", 32],
|
|
4942
|
+
["fc00::", 7],
|
|
4943
|
+
["fe80::", 10],
|
|
4944
|
+
["fec0::", 10],
|
|
4945
|
+
["ff00::", 8]
|
|
4946
|
+
]) PRIVATE_RANGES.addSubnet(network, prefix, "ipv6");
|
|
4947
|
+
function ipv6Groups(address) {
|
|
4948
|
+
const quad = address.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
4949
|
+
const hex = quad ? `${quad[1]}${(+quad[2] << 8 | +quad[3]).toString(16)}:${(+quad[4] << 8 | +quad[5]).toString(16)}` : address;
|
|
4950
|
+
const [head, tail] = hex.split("::");
|
|
4951
|
+
const parse2 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
|
|
4952
|
+
const front = parse2(head);
|
|
4953
|
+
const back = parse2(tail);
|
|
4954
|
+
return tail === void 0 ? front : [...front, ...Array(8 - front.length - back.length).fill(0), ...back];
|
|
4955
|
+
}
|
|
4956
|
+
function embeddedIPv4(address) {
|
|
4957
|
+
const g = ipv6Groups(address);
|
|
4958
|
+
const quad = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
4959
|
+
const zero = (from, to) => g.slice(from, to).every((x) => x === 0);
|
|
4960
|
+
if (zero(0, 5) && g[5] === 65535) return quad(g[6], g[7]);
|
|
4961
|
+
if (g[0] === 100 && g[1] === 65435 && zero(2, 6)) return quad(g[6], g[7]);
|
|
4962
|
+
if (g[0] === 8194) return quad(g[1], g[2]);
|
|
4963
|
+
return null;
|
|
4964
|
+
}
|
|
4965
|
+
function isPrivateAddress(address) {
|
|
4966
|
+
const ip = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
4967
|
+
const family = isIP(ip);
|
|
4968
|
+
if (family === 4) return PRIVATE_RANGES.check(ip, "ipv4");
|
|
4969
|
+
if (family !== 6) return false;
|
|
4970
|
+
const v4 = embeddedIPv4(ip);
|
|
4971
|
+
return v4 !== null && PRIVATE_RANGES.check(v4, "ipv4") || PRIVATE_RANGES.check(ip, "ipv6");
|
|
4972
|
+
}
|
|
4924
4973
|
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;
|
|
4974
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.+$/, "");
|
|
4975
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal")) return true;
|
|
4976
|
+
return isPrivateAddress(host);
|
|
4939
4977
|
}
|
|
4940
|
-
function
|
|
4978
|
+
async function resolvesToPrivateAddress(hostname) {
|
|
4941
4979
|
try {
|
|
4942
|
-
const
|
|
4943
|
-
return
|
|
4980
|
+
const addresses = await dnsLookupAll(hostname.replace(/^\[|\]$/g, ""), { all: true });
|
|
4981
|
+
return addresses.some((a) => isPrivateAddress(a.address));
|
|
4944
4982
|
} catch {
|
|
4945
4983
|
return false;
|
|
4946
4984
|
}
|
|
4947
4985
|
}
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
const
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4986
|
+
var guardedLookup = (hostname, options, callback) => {
|
|
4987
|
+
dnsLookup(hostname, { ...options, all: true }, (err, addresses) => {
|
|
4988
|
+
if (err) return callback(err, "");
|
|
4989
|
+
const blocked = addresses.find((a) => isPrivateAddress(a.address));
|
|
4990
|
+
if (blocked) {
|
|
4991
|
+
return callback(Object.assign(new Error(`${hostname} resolves to private address ${blocked.address}`), { code: "EPRIVATEADDR" }), "");
|
|
4992
|
+
}
|
|
4993
|
+
if (options.all) return callback(null, addresses);
|
|
4994
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
4995
|
+
});
|
|
4996
|
+
};
|
|
4997
|
+
var USER_AGENT = "Prodlint-WebScanner/1.0";
|
|
4998
|
+
var MAX_REDIRECTS = 5;
|
|
4999
|
+
var MAX_BODY_BYTES = 5 * 1024 * 1024;
|
|
5000
|
+
function flattenHeaders(res) {
|
|
5001
|
+
const headers = {};
|
|
5002
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
5003
|
+
if (value !== void 0) headers[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
4958
5004
|
}
|
|
5005
|
+
return headers;
|
|
4959
5006
|
}
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
const
|
|
4968
|
-
|
|
4969
|
-
|
|
5007
|
+
function readBody(res) {
|
|
5008
|
+
const encoding = String(res.headers["content-encoding"] ?? "").trim().toLowerCase();
|
|
5009
|
+
const decoder = encoding === "gzip" || encoding === "x-gzip" ? createGunzip() : encoding === "deflate" ? createInflate() : encoding === "br" ? createBrotliDecompress() : null;
|
|
5010
|
+
const stream = decoder ? res.pipe(decoder) : res;
|
|
5011
|
+
return new Promise((resolve4, reject) => {
|
|
5012
|
+
const chunks = [];
|
|
5013
|
+
let size = 0;
|
|
5014
|
+
const done = () => resolve4(Buffer.concat(chunks).toString("utf8"));
|
|
5015
|
+
stream.on("data", (chunk) => {
|
|
5016
|
+
if (size >= MAX_BODY_BYTES) return;
|
|
5017
|
+
chunks.push(chunk.subarray(0, MAX_BODY_BYTES - size));
|
|
5018
|
+
size += chunk.length;
|
|
5019
|
+
if (size >= MAX_BODY_BYTES) {
|
|
5020
|
+
done();
|
|
5021
|
+
res.destroy();
|
|
5022
|
+
decoder?.destroy();
|
|
5023
|
+
}
|
|
4970
5024
|
});
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
}
|
|
5025
|
+
stream.on("end", done);
|
|
5026
|
+
stream.on("error", reject);
|
|
5027
|
+
res.on("error", reject);
|
|
5028
|
+
});
|
|
4975
5029
|
}
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
const
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
5030
|
+
function guardedGet(url, timeoutMs, lookup = guardedLookup) {
|
|
5031
|
+
return new Promise((resolve4) => {
|
|
5032
|
+
let current;
|
|
5033
|
+
const finish = (result) => {
|
|
5034
|
+
clearTimeout(timer);
|
|
5035
|
+
current?.destroy();
|
|
5036
|
+
resolve4(result);
|
|
5037
|
+
};
|
|
5038
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
5039
|
+
const attempt = (target, redirectsLeft) => {
|
|
5040
|
+
if (target.protocol !== "http:" && target.protocol !== "https:" || isPrivateHost(target.hostname)) return finish(null);
|
|
5041
|
+
const send = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
5042
|
+
current = send(target, { agent: false, lookup, headers: { "User-Agent": USER_AGENT, "Accept-Encoding": "gzip, deflate, br" } }, (res) => {
|
|
5043
|
+
const status = res.statusCode ?? 0;
|
|
5044
|
+
const location = res.headers.location;
|
|
5045
|
+
if (status >= 300 && status < 400 && location) {
|
|
5046
|
+
res.resume();
|
|
5047
|
+
let next;
|
|
5048
|
+
try {
|
|
5049
|
+
next = new URL(location, target);
|
|
5050
|
+
} catch {
|
|
5051
|
+
return finish(null);
|
|
5052
|
+
}
|
|
5053
|
+
return redirectsLeft > 0 ? attempt(next, redirectsLeft - 1) : finish(null);
|
|
5054
|
+
}
|
|
5055
|
+
readBody(res).then((body) => finish({ status, headers: flattenHeaders(res), body }), () => finish(null));
|
|
5056
|
+
});
|
|
5057
|
+
current.on("error", () => finish(null));
|
|
5058
|
+
current.end();
|
|
5059
|
+
};
|
|
5060
|
+
try {
|
|
5061
|
+
attempt(new URL(url), MAX_REDIRECTS);
|
|
5062
|
+
} catch {
|
|
5063
|
+
finish(null);
|
|
5064
|
+
}
|
|
5065
|
+
});
|
|
5066
|
+
}
|
|
5067
|
+
async function fetchText(url, timeout = 8e3) {
|
|
5068
|
+
const r = await guardedGet(url, timeout);
|
|
5069
|
+
return r && r.status >= 200 && r.status < 300 ? r.body : null;
|
|
5070
|
+
}
|
|
5071
|
+
async function fetchPage(url, timeout = 15e3) {
|
|
5072
|
+
const start = Date.now();
|
|
5073
|
+
const r = await guardedGet(url, timeout);
|
|
5074
|
+
const ok = r !== null && r.status >= 200 && r.status < 300;
|
|
5075
|
+
return { headers: r?.headers ?? {}, html: ok ? r.body : null, loadTimeMs: ok ? Date.now() - start : null };
|
|
4989
5076
|
}
|
|
4990
5077
|
function normalizeUrl(input) {
|
|
4991
5078
|
let url = input.trim();
|
|
@@ -4997,7 +5084,7 @@ function normalizeUrl(input) {
|
|
|
4997
5084
|
}
|
|
4998
5085
|
async function runWebScan(targetUrl) {
|
|
4999
5086
|
const domain = getDomain(targetUrl);
|
|
5000
|
-
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
5087
|
+
const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, page] = await Promise.all([
|
|
5001
5088
|
fetchText(`${targetUrl}/robots.txt`),
|
|
5002
5089
|
fetchText(`${targetUrl}/llms.txt`),
|
|
5003
5090
|
fetchText(`${targetUrl}/ai.txt`),
|
|
@@ -5005,10 +5092,9 @@ async function runWebScan(targetUrl) {
|
|
|
5005
5092
|
fetchText(`${targetUrl}/.well-known/agent-card.json`),
|
|
5006
5093
|
fetchText(`${targetUrl}/sitemap.xml`),
|
|
5007
5094
|
fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
|
|
5008
|
-
|
|
5009
|
-
fetchWithTiming(targetUrl)
|
|
5095
|
+
fetchPage(targetUrl)
|
|
5010
5096
|
]);
|
|
5011
|
-
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory,
|
|
5097
|
+
const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, ...page };
|
|
5012
5098
|
const checks = allChecks.map((fn) => fn(ctx));
|
|
5013
5099
|
const totalPoints = checks.reduce((s, c) => s + c.points, 0);
|
|
5014
5100
|
const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
|
|
@@ -5151,7 +5237,7 @@ async function runWebScan2(url, opts) {
|
|
|
5151
5237
|
process.exit(2);
|
|
5152
5238
|
}
|
|
5153
5239
|
const hostname = new URL(normalizedUrl).hostname;
|
|
5154
|
-
if (isPrivateHost(hostname)) {
|
|
5240
|
+
if (isPrivateHost(hostname) || await resolvesToPrivateAddress(hostname)) {
|
|
5155
5241
|
console.error("Cannot scan private/internal hosts.");
|
|
5156
5242
|
process.exit(2);
|
|
5157
5243
|
}
|