sentinelayer-cli 0.46.5 → 0.48.0
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 +47 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +32 -1
- package/src/auth/session-store.js +37 -0
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +516 -30
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +25 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +110 -15
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §EW04 — SSRF-safe source reader.
|
|
3
|
+
*
|
|
4
|
+
* Fetches an owner-approved public URL with defence-in-depth against SSRF:
|
|
5
|
+
* - DNS vetting: all resolved addresses checked against private/metadata ranges
|
|
6
|
+
* - IP pinning: connection made to the vetted IP, not the hostname
|
|
7
|
+
* - HTTPS only (http only via test-only injected transport)
|
|
8
|
+
* - Manual redirects (max 3), full re-check on every hop
|
|
9
|
+
* - 10 s total deadline, 5 MiB body cap, content-type allowlist
|
|
10
|
+
* - No cookies, credentials, or Authorization header
|
|
11
|
+
* - Generic errors: never echo resolved IPs or response bodies of refused fetches
|
|
12
|
+
*
|
|
13
|
+
* Library only. Node builtins + existing zod contracts. No new dependency.
|
|
14
|
+
* No headless browser or JavaScript execution.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promises as dnsPromises } from "node:dns";
|
|
18
|
+
import https from "node:https";
|
|
19
|
+
import http from "node:http";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { isIP } from "node:net";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Constants
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MiB
|
|
28
|
+
export const MAX_REDIRECT_HOPS = 3;
|
|
29
|
+
export const TOTAL_DEADLINE_MS = 10_000; // 10 s
|
|
30
|
+
|
|
31
|
+
const ALLOWED_CONTENT_TYPES = new Set([
|
|
32
|
+
"text/html",
|
|
33
|
+
"application/json",
|
|
34
|
+
"application/ld+json",
|
|
35
|
+
"text/plain",
|
|
36
|
+
"application/xml",
|
|
37
|
+
"text/xml",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const REQUEST_HEADERS = {
|
|
41
|
+
"Accept": "text/html, application/json, application/ld+json, text/plain, application/xml, text/xml",
|
|
42
|
+
"User-Agent": "ENGRAM-SafeReader/1.0",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Address classification — every private/metadata range from the ticket
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const IPV4_PRIVATE_RANGES = [
|
|
50
|
+
{ start: 0x00000000, end: 0x00ffffff, label: "0/8" }, // 0.0.0.0/8
|
|
51
|
+
{ start: 0x0a000000, end: 0x0affffff, label: "10/8" }, // 10.0.0.0/8
|
|
52
|
+
{ start: 0x7f000000, end: 0x7fffffff, label: "127/8" }, // 127.0.0.0/8
|
|
53
|
+
{ start: 0xa9fe0000, end: 0xa9feffff, label: "169.254/16" }, // 169.254.0.0/16 (includes 169.254.169.254)
|
|
54
|
+
{ start: 0xac100000, end: 0xac1fffff, label: "172.16/12" }, // 172.16.0.0/12
|
|
55
|
+
{ start: 0xc0a80000, end: 0xc0a8ffff, label: "192.168/16" }, // 192.168.0.0/16
|
|
56
|
+
{ start: 0x64400000, end: 0x647fffff, label: "100.64/10" }, // 100.64.0.0/10 (CGNAT)
|
|
57
|
+
{ start: 0xe0000000, end: 0xefffffff, label: "224/4" }, // 224.0.0.0/4 (multicast)
|
|
58
|
+
{ start: 0xf0000000, end: 0xffffffff, label: "240/4" }, // 240.0.0.0/4 (reserved, incl 255.255.255.255)
|
|
59
|
+
{ start: 0xc0000000, end: 0xc00000ff, label: "192.0.0/24" }, // 192.0.0.0/24 (IETF protocol assignments)
|
|
60
|
+
{ start: 0xc6120000, end: 0xc613ffff, label: "198.18/15" }, // 198.18.0.0/15 (benchmarking)
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function ipv4ToInt(ip) {
|
|
64
|
+
const parts = ip.split(".");
|
|
65
|
+
if (parts.length !== 4) return null;
|
|
66
|
+
const nums = parts.map((p) => { const n = Number(p); return (n >= 0 && n <= 255 && String(n) === p) ? n : null; });
|
|
67
|
+
if (nums.includes(null)) return null;
|
|
68
|
+
return ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isPrivateIPv4(ip) {
|
|
72
|
+
const int = ipv4ToInt(ip);
|
|
73
|
+
if (int === null) return false;
|
|
74
|
+
for (const range of IPV4_PRIVATE_RANGES) {
|
|
75
|
+
if (int >= range.start && int <= range.end) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// IPv6 private and special-purpose ranges (normalized, uncompressed form)
|
|
81
|
+
const IPV6_PRIVATE_NETWORKS = [
|
|
82
|
+
{ prefixBits: 0x00000000000000000000000000000000n, len: 16, label: "::/16 (unspecified/IPv4-mapped)" },
|
|
83
|
+
// ::1 is handled by the raw string check above; ::/16 below catches unspecified
|
|
84
|
+
{ prefixBits: 0xfc000000000000000000000000000000n, len: 7, label: "fc00::/7 (unique-local)" },
|
|
85
|
+
{ prefixBits: 0xfe800000000000000000000000000000n, len: 10, label: "fe80::/10 (link-local)" },
|
|
86
|
+
{ prefixBits: 0xff000000000000000000000000000000n, len: 8, label: "ff00::/8 (multicast)" },
|
|
87
|
+
// R7-4: special-purpose ranges (RFC 6890, RFC 3849, RFC 6666, RFC 2928)
|
|
88
|
+
{ prefixBits: 0x20010db8000000000000000000000000n, len: 32, label: "2001:db8::/32 (documentation)" },
|
|
89
|
+
{ prefixBits: 0x01000000000000000000000000000000n, len: 64, label: "100::/64 (discard)" },
|
|
90
|
+
{ prefixBits: 0x20010000000000000000000000000000n, len: 23, label: "2001::/23 (IETF protocol assignments)" },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// NAT64 well-known prefix: 64:ff9b::/96 — 12 bytes (24 hex chars) in expanded flat form
|
|
94
|
+
const NAT64_PREFIX_BITS = 0x0064ff9b000000000000000000000000n;
|
|
95
|
+
|
|
96
|
+
// 6to4: 2002::/16 — 2 bytes (4 hex chars) in expanded flat form, then embedded IPv4 at bytes 2-5 (hex 4-12)
|
|
97
|
+
const SIXTO4_PREFIX = "2002";
|
|
98
|
+
|
|
99
|
+
function expandIPv6(ip) {
|
|
100
|
+
// Remove brackets if present
|
|
101
|
+
const raw = ip.replace(/^\[|\]$/g, "");
|
|
102
|
+
|
|
103
|
+
// Detect dotted IPv4 tail: last group matches a.b.c.d with each octet 0-255.
|
|
104
|
+
const dottedV4Re = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
|
105
|
+
const convertDottedTail = (groups) => {
|
|
106
|
+
if (groups.length === 0) return groups;
|
|
107
|
+
const last = groups[groups.length - 1];
|
|
108
|
+
const m = last.match(dottedV4Re);
|
|
109
|
+
if (!m) return groups;
|
|
110
|
+
const octets = m.slice(1).map(Number);
|
|
111
|
+
if (octets.some((o) => o > 255 || o < 0)) return groups;
|
|
112
|
+
const h1 = ((octets[0] << 8) | octets[1]).toString(16).padStart(4, "0");
|
|
113
|
+
const h2 = ((octets[2] << 8) | octets[3]).toString(16).padStart(4, "0");
|
|
114
|
+
return [...groups.slice(0, -1), h1, h2];
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Handle :: compression
|
|
118
|
+
const parts = raw.split("::");
|
|
119
|
+
if (parts.length > 2) return null;
|
|
120
|
+
const left = parts[0] ? parts[0].split(":") : [];
|
|
121
|
+
const right = parts[1] ? parts[1].split(":") : [];
|
|
122
|
+
|
|
123
|
+
// No compression case: 7 groups with a dotted tail (e.g. 0:0:0:0:0:ffff:127.0.0.1)
|
|
124
|
+
// expands to 8; 8 fully-expanded groups also handled here.
|
|
125
|
+
if (parts.length === 1) {
|
|
126
|
+
const norm = convertDottedTail(left);
|
|
127
|
+
// After normalization, must be exactly 8 valid hextets
|
|
128
|
+
if (norm.length !== 8 || norm.some((g) => !/^[0-9a-fA-F]{1,4}$/.test(g))) return null;
|
|
129
|
+
return norm.map((g) => g.padStart(4, "0").toLowerCase()).join(":");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const normalizedLeft = convertDottedTail(left);
|
|
133
|
+
const normalizedRight = convertDottedTail(right);
|
|
134
|
+
|
|
135
|
+
if (normalizedLeft.length + normalizedRight.length > 7) return null;
|
|
136
|
+
const middle = Array(8 - normalizedLeft.length - normalizedRight.length).fill("0");
|
|
137
|
+
const all = [...normalizedLeft, ...middle, ...normalizedRight];
|
|
138
|
+
if (all.some((g) => !/^[0-9a-fA-F]{1,4}$/.test(g))) return null;
|
|
139
|
+
return all.map((g) => g.padStart(4, "0").toLowerCase()).join(":");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function ipv6ToBigInt(expanded) {
|
|
143
|
+
const flat = expanded.replace(/:/g, "");
|
|
144
|
+
return BigInt("0x" + flat);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isPrivateIPv6(ip) {
|
|
148
|
+
const raw = ip.replace(/^\[|\]$/g, "").toLowerCase();
|
|
149
|
+
// ::1
|
|
150
|
+
if (raw === "::1" || raw === "0:0:0:0:0:0:0:1") return true;
|
|
151
|
+
|
|
152
|
+
// IPv4-mapped IPv6: ::ffff:x.x.x.x
|
|
153
|
+
const v4Mapped = raw.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
|
|
154
|
+
if (v4Mapped) return isPrivateIPv4(v4Mapped[1]);
|
|
155
|
+
|
|
156
|
+
const expanded = expandIPv6(ip);
|
|
157
|
+
// FAIL CLOSED: if we cannot normalize an IPv6 address, treat it as dangerous.
|
|
158
|
+
// Valid IPv6 addresses must normalize to canonical 8-hextet colon form.
|
|
159
|
+
if (!expanded) return true;
|
|
160
|
+
|
|
161
|
+
// BigInt-based numeric prefix matching
|
|
162
|
+
const addrBits = ipv6ToBigInt(expanded);
|
|
163
|
+
|
|
164
|
+
for (const net of IPV6_PRIVATE_NETWORKS) {
|
|
165
|
+
const shift = 128n - BigInt(net.len);
|
|
166
|
+
const mask = (~0n) << shift;
|
|
167
|
+
if ((addrBits & mask) === (net.prefixBits & mask)) return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// NAT64: 64:ff9b::/96 — extract last 4 bytes as IPv4
|
|
171
|
+
const NAT64_MASK = (~0n) << 32n;
|
|
172
|
+
if ((addrBits & NAT64_MASK) === NAT64_PREFIX_BITS) {
|
|
173
|
+
const embeddedV4 = Number(addrBits & 0xffffffffn);
|
|
174
|
+
const octets = [
|
|
175
|
+
(embeddedV4 >>> 24) & 0xff,
|
|
176
|
+
(embeddedV4 >>> 16) & 0xff,
|
|
177
|
+
(embeddedV4 >>> 8) & 0xff,
|
|
178
|
+
embeddedV4 & 0xff,
|
|
179
|
+
];
|
|
180
|
+
return isPrivateIPv4(octets.join("."));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 6to4: 2002::/16 — extract embedded IPv4 at bytes 2-5 (bits 16-47)
|
|
184
|
+
const SIXTO4_MASK = (~0n) << 112n;
|
|
185
|
+
if ((addrBits & SIXTO4_MASK) === 0x20020000000000000000000000000000n) {
|
|
186
|
+
const embeddedV4 = Number((addrBits >> 80n) & 0xffffffffn);
|
|
187
|
+
const octets = [
|
|
188
|
+
(embeddedV4 >>> 24) & 0xff,
|
|
189
|
+
(embeddedV4 >>> 16) & 0xff,
|
|
190
|
+
(embeddedV4 >>> 8) & 0xff,
|
|
191
|
+
embeddedV4 & 0xff,
|
|
192
|
+
];
|
|
193
|
+
return isPrivateIPv4(octets.join("."));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// IPv4-compatible IPv6 (deprecated): first 96 bits zero, last 32 bits are IPv4
|
|
197
|
+
// Skip ::1 (already handled above) and :: (unspecified, caught by prefix match)
|
|
198
|
+
if ((addrBits >> 32n) === 0n && (addrBits & 0xffffffffn) !== 0n && (addrBits & 0xffffffffn) !== 1n) {
|
|
199
|
+
const embeddedV4 = Number(addrBits & 0xffffffffn);
|
|
200
|
+
const octets = [
|
|
201
|
+
(embeddedV4 >>> 24) & 0xff,
|
|
202
|
+
(embeddedV4 >>> 16) & 0xff,
|
|
203
|
+
(embeddedV4 >>> 8) & 0xff,
|
|
204
|
+
embeddedV4 & 0xff,
|
|
205
|
+
];
|
|
206
|
+
const v4Addr = octets.join(".");
|
|
207
|
+
if (isIP(v4Addr) === 4) return isPrivateIPv4(v4Addr);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isPrivateAddress(addr) {
|
|
214
|
+
if (isIP(addr) === 4) return isPrivateIPv4(addr);
|
|
215
|
+
if (isIP(addr) === 6) return isPrivateIPv6(addr);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isMetadataAddress(addr) {
|
|
220
|
+
return addr === "169.254.169.254";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isDangerousAddress(addr) {
|
|
224
|
+
// isPrivateAddress already fails closed for unparseable IPv6.
|
|
225
|
+
// But we double-check: if net.isIP says this is a v6 address and we cannot
|
|
226
|
+
// produce a canonical form, it is dangerous regardless.
|
|
227
|
+
if (isIP(addr) === 6) {
|
|
228
|
+
const normalized = expandIPv6(addr);
|
|
229
|
+
if (!normalized) return true;
|
|
230
|
+
}
|
|
231
|
+
if (isIP(addr) === 4) {
|
|
232
|
+
if (ipv4ToInt(addr) === null) return true;
|
|
233
|
+
}
|
|
234
|
+
// Anything that isPrivateAddress cannot classify and isIP cannot identify:
|
|
235
|
+
// we don't try to classify it here — let the caller decide (resolveAndVet
|
|
236
|
+
// will refuse anything that isn't isIP 4 or 6).
|
|
237
|
+
return isPrivateAddress(addr) || isMetadataAddress(addr);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Errors
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
export class SafeReaderError extends Error {
|
|
245
|
+
constructor(code) {
|
|
246
|
+
super(code);
|
|
247
|
+
this.name = "SafeReaderError";
|
|
248
|
+
this.code = code;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function reject(code) {
|
|
253
|
+
throw new SafeReaderError(code);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// DNS resolution + vetting
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
async function resolveAndVet(hostname, resolver, signal) {
|
|
261
|
+
// Check deadline before starting DNS
|
|
262
|
+
if (signal && signal.aborted) {
|
|
263
|
+
return { vetted: null, error: "fetch_timeout" };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (isIP(hostname)) {
|
|
267
|
+
// Raw IP — accept ONLY if isIP returns 4 or 6 AND it is not dangerous.
|
|
268
|
+
// An unclassifiable address (isIP returns 0) is never dialed.
|
|
269
|
+
const kind = isIP(hostname);
|
|
270
|
+
if (kind !== 4 && kind !== 6) {
|
|
271
|
+
return { vetted: null, error: "fetch_private_address" };
|
|
272
|
+
}
|
|
273
|
+
if (isDangerousAddress(hostname)) {
|
|
274
|
+
return { vetted: null, error: "fetch_private_address" };
|
|
275
|
+
}
|
|
276
|
+
return { vetted: hostname, error: null };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Resolve all addresses, racing against the deadline signal
|
|
280
|
+
let v4 = [];
|
|
281
|
+
let v6 = [];
|
|
282
|
+
try {
|
|
283
|
+
const dnsPromise = Promise.all([
|
|
284
|
+
resolver.resolve4(hostname).catch(() => []),
|
|
285
|
+
resolver.resolve6(hostname).catch(() => []),
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
if (signal) {
|
|
289
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
290
|
+
signal.addEventListener("abort", () => reject(new SafeReaderError("fetch_timeout")), { once: true });
|
|
291
|
+
if (signal.aborted) reject(new SafeReaderError("fetch_timeout"));
|
|
292
|
+
});
|
|
293
|
+
[v4, v6] = await Promise.race([dnsPromise, timeoutPromise]);
|
|
294
|
+
} else {
|
|
295
|
+
[v4, v6] = await dnsPromise;
|
|
296
|
+
}
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (err instanceof SafeReaderError) {
|
|
299
|
+
return { vetted: null, error: err.code };
|
|
300
|
+
}
|
|
301
|
+
return { vetted: null, error: "fetch_dns_failed" };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const allAddrs = [...v4, ...v6];
|
|
305
|
+
if (allAddrs.length === 0) {
|
|
306
|
+
return { vetted: null, error: "fetch_dns_failed" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Always refuse if ANY address is dangerous or unclassifiable.
|
|
310
|
+
// An address is acceptable ONLY if isIP returns 4 or 6 AND it passes
|
|
311
|
+
// the danger check (which fails closed for unparseable IPv6).
|
|
312
|
+
for (const addr of allAddrs) {
|
|
313
|
+
const kind = isIP(addr);
|
|
314
|
+
if (kind !== 4 && kind !== 6) {
|
|
315
|
+
return { vetted: null, error: "fetch_private_address" };
|
|
316
|
+
}
|
|
317
|
+
if (isDangerousAddress(addr)) {
|
|
318
|
+
return { vetted: null, error: "fetch_private_address" };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Pin to the first IPv4 (or first IPv6 if no IPv4)
|
|
323
|
+
const vetted = v4.length > 0 ? v4[0] : v6[0];
|
|
324
|
+
return { vetted, error: null };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Unified request + body read (de-chunked by Node)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Make an HTTP(S) request and read the response with the 5 MiB cap enforced
|
|
333
|
+
* in the data handler. Uses Node's request API so Transfer-Encoding: chunked
|
|
334
|
+
* is decoded automatically.
|
|
335
|
+
*/
|
|
336
|
+
function doRequest({ transport, hostname, ip, port, path, signal }) {
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
const lookup = (host, opts, cb) => {
|
|
339
|
+
const family = isIP(ip) === 6 ? 6 : 4;
|
|
340
|
+
const dialIp = ip;
|
|
341
|
+
if (opts.all) {
|
|
342
|
+
cb(null, [{ address: dialIp, family }]);
|
|
343
|
+
} else {
|
|
344
|
+
cb(null, dialIp, family);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const reqOpts = {
|
|
349
|
+
host: hostname,
|
|
350
|
+
port,
|
|
351
|
+
path: path || "/",
|
|
352
|
+
method: "GET",
|
|
353
|
+
agent: false,
|
|
354
|
+
signal,
|
|
355
|
+
lookup,
|
|
356
|
+
headers: { ...REQUEST_HEADERS },
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// Only set servername/rejectUnauthorized for https
|
|
360
|
+
if (transport === https) {
|
|
361
|
+
reqOpts.servername = hostname;
|
|
362
|
+
reqOpts.rejectUnauthorized = true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const req = transport.request(reqOpts);
|
|
366
|
+
|
|
367
|
+
req.on("error", (err) => {
|
|
368
|
+
if (err.name === "AbortError") {
|
|
369
|
+
reject(new SafeReaderError("fetch_timeout"));
|
|
370
|
+
} else {
|
|
371
|
+
reject(new SafeReaderError("fetch_io_error"));
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
req.on("response", (resp) => {
|
|
376
|
+
let bodyLength = 0;
|
|
377
|
+
const chunks = [];
|
|
378
|
+
|
|
379
|
+
resp.on("data", (chunk) => {
|
|
380
|
+
bodyLength += chunk.length;
|
|
381
|
+
if (bodyLength > MAX_BODY_BYTES) {
|
|
382
|
+
resp.destroy();
|
|
383
|
+
reject(new SafeReaderError("fetch_too_large"));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
chunks.push(chunk);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
resp.on("end", () => {
|
|
390
|
+
const body = Buffer.concat(chunks, bodyLength);
|
|
391
|
+
const contentType = resp.headers["content-type"] || "";
|
|
392
|
+
resolve({
|
|
393
|
+
status: resp.statusCode,
|
|
394
|
+
location: resp.headers["location"] || null,
|
|
395
|
+
body,
|
|
396
|
+
contentType,
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
resp.on("error", (err) => {
|
|
401
|
+
if (err.name === "AbortError") {
|
|
402
|
+
reject(new SafeReaderError("fetch_timeout"));
|
|
403
|
+
} else {
|
|
404
|
+
reject(new SafeReaderError("fetch_io_error"));
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
req.end();
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Main fetch function (internal)
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
async function fetchOne({
|
|
418
|
+
url,
|
|
419
|
+
approvedOrigins,
|
|
420
|
+
resolver,
|
|
421
|
+
transport,
|
|
422
|
+
deadlineSignal,
|
|
423
|
+
}) {
|
|
424
|
+
const parsed = new URL(url);
|
|
425
|
+
|
|
426
|
+
// Protocol check
|
|
427
|
+
if (parsed.protocol !== "https:") {
|
|
428
|
+
if (parsed.protocol === "http:" && (transport === http || transport._isTestHttp)) {
|
|
429
|
+
// http only with injected test transport
|
|
430
|
+
} else {
|
|
431
|
+
reject("fetch_blocked");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Origin must be in approved set
|
|
436
|
+
if (!approvedOrigins.has(parsed.origin)) {
|
|
437
|
+
reject("fetch_blocked");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// No userinfo
|
|
441
|
+
if (parsed.username || parsed.password) {
|
|
442
|
+
reject("fetch_blocked");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// DNS vet
|
|
446
|
+
const hostname = parsed.hostname;
|
|
447
|
+
const { vetted, error } = await resolveAndVet(hostname, resolver, deadlineSignal);
|
|
448
|
+
|
|
449
|
+
if (error) reject(error);
|
|
450
|
+
if (!vetted) reject("fetch_dns_failed");
|
|
451
|
+
|
|
452
|
+
// Check deadline
|
|
453
|
+
if (deadlineSignal.aborted) reject("fetch_timeout");
|
|
454
|
+
|
|
455
|
+
const port = parsed.port || (parsed.protocol === "https:" ? 443 : 80);
|
|
456
|
+
|
|
457
|
+
const result = await doRequest({
|
|
458
|
+
transport,
|
|
459
|
+
hostname,
|
|
460
|
+
ip: vetted,
|
|
461
|
+
port,
|
|
462
|
+
path: parsed.pathname + parsed.search,
|
|
463
|
+
signal: deadlineSignal,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const status = result.status;
|
|
467
|
+
const responseBody = result.body;
|
|
468
|
+
const contentType = result.contentType;
|
|
469
|
+
const mediaType = contentType.split(";")[0].trim().toLowerCase();
|
|
470
|
+
|
|
471
|
+
if (status === null || status < 200 || status >= 300) {
|
|
472
|
+
// Check for redirect
|
|
473
|
+
if (result.location && [301, 302, 303, 307, 308].includes(status)) {
|
|
474
|
+
return { redirect: result.location, status };
|
|
475
|
+
}
|
|
476
|
+
reject("fetch_bad_status");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Content-type check
|
|
480
|
+
if (!ALLOWED_CONTENT_TYPES.has(mediaType)) {
|
|
481
|
+
reject("fetch_bad_content_type");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
bytes: responseBody,
|
|
486
|
+
contentType: contentType,
|
|
487
|
+
finalUrl: url,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Public API: createSafeReader
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Create a safe reader configured with the given dependencies.
|
|
497
|
+
*
|
|
498
|
+
* @param {object} options
|
|
499
|
+
* @param {object} [options.resolver] — dns.promises by default; injectable for tests
|
|
500
|
+
* @param {function} [options.now] — Date.now by default; injectable for tests
|
|
501
|
+
* @param {object} [options.limits] — override limits
|
|
502
|
+
* @param {object} [options.transport] — node:https by default; inject a test transport for tests only
|
|
503
|
+
* Test transport MUST have .request(opts) and _isTestHttp:true flag
|
|
504
|
+
* @returns {{ read: function }}
|
|
505
|
+
*/
|
|
506
|
+
export function createSafeReader({
|
|
507
|
+
resolver = dnsPromises,
|
|
508
|
+
now = () => Date.now(),
|
|
509
|
+
limits = {},
|
|
510
|
+
transport = https,
|
|
511
|
+
} = {}) {
|
|
512
|
+
const maxBytes = limits.maxBytes ?? MAX_BODY_BYTES;
|
|
513
|
+
const maxRedirects = limits.maxRedirects ?? MAX_REDIRECT_HOPS;
|
|
514
|
+
const deadlineMs = limits.deadlineMs ?? TOTAL_DEADLINE_MS;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Read a URL, returning bytes, content type, final URL, version hash, and fetch time.
|
|
518
|
+
*
|
|
519
|
+
* @param {string} url — the URL to fetch
|
|
520
|
+
* @param {Set<string>} approvedOrigins — allowed origins
|
|
521
|
+
* @returns {Promise<{bytes: Buffer, contentType: string, finalUrl: string, version: string, fetchedAt: string}>}
|
|
522
|
+
*/
|
|
523
|
+
async function read(url, opts) {
|
|
524
|
+
if (!opts || !(opts.approvedOrigins instanceof Set)) reject("fetch_blocked");
|
|
525
|
+
const { approvedOrigins } = opts;
|
|
526
|
+
|
|
527
|
+
const startTime = now();
|
|
528
|
+
const deadline = startTime + deadlineMs;
|
|
529
|
+
|
|
530
|
+
const controller = new AbortController();
|
|
531
|
+
const deadlineTimer = setTimeout(() => controller.abort(), deadlineMs);
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
let currentUrl = url;
|
|
535
|
+
let result;
|
|
536
|
+
|
|
537
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
538
|
+
if (controller.signal.aborted) reject("fetch_timeout");
|
|
539
|
+
|
|
540
|
+
result = await fetchOne({
|
|
541
|
+
url: currentUrl,
|
|
542
|
+
approvedOrigins,
|
|
543
|
+
resolver,
|
|
544
|
+
transport,
|
|
545
|
+
deadlineSignal: controller.signal,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
if (!result.redirect) break;
|
|
549
|
+
|
|
550
|
+
// Resolve redirect target
|
|
551
|
+
let nextUrl;
|
|
552
|
+
try {
|
|
553
|
+
nextUrl = new URL(result.redirect, currentUrl).href;
|
|
554
|
+
} catch {
|
|
555
|
+
reject("fetch_redirect_rejected");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Check redirect target origin is approved
|
|
559
|
+
const nextOrigin = new URL(nextUrl).origin;
|
|
560
|
+
if (!approvedOrigins.has(nextOrigin)) {
|
|
561
|
+
reject("fetch_redirect_rejected");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// DNS vet on redirect target
|
|
565
|
+
const nextHostname = new URL(nextUrl).hostname;
|
|
566
|
+
const { vetted: nextVetted, error: nextError } = await resolveAndVet(nextHostname, resolver, controller.signal);
|
|
567
|
+
if (nextError) reject(nextError);
|
|
568
|
+
if (!nextVetted) reject("fetch_redirect_rejected");
|
|
569
|
+
if (isDangerousAddress(nextVetted)) reject("fetch_redirect_rejected");
|
|
570
|
+
|
|
571
|
+
// Cycle detection
|
|
572
|
+
if (nextUrl === currentUrl) reject("fetch_redirect_loop");
|
|
573
|
+
|
|
574
|
+
currentUrl = nextUrl;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (result.redirect) {
|
|
578
|
+
reject("fetch_redirect_loop"); // exceeded max hops
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const fetchedAt = new Date(now()).toISOString();
|
|
582
|
+
const version = `sha256:${createHash("sha256").update(result.bytes).digest("hex")}`;
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
bytes: result.bytes,
|
|
586
|
+
contentType: result.contentType,
|
|
587
|
+
finalUrl: result.finalUrl,
|
|
588
|
+
version,
|
|
589
|
+
fetchedAt,
|
|
590
|
+
};
|
|
591
|
+
} finally {
|
|
592
|
+
clearTimeout(deadlineTimer);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return { read };
|
|
597
|
+
}
|