@telorun/kernel 0.44.1 → 0.45.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.
Files changed (39) hide show
  1. package/dist/controller-loaders/npm-loader.d.ts +32 -0
  2. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  3. package/dist/controller-loaders/npm-loader.js +76 -54
  4. package/dist/controller-loaders/npm-loader.js.map +1 -1
  5. package/dist/transports/egress-guard.d.ts +26 -0
  6. package/dist/transports/egress-guard.d.ts.map +1 -0
  7. package/dist/transports/egress-guard.js +99 -0
  8. package/dist/transports/egress-guard.js.map +1 -0
  9. package/dist/transports/oci/oci-client.d.ts +8 -0
  10. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  11. package/dist/transports/oci/oci-client.js +62 -6
  12. package/dist/transports/oci/oci-client.js.map +1 -1
  13. package/dist/transports/oci/oci-ref.d.ts +1 -20
  14. package/dist/transports/oci/oci-ref.d.ts.map +1 -1
  15. package/dist/transports/oci/oci-ref.js +4 -35
  16. package/dist/transports/oci/oci-ref.js.map +1 -1
  17. package/dist/transports/oci/oci-transport.d.ts +1 -0
  18. package/dist/transports/oci/oci-transport.d.ts.map +1 -1
  19. package/dist/transports/oci/oci-transport.js +4 -0
  20. package/dist/transports/oci/oci-transport.js.map +1 -1
  21. package/dist/transports/registry-transport.d.ts +1 -0
  22. package/dist/transports/registry-transport.d.ts.map +1 -1
  23. package/dist/transports/registry-transport.js +36 -2
  24. package/dist/transports/registry-transport.js.map +1 -1
  25. package/dist/transports/transport-registry.d.ts +3 -0
  26. package/dist/transports/transport-registry.d.ts.map +1 -1
  27. package/dist/transports/transport-registry.js +5 -0
  28. package/dist/transports/transport-registry.js.map +1 -1
  29. package/dist/transports/transport.d.ts +9 -0
  30. package/dist/transports/transport.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/controller-loaders/npm-loader.ts +79 -50
  33. package/src/transports/egress-guard.ts +103 -0
  34. package/src/transports/oci/oci-client.ts +67 -6
  35. package/src/transports/oci/oci-ref.ts +4 -53
  36. package/src/transports/oci/oci-transport.ts +5 -0
  37. package/src/transports/registry-transport.ts +34 -1
  38. package/src/transports/transport-registry.ts +6 -0
  39. package/src/transports/transport.ts +10 -0
@@ -0,0 +1,103 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { isIP } from "node:net";
3
+
4
+ import { hostEnv } from "../host-env.js";
5
+
6
+ /** Egress policy for transport fetches, from `TELO_EGRESS`:
7
+ * - unset / `open` — no restriction (the default; a developer machine).
8
+ * - `public-only` — refuse any host that is, or resolves to, a private,
9
+ * loopback, link-local, or carrier-grade-NAT address.
10
+ *
11
+ * Built for deployments whose transports fetch attacker-suppliable refs — the
12
+ * discovery hub's tracker is the first: a registered `oci://10.0.0.5/…` or
13
+ * `https://169.254.169.254/…` ref must not become a request into the hub's
14
+ * own network. A guardrail, not isolation: it checks the name a fetch starts
15
+ * at, so redirect hops are not re-checked, and the check-then-fetch gap is
16
+ * open to DNS rebinding (a hostile resolver can answer public for the check
17
+ * and private for the fetch — the classic bypass for this guard shape).
18
+ * Network-level egress policy on the deployment is the actual boundary;
19
+ * this guard is defense-in-depth, not a substitute. */
20
+ export class EgressDeniedError extends Error {
21
+ constructor(host: string, address: string) {
22
+ super(
23
+ `Egress to '${host}' denied: it resolves to the non-public address ${address} ` +
24
+ `(TELO_EGRESS=public-only). Refusing to fetch from private, loopback, ` +
25
+ `link-local, or CGNAT ranges.`,
26
+ );
27
+ this.name = "EgressDeniedError";
28
+ }
29
+ }
30
+
31
+ function isPrivateIpv4(address: string): boolean {
32
+ const octets = address.split(".").map(Number);
33
+ if (octets.length !== 4 || octets.some((o) => Number.isNaN(o))) return true; // malformed → deny
34
+ const [a, b] = octets;
35
+ return (
36
+ a === 0 || // "this network"
37
+ a === 10 ||
38
+ a === 127 || // loopback
39
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
40
+ (a === 169 && b === 254) || // link-local (incl. cloud metadata)
41
+ (a === 172 && b >= 16 && b <= 31) ||
42
+ (a === 192 && b === 168)
43
+ );
44
+ }
45
+
46
+ function isPrivateIpv6(address: string): boolean {
47
+ const lower = address.toLowerCase();
48
+ // IPv4-mapped (::ffff:a.b.c.d) — judge the embedded IPv4.
49
+ const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
50
+ if (mapped) return isPrivateIpv4(mapped[1]);
51
+ return (
52
+ lower === "::" ||
53
+ lower === "::1" || // loopback
54
+ lower.startsWith("fc") || // unique-local fc00::/7
55
+ lower.startsWith("fd") ||
56
+ lower.startsWith("fe8") || // link-local fe80::/10
57
+ lower.startsWith("fe9") ||
58
+ lower.startsWith("fea") ||
59
+ lower.startsWith("feb")
60
+ );
61
+ }
62
+
63
+ /** True when `address` (an IP literal) is not publicly routable. */
64
+ export function isPrivateAddress(address: string): boolean {
65
+ const family = isIP(address);
66
+ if (family === 4) return isPrivateIpv4(address);
67
+ if (family === 6) return isPrivateIpv6(address);
68
+ return true; // not an IP literal → caller passed garbage; deny
69
+ }
70
+
71
+ function policyActive(): boolean {
72
+ // Read through `hostEnv()` like every kernel TELO_* read: `boot()` replaces
73
+ // `process.env` with the guardrail proxy that hides manifest-declared keys,
74
+ // so a manifest binding `env: TELO_EGRESS` must not be able to silently
75
+ // switch this security control off.
76
+ return (hostEnv().TELO_EGRESS ?? "").toLowerCase() === "public-only";
77
+ }
78
+
79
+ /** Assert `hostOrUrl` (a `host[:port]` or a full URL) may be fetched under the
80
+ * active egress policy. No-op unless `TELO_EGRESS=public-only`. An IP-literal
81
+ * host is judged directly; a hostname is resolved and every returned address
82
+ * must be public. Throws {@link EgressDeniedError}; DNS failure surfaces as
83
+ * the underlying error (never a silent pass). */
84
+ export async function assertPublicEgress(hostOrUrl: string): Promise<void> {
85
+ if (!policyActive()) return;
86
+ let hostname = hostOrUrl;
87
+ if (hostOrUrl.includes("://")) {
88
+ hostname = new URL(hostOrUrl).hostname;
89
+ } else {
90
+ // Bare host[:port] — URL parsing handles IPv6 brackets and ports.
91
+ hostname = new URL(`https://${hostOrUrl}`).hostname;
92
+ }
93
+ // URL wraps IPv6 literals in brackets; strip for isIP/lookup.
94
+ const bare = hostname.replace(/^\[|\]$/g, "");
95
+ if (isIP(bare)) {
96
+ if (isPrivateAddress(bare)) throw new EgressDeniedError(bare, bare);
97
+ return;
98
+ }
99
+ const addresses = await lookup(bare, { all: true, verbatim: true });
100
+ for (const { address } of addresses) {
101
+ if (isPrivateAddress(address)) throw new EgressDeniedError(bare, address);
102
+ }
103
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
+ import { assertPublicEgress } from "../egress-guard.js";
3
4
  import { resolveDockerCredential } from "./docker-credentials.js";
4
5
 
5
6
  export const TELO_LAYER_MEDIA_TYPE = "application/vnd.telo.module.v1+tar";
@@ -37,6 +38,17 @@ function sha256Hex(bytes: Uint8Array): string {
37
38
  return createHash("sha256").update(bytes).digest("hex");
38
39
  }
39
40
 
41
+ /** Resolve the `rel="next"` target of a `Link` header against the registry
42
+ * origin, or `null` when there is no next page. */
43
+ function nextPageUrl(linkHeader: string | null, origin: string): string | null {
44
+ if (!linkHeader) return null;
45
+ for (const part of linkHeader.split(",")) {
46
+ const m = part.match(/<([^>]+)>\s*;[^,]*rel="?next"?/i);
47
+ if (m) return new URL(m[1], origin).href;
48
+ }
49
+ return null;
50
+ }
51
+
40
52
  /** Parse a `WWW-Authenticate: Bearer realm="...",service="...",scope="..."` header. */
41
53
  function parseBearerChallenge(header: string): Record<string, string> {
42
54
  const out: Record<string, string> = {};
@@ -82,6 +94,9 @@ export class OciClient {
82
94
  init: RequestInit,
83
95
  scope: string,
84
96
  ): Promise<Response> {
97
+ // Registry refs are attacker-suppliable once public registration exists —
98
+ // refuse non-public hosts under TELO_EGRESS=public-only (no-op otherwise).
99
+ await assertPublicEgress(url);
85
100
  const withToken = (token?: string): RequestInit => {
86
101
  const headers = new Headers(init.headers);
87
102
  if (token) headers.set("authorization", `Bearer ${token}`);
@@ -107,6 +122,10 @@ export class OciClient {
107
122
  private async fetchToken(challenge: string, scope: string): Promise<string | null> {
108
123
  const params = parseBearerChallenge(challenge);
109
124
  if (!params.realm) return null;
125
+ // The token realm comes from the registry's own WWW-Authenticate header —
126
+ // an attacker-controlled registry could point it anywhere, so it is
127
+ // egress-checked like any other host.
128
+ await assertPublicEgress(params.realm);
110
129
  const tokenUrl = new URL(params.realm);
111
130
  if (params.service) tokenUrl.searchParams.set("service", params.service);
112
131
  tokenUrl.searchParams.set("scope", params.scope || scope);
@@ -147,16 +166,58 @@ export class OciClient {
147
166
  return Buffer.from(await res.arrayBuffer());
148
167
  }
149
168
 
150
- async listTags(): Promise<string[]> {
151
- const res = await this.authedFetch(`${this.base()}/tags/list`, {}, this.pullScope());
152
- if (res.status === 404) return [];
169
+ /** Content-identity digest of the manifest a reference resolves to, via a
170
+ * HEAD request no blob download. Falls back to hashing the manifest body
171
+ * when the registry omits `Docker-Content-Digest`. `null` when the
172
+ * reference does not exist. */
173
+ async headManifest(reference: string): Promise<string | null> {
174
+ const url = `${this.base()}/manifests/${reference}`;
175
+ const head = await this.authedFetch(
176
+ url,
177
+ { method: "HEAD", headers: { accept: MANIFEST_ACCEPT } },
178
+ this.pullScope(),
179
+ );
180
+ await head.text().catch(() => {});
181
+ if (head.status === 404) return null;
182
+ if (!head.ok) {
183
+ throw new Error(
184
+ `OCI head manifest ${this.repo}:${reference} on ${this.host} failed: ${head.status} ${head.statusText}`,
185
+ );
186
+ }
187
+ const digest = head.headers.get("docker-content-digest");
188
+ if (digest) return digest;
189
+
190
+ const res = await this.authedFetch(url, { headers: { accept: MANIFEST_ACCEPT } }, this.pullScope());
191
+ if (res.status === 404) return null;
153
192
  if (!res.ok) {
154
193
  throw new Error(
155
- `OCI list tags for ${this.repo} on ${this.host} failed: ${res.status} ${res.statusText}`,
194
+ `OCI pull manifest ${this.repo}:${reference} on ${this.host} failed: ${res.status} ${res.statusText}`,
156
195
  );
157
196
  }
158
- const body = (await res.json()) as { tags?: string[] | null };
159
- return Array.isArray(body.tags) ? body.tags : [];
197
+ return `sha256:${sha256Hex(new Uint8Array(await res.arrayBuffer()))}`;
198
+ }
199
+
200
+ /** All tags, following the distribution spec's pagination (`Link: …;
201
+ * rel="next"` with `last=` cursors) so a many-versioned repo enumerates
202
+ * fully — registries cap a single page (Docker Hub at 100). */
203
+ async listTags(): Promise<string[]> {
204
+ const tags: string[] = [];
205
+ let url: string | null = `${this.base()}/tags/list?n=1000`;
206
+ const seen = new Set<string>();
207
+ while (url && !seen.has(url)) {
208
+ seen.add(url);
209
+ const res: Response = await this.authedFetch(url, {}, this.pullScope());
210
+ if (res.status === 404) return tags;
211
+ if (!res.ok) {
212
+ throw new Error(
213
+ `OCI list tags for ${this.repo} on ${this.host} failed: ${res.status} ${res.statusText}`,
214
+ );
215
+ }
216
+ const body = (await res.json()) as { tags?: string[] | null };
217
+ if (Array.isArray(body.tags)) tags.push(...body.tags);
218
+ url = nextPageUrl(res.headers.get("link"), `https://${this.host}`);
219
+ }
220
+ return tags;
160
221
  }
161
222
 
162
223
  /** Upload `bytes` as a blob (skipped when already present), returning its
@@ -1,53 +1,4 @@
1
- import { splitIntegrity } from "@telorun/analyzer";
2
-
3
- export const OCI_SCHEME = "oci://";
4
-
5
- /** A parsed `oci://host/repo@reference` module ref.
6
- *
7
- * - `host` — the registry host (`ghcr.io`, `123.dkr.ecr.us-east-1.amazonaws.com`).
8
- * - `repo` — the repository path (`aws/telo-s3`), possibly multi-segment.
9
- * - `reference` — a tag (`1.2.0`) or a digest (`sha256:...`); the OCI address.
10
- * - `integrity` — Telo's inline `sha256-<base64url>` hash when the ref is pinned
11
- * (authoritative across transports; the OCI digest is only corroborating). */
12
- export interface ParsedOciRef {
13
- host: string;
14
- repo: string;
15
- reference: string;
16
- integrity?: string;
17
- }
18
-
19
- /** True when `ref` uses the `oci://` scheme (integrity fragment tolerated). */
20
- export function isOciRef(ref: string): boolean {
21
- return splitIntegrity(ref).base.startsWith(OCI_SCHEME);
22
- }
23
-
24
- /** Parse `oci://host/repo@reference[#sha256-...]`. Throws on a malformed ref.
25
- * A `reference` may be a tag or a `sha256:` digest; when absent (`@` omitted)
26
- * it defaults to `latest`, matching OCI tooling. */
27
- export function parseOciRef(ref: string): ParsedOciRef {
28
- const { base, integrity } = splitIntegrity(ref);
29
- if (!base.startsWith(OCI_SCHEME)) {
30
- throw new Error(`Invalid OCI reference '${ref}', expected oci://host/repo@reference`);
31
- }
32
- const rest = base.slice(OCI_SCHEME.length);
33
- const slash = rest.indexOf("/");
34
- if (slash <= 0) {
35
- throw new Error(`Invalid OCI reference '${ref}', missing repository path after host`);
36
- }
37
- const host = rest.slice(0, slash);
38
- let repoAndRef = rest.slice(slash + 1);
39
-
40
- let reference = "latest";
41
- const at = repoAndRef.lastIndexOf("@");
42
- // A digest reference is `repo@sha256:...` — the `@` before `sha256:` is the
43
- // separator, not part of the repo. A tag reference has no `@`.
44
- if (at > 0) {
45
- reference = repoAndRef.slice(at + 1);
46
- repoAndRef = repoAndRef.slice(0, at);
47
- }
48
- const repo = repoAndRef;
49
- if (!host || !repo || !reference) {
50
- throw new Error(`Invalid OCI reference '${ref}', expected oci://host/repo@reference`);
51
- }
52
- return { host, repo, reference, integrity };
53
- }
1
+ // The OCI ref grammar is pure string parsing shared with the browser-safe
2
+ // manifest-cache key helper, so it lives in `@telorun/analyzer`; this shim
3
+ // keeps the kernel-internal import sites stable.
4
+ export { OCI_SCHEME, isOciRef, parseOciRef, type ParsedOciRef } from "@telorun/analyzer";
@@ -125,6 +125,11 @@ export class OciTransport implements Transport {
125
125
  return tags;
126
126
  }
127
127
 
128
+ async digest(ref: string): Promise<string | null> {
129
+ const { host, repo, reference } = parseOciRef(ref);
130
+ return new OciClient(host, repo).headManifest(reference);
131
+ }
132
+
128
133
  async fetchArtifact(ref: string): Promise<FetchedArtifact> {
129
134
  return pullVerified(ref);
130
135
  }
@@ -5,6 +5,7 @@ import {
5
5
  RegistrySource,
6
6
  isRegistryRef,
7
7
  parseModuleRef,
8
+ sha256Base64Url,
8
9
  splitIntegrity,
9
10
  type ManifestSource,
10
11
  } from "@telorun/analyzer";
@@ -13,6 +14,7 @@ import { createHash } from "crypto";
13
14
  import { computeFilesIntegrity, injectFilesIntegrity } from "../bundle/files-integrity.js";
14
15
  import { readOwnerManifest } from "../bundle/module-manifest.js";
15
16
  import { makeTarGz, readTarGz, toPayloadFiles } from "../bundle/tar.js";
17
+ import { assertPublicEgress } from "./egress-guard.js";
16
18
  import type {
17
19
  FetchedArtifact,
18
20
  PublishBundle,
@@ -88,7 +90,12 @@ export class RegistryTransport implements Transport {
88
90
  this.httpSource.supports(ref) ? this.httpSource : this.registrySource;
89
91
  this.source = {
90
92
  supports: (url) => this.supports(url),
91
- read: (url) => pick(url).read(url),
93
+ read: async (url) => {
94
+ // The browser-safe sources do the fetch; the Node-side egress policy
95
+ // is enforced here, on the host the read will actually hit.
96
+ await assertPublicEgress(this.httpSource.supports(url) ? url : this.registryUrl);
97
+ return pick(url).read(url);
98
+ },
92
99
  resolveRelative: (base, relative) => pick(base).resolveRelative(base, relative),
93
100
  };
94
101
  }
@@ -151,6 +158,7 @@ export class RegistryTransport implements Transport {
151
158
  if (!isRegistryRef(ref)) return null;
152
159
  const { modulePath } = parseModuleRef(ref);
153
160
  const url = `${this.registryUrl.replace(/\/+$/, "")}/${modulePath}`;
161
+ await assertPublicEgress(url);
154
162
  const res = await fetch(url, { headers: { accept: "application/json" } });
155
163
  if (res.status === 404) return null;
156
164
  if (!res.ok) {
@@ -160,6 +168,30 @@ export class RegistryTransport implements Transport {
160
168
  return Array.isArray(body.versions) ? body.versions : [];
161
169
  }
162
170
 
171
+ async digest(ref: string): Promise<string | null> {
172
+ // Mirrors the sources' fetch-URL derivation: a direct URL points at (or
173
+ // contains) the YAML file; a bare registry ref folds into the registry
174
+ // layout. The digest is Telo's canonical hash over the `telo.yaml` bytes.
175
+ const { base } = splitIntegrity(ref);
176
+ let fetchUrl: string;
177
+ if (base.startsWith("http://") || base.startsWith("https://")) {
178
+ fetchUrl = base.includes(".yaml") ? base : `${base}/${DEFAULT_MANIFEST_FILENAME}`;
179
+ } else if (isRegistryRef(ref)) {
180
+ const { modulePath, version } = parseModuleRef(ref);
181
+ fetchUrl = `${this.registryUrl.replace(/\/+$/, "")}/${modulePath}/${version}/${DEFAULT_MANIFEST_FILENAME}`;
182
+ } else {
183
+ return null;
184
+ }
185
+ await assertPublicEgress(fetchUrl);
186
+ const res = await fetch(fetchUrl);
187
+ if (res.status === 404) return null;
188
+ if (!res.ok) {
189
+ throw new Error(`Registry returned ${res.status} ${res.statusText} for ${fetchUrl}`);
190
+ }
191
+ const bytes = new Uint8Array(await res.arrayBuffer());
192
+ return `sha256-${await sha256Base64Url(bytes)}`;
193
+ }
194
+
163
195
  async fetchArtifact(ref: string): Promise<FetchedArtifact> {
164
196
  // `read` verifies the manifest bytes against the inline `#sha256-...` hash.
165
197
  const { text: manifest, source } = await this.source.read(ref);
@@ -168,6 +200,7 @@ export class RegistryTransport implements Transport {
168
200
 
169
201
  // The payload rides beside the manifest as `module.tar.gz`.
170
202
  const tarUrl = source.replace(/\/telo\.yaml$/, "/module.tar.gz");
203
+ await assertPublicEgress(tarUrl);
171
204
  const res = await fetch(tarUrl);
172
205
  if (!res.ok) {
173
206
  throw new Error(`could not fetch bundle ${tarUrl}: ${res.status} ${res.statusText}`);
@@ -47,6 +47,12 @@ export class TransportRegistry {
47
47
  return this.require(ref).fetchArtifact(ref);
48
48
  }
49
49
 
50
+ /** Cheap content-identity digest for `ref` via its owning transport; `null`
51
+ * when the version does not exist. Throws when no transport owns the ref. */
52
+ digest(ref: string): Promise<string | null> {
53
+ return this.require(ref).digest(ref);
54
+ }
55
+
50
56
  /** Publish `bundle` to `destination` via the transport its scheme selects.
51
57
  * Throws when no transport owns the destination. */
52
58
  publish(
@@ -89,6 +89,16 @@ export interface Transport {
89
89
  * the out-of-band bundle fetch that used to sit outside the source chain. */
90
90
  fetchArtifact(ref: string): Promise<FetchedArtifact>;
91
91
 
92
+ /** Cheap content-identity digest of what `ref` currently resolves to — no
93
+ * payload download. Opaque and transport-specific (OCI: the image manifest's
94
+ * `sha256:<hex>` content digest; HTTP: `sha256-<base64url>` over the
95
+ * `telo.yaml` bytes), so compare for equality only, never across transports.
96
+ * Returns `null` when the version does not exist. Version content
97
+ * immutability is a convention no transport enforces — a tag can be
98
+ * re-pushed to different bytes — so the discovery tracker records this
99
+ * digest per version and re-checks it on every track. */
100
+ digest(ref: string): Promise<string | null>;
101
+
92
102
  /** Push `bundle` to `destination` (a base ref / repo whose scheme this
93
103
  * transport owns), pinning the payload and writing the transport-native
94
104
  * artifact shape. Throws on failure. Used by `telo publish`. */