@provos/ironcurtain 0.7.2 → 0.9.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 +29 -100
- package/dist/auth/auth-command.d.ts +15 -0
- package/dist/auth/auth-command.js +424 -0
- package/dist/auth/auth-command.js.map +1 -0
- package/dist/auth/oauth-flow.d.ts +46 -0
- package/dist/auth/oauth-flow.js +279 -0
- package/dist/auth/oauth-flow.js.map +1 -0
- package/dist/auth/oauth-provider.d.ts +55 -0
- package/dist/auth/oauth-provider.js +73 -0
- package/dist/auth/oauth-provider.js.map +1 -0
- package/dist/auth/oauth-registry.d.ts +34 -0
- package/dist/auth/oauth-registry.js +69 -0
- package/dist/auth/oauth-registry.js.map +1 -0
- package/dist/auth/oauth-token-provider.d.ts +52 -0
- package/dist/auth/oauth-token-provider.js +159 -0
- package/dist/auth/oauth-token-provider.js.map +1 -0
- package/dist/auth/oauth-token-store.d.ts +30 -0
- package/dist/auth/oauth-token-store.js +93 -0
- package/dist/auth/oauth-token-store.js.map +1 -0
- package/dist/auth/providers/google-scopes.d.ts +35 -0
- package/dist/auth/providers/google-scopes.js +183 -0
- package/dist/auth/providers/google-scopes.js.map +1 -0
- package/dist/auth/providers/google.d.ts +2 -0
- package/dist/auth/providers/google.js +28 -0
- package/dist/auth/providers/google.js.map +1 -0
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/config-command.js +39 -1
- package/dist/config/config-command.js.map +1 -1
- package/dist/config/constitution-readonly.md +14 -11
- package/dist/config/constitution-user-base.md +15 -0
- package/dist/config/first-start.js +4 -1
- package/dist/config/first-start.js.map +1 -1
- package/dist/config/generated/test-scenarios.json +1 -1
- package/dist/config/generated/tool-annotations.json +10617 -296
- package/dist/config/generated-readonly/compiled-policy.json +787 -244
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/mcp-servers.json +31 -2
- package/dist/config/paths.d.ts +20 -0
- package/dist/config/paths.js +43 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/types.d.ts +8 -0
- package/dist/config/user-config.d.ts +29 -0
- package/dist/config/user-config.js +30 -0
- package/dist/config/user-config.js.map +1 -1
- package/dist/cron/constitution-generator.d.ts +2 -1
- package/dist/cron/constitution-generator.js +35 -36
- package/dist/cron/constitution-generator.js.map +1 -1
- package/dist/cron/headless-transport.d.ts +4 -0
- package/dist/cron/headless-transport.js +1 -1
- package/dist/cron/headless-transport.js.map +1 -1
- package/dist/daemon/ironcurtain-daemon.js +7 -1
- package/dist/daemon/ironcurtain-daemon.js.map +1 -1
- package/dist/docker/adapters/claude-code.js +32 -5
- package/dist/docker/adapters/claude-code.js.map +1 -1
- package/dist/docker/agent-adapter.d.ts +40 -0
- package/dist/docker/claude-md-seed.d.ts +27 -0
- package/dist/docker/claude-md-seed.js +42 -0
- package/dist/docker/claude-md-seed.js.map +1 -0
- package/dist/docker/docker-agent-session.d.ts +3 -1
- package/dist/docker/docker-agent-session.js +51 -28
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-infrastructure.d.ts +15 -1
- package/dist/docker/docker-infrastructure.js +105 -9
- package/dist/docker/docker-infrastructure.js.map +1 -1
- package/dist/docker/docker-manager.d.ts +2 -0
- package/dist/docker/docker-manager.js +9 -1
- package/dist/docker/docker-manager.js.map +1 -1
- package/dist/docker/mitm-proxy.d.ts +65 -0
- package/dist/docker/mitm-proxy.js +288 -31
- package/dist/docker/mitm-proxy.js.map +1 -1
- package/dist/docker/package-types.d.ts +127 -0
- package/dist/docker/package-types.js +8 -0
- package/dist/docker/package-types.js.map +1 -0
- package/dist/docker/package-validator.d.ts +41 -0
- package/dist/docker/package-validator.js +90 -0
- package/dist/docker/package-validator.js.map +1 -0
- package/dist/docker/platform.d.ts +6 -6
- package/dist/docker/platform.js +8 -6
- package/dist/docker/platform.js.map +1 -1
- package/dist/docker/proxy-tools.d.ts +80 -0
- package/dist/docker/proxy-tools.js +248 -0
- package/dist/docker/proxy-tools.js.map +1 -0
- package/dist/docker/pty-session.d.ts +15 -0
- package/dist/docker/pty-session.js +253 -73
- package/dist/docker/pty-session.js.map +1 -1
- package/dist/docker/pty-types.d.ts +28 -0
- package/dist/docker/pty-types.js +2 -0
- package/dist/docker/pty-types.js.map +1 -1
- package/dist/docker/registry-proxy.d.ts +142 -0
- package/dist/docker/registry-proxy.js +856 -0
- package/dist/docker/registry-proxy.js.map +1 -0
- package/dist/escalation/escalation-watcher.d.ts +3 -1
- package/dist/escalation/escalation-watcher.js +6 -2
- package/dist/escalation/escalation-watcher.js.map +1 -1
- package/dist/escalation/listener-command.js +24 -10
- package/dist/escalation/listener-command.js.map +1 -1
- package/dist/escalation/listener-lock.d.ts +5 -0
- package/dist/escalation/listener-lock.js +14 -0
- package/dist/escalation/listener-lock.js.map +1 -1
- package/dist/index.js +16 -3
- package/dist/index.js.map +1 -1
- package/dist/memory/auto-save.d.ts +25 -0
- package/dist/memory/auto-save.js +89 -0
- package/dist/memory/auto-save.js.map +1 -0
- package/dist/memory/memory-annotations.d.ts +47 -0
- package/dist/memory/memory-annotations.js +87 -0
- package/dist/memory/memory-annotations.js.map +1 -0
- package/dist/memory/memory-prompt.d.ts +12 -0
- package/dist/memory/memory-prompt.js +20 -0
- package/dist/memory/memory-prompt.js.map +1 -0
- package/dist/memory/resolve-memory-path.d.ts +20 -0
- package/dist/memory/resolve-memory-path.js +30 -0
- package/dist/memory/resolve-memory-path.js.map +1 -0
- package/dist/mux/mux-app.d.ts +4 -0
- package/dist/mux/mux-app.js +246 -28
- package/dist/mux/mux-app.js.map +1 -1
- package/dist/mux/mux-command.d.ts +2 -2
- package/dist/mux/mux-command.js +14 -22
- package/dist/mux/mux-command.js.map +1 -1
- package/dist/mux/mux-escalation-manager.d.ts +9 -3
- package/dist/mux/mux-escalation-manager.js +69 -19
- package/dist/mux/mux-escalation-manager.js.map +1 -1
- package/dist/mux/mux-input-handler.d.ts +59 -3
- package/dist/mux/mux-input-handler.js +270 -32
- package/dist/mux/mux-input-handler.js.map +1 -1
- package/dist/mux/mux-renderer.d.ts +19 -1
- package/dist/mux/mux-renderer.js +466 -11
- package/dist/mux/mux-renderer.js.map +1 -1
- package/dist/mux/persona-scanner.d.ts +19 -0
- package/dist/mux/persona-scanner.js +53 -0
- package/dist/mux/persona-scanner.js.map +1 -0
- package/dist/mux/pty-bridge.d.ts +8 -0
- package/dist/mux/pty-bridge.js +19 -1
- package/dist/mux/pty-bridge.js.map +1 -1
- package/dist/mux/session-scanner.d.ts +18 -0
- package/dist/mux/session-scanner.js +65 -0
- package/dist/mux/session-scanner.js.map +1 -0
- package/dist/mux/types.d.ts +36 -1
- package/dist/mux/types.js +14 -1
- package/dist/mux/types.js.map +1 -1
- package/dist/persona/persona-command.js +130 -93
- package/dist/persona/persona-command.js.map +1 -1
- package/dist/persona/persona-prompt.d.ts +7 -7
- package/dist/persona/persona-prompt.js +14 -29
- package/dist/persona/persona-prompt.js.map +1 -1
- package/dist/persona/resolve.js +5 -2
- package/dist/persona/resolve.js.map +1 -1
- package/dist/pipeline/annotate.js +1 -7
- package/dist/pipeline/annotate.js.map +1 -1
- package/dist/pipeline/compile.d.ts +5 -2
- package/dist/pipeline/compile.js +23 -11
- package/dist/pipeline/compile.js.map +1 -1
- package/dist/pipeline/constitution-compiler.d.ts +302 -3
- package/dist/pipeline/constitution-compiler.js +285 -23
- package/dist/pipeline/constitution-compiler.js.map +1 -1
- package/dist/pipeline/generate-with-repair.d.ts +2 -2
- package/dist/pipeline/generate-with-repair.js +2 -2
- package/dist/pipeline/handwritten-scenarios.d.ts +20 -7
- package/dist/pipeline/handwritten-scenarios.js +20 -41
- package/dist/pipeline/handwritten-scenarios.js.map +1 -1
- package/dist/pipeline/list-resolver.d.ts +18 -20
- package/dist/pipeline/list-resolver.js +28 -36
- package/dist/pipeline/list-resolver.js.map +1 -1
- package/dist/pipeline/pipeline-runner.d.ts +102 -13
- package/dist/pipeline/pipeline-runner.js +497 -237
- package/dist/pipeline/pipeline-runner.js.map +1 -1
- package/dist/pipeline/pipeline-shared.d.ts +7 -9
- package/dist/pipeline/pipeline-shared.js +8 -18
- package/dist/pipeline/pipeline-shared.js.map +1 -1
- package/dist/pipeline/policy-verifier.d.ts +32 -5
- package/dist/pipeline/policy-verifier.js +137 -8
- package/dist/pipeline/policy-verifier.js.map +1 -1
- package/dist/pipeline/proxy-mcp-connections.d.ts +25 -0
- package/dist/pipeline/proxy-mcp-connections.js +121 -0
- package/dist/pipeline/proxy-mcp-connections.js.map +1 -0
- package/dist/pipeline/refresh-lists.js +21 -13
- package/dist/pipeline/refresh-lists.js.map +1 -1
- package/dist/pipeline/scenario-generator.d.ts +29 -49
- package/dist/pipeline/scenario-generator.js +180 -143
- package/dist/pipeline/scenario-generator.js.map +1 -1
- package/dist/pipeline/scenario-schema-validator.d.ts +51 -0
- package/dist/pipeline/scenario-schema-validator.js +132 -0
- package/dist/pipeline/scenario-schema-validator.js.map +1 -0
- package/dist/pipeline/tool-annotator.d.ts +9 -8
- package/dist/pipeline/tool-annotator.js +48 -95
- package/dist/pipeline/tool-annotator.js.map +1 -1
- package/dist/pipeline/types.d.ts +31 -24
- package/dist/sandbox/index.d.ts +26 -10
- package/dist/sandbox/index.js +151 -40
- package/dist/sandbox/index.js.map +1 -1
- package/dist/servers/fetch-server.js +10 -5
- package/dist/servers/fetch-server.js.map +1 -1
- package/dist/session/agent-session.d.ts +8 -20
- package/dist/session/agent-session.js +22 -70
- package/dist/session/agent-session.js.map +1 -1
- package/dist/session/base-transport.d.ts +17 -1
- package/dist/session/base-transport.js +25 -1
- package/dist/session/base-transport.js.map +1 -1
- package/dist/session/cli-transport.d.ts +5 -0
- package/dist/session/cli-transport.js +48 -25
- package/dist/session/cli-transport.js.map +1 -1
- package/dist/session/index.d.ts +25 -1
- package/dist/session/index.js +57 -8
- package/dist/session/index.js.map +1 -1
- package/dist/session/prompts.js +8 -9
- package/dist/session/prompts.js.map +1 -1
- package/dist/session/session-manager.d.ts +6 -0
- package/dist/session/session-manager.js +12 -0
- package/dist/session/session-manager.js.map +1 -1
- package/dist/session/types.d.ts +11 -1
- package/dist/session/types.js.map +1 -1
- package/dist/session/workspace-validation.js +37 -6
- package/dist/session/workspace-validation.js.map +1 -1
- package/dist/signal/signal-bot-daemon.js +24 -8
- package/dist/signal/signal-bot-daemon.js.map +1 -1
- package/dist/signal/signal-transport.d.ts +5 -2
- package/dist/signal/signal-transport.js +3 -3
- package/dist/signal/signal-transport.js.map +1 -1
- package/dist/trusted-process/approval-whitelist.d.ts +117 -0
- package/dist/trusted-process/approval-whitelist.js +237 -0
- package/dist/trusted-process/approval-whitelist.js.map +1 -0
- package/dist/trusted-process/gworkspace-credentials.d.ts +40 -0
- package/dist/trusted-process/gworkspace-credentials.js +45 -0
- package/dist/trusted-process/gworkspace-credentials.js.map +1 -0
- package/dist/trusted-process/index.d.ts +11 -1
- package/dist/trusted-process/index.js +83 -53
- package/dist/trusted-process/index.js.map +1 -1
- package/dist/trusted-process/mcp-proxy-server.d.ts +23 -0
- package/dist/trusted-process/mcp-proxy-server.js +409 -94
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/policy-engine.d.ts +12 -3
- package/dist/trusted-process/policy-engine.js +33 -23
- package/dist/trusted-process/policy-engine.js.map +1 -1
- package/dist/trusted-process/policy-types.d.ts +17 -0
- package/dist/trusted-process/sandbox-integration.d.ts +23 -0
- package/dist/trusted-process/sandbox-integration.js +115 -3
- package/dist/trusted-process/sandbox-integration.js.map +1 -1
- package/dist/trusted-process/token-file-refresher.d.ts +69 -0
- package/dist/trusted-process/token-file-refresher.js +120 -0
- package/dist/trusted-process/token-file-refresher.js.map +1 -0
- package/dist/types/argument-roles.d.ts +7 -1
- package/dist/types/argument-roles.js +50 -0
- package/dist/types/argument-roles.js.map +1 -1
- package/dist/types/audit.d.ts +11 -0
- package/docker/Dockerfile.base +22 -0
- package/docker/Dockerfile.base.arm64 +44 -4
- package/docker/Dockerfile.claude-code +2 -2
- package/docker/entrypoint-claude-code.sh +24 -6
- package/package.json +8 -2
- package/src/config/constitution-user-base.md +15 -0
- package/src/config/generated/test-scenarios.json +1 -1
- package/src/config/generated/tool-annotations.json +10617 -296
- package/src/config/mcp-servers.json +31 -2
- package/dist/pipeline/mcp-connections.d.ts +0 -16
- package/dist/pipeline/mcp-connections.js +0 -61
- package/dist/pipeline/mcp-connections.js.map +0 -1
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry proxy: URL parsing, metadata filtering, and tarball backstop
|
|
3
|
+
* for npm and PyPI package registries.
|
|
4
|
+
*
|
|
5
|
+
* This module provides:
|
|
6
|
+
* - URL parsers that extract PackageIdentity from HTTP request paths
|
|
7
|
+
* - Metadata filtering functions that remove disallowed versions
|
|
8
|
+
* - Tarball backstop logic that blocks direct tarball URL access
|
|
9
|
+
* - Built-in registry configurations for npm and PyPI
|
|
10
|
+
*/
|
|
11
|
+
import * as https from 'node:https';
|
|
12
|
+
import { promises as fs } from 'node:fs';
|
|
13
|
+
import { packageCacheKey, canonicalPackageName } from './package-validator.js';
|
|
14
|
+
import * as logger from '../logger.js';
|
|
15
|
+
// ── Built-in registry configs ───────────────────────────────────────
|
|
16
|
+
export const npmRegistry = {
|
|
17
|
+
host: 'registry.npmjs.org',
|
|
18
|
+
displayName: 'npm',
|
|
19
|
+
type: 'npm',
|
|
20
|
+
};
|
|
21
|
+
export const pypiRegistry = {
|
|
22
|
+
host: 'pypi.org',
|
|
23
|
+
displayName: 'PyPI',
|
|
24
|
+
type: 'pypi',
|
|
25
|
+
mirrorHosts: ['files.pythonhosted.org'],
|
|
26
|
+
};
|
|
27
|
+
export const debianRegistry = {
|
|
28
|
+
host: 'deb.debian.org',
|
|
29
|
+
displayName: 'Debian APT',
|
|
30
|
+
type: 'debian',
|
|
31
|
+
mirrorHosts: ['security.debian.org'],
|
|
32
|
+
};
|
|
33
|
+
// ── PyPI sidecar suffixes (PEP 658 metadata, PEP 714 provenance) ────
|
|
34
|
+
const PYPI_SIDECAR_SUFFIXES = ['.metadata', '.provenance'];
|
|
35
|
+
/**
|
|
36
|
+
* Strips known PEP 658/714 sidecar suffixes from a PyPI filename.
|
|
37
|
+
* e.g. "numpy-1.26.0-cp312-cp312-manylinux_2_17_x86_64.whl.metadata"
|
|
38
|
+
* -> "numpy-1.26.0-cp312-cp312-manylinux_2_17_x86_64.whl"
|
|
39
|
+
* Returns the filename unchanged if no known suffix matches.
|
|
40
|
+
*/
|
|
41
|
+
function stripPypiSidecarSuffix(filename) {
|
|
42
|
+
for (const suffix of PYPI_SIDECAR_SUFFIXES) {
|
|
43
|
+
if (filename.endsWith(suffix)) {
|
|
44
|
+
return filename.slice(0, -suffix.length);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return filename;
|
|
48
|
+
}
|
|
49
|
+
// ── URL Parsers ─────────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Parses an npm registry URL path into a PackageIdentity.
|
|
52
|
+
* Returns undefined if the path doesn't match a known pattern.
|
|
53
|
+
*
|
|
54
|
+
* Patterns:
|
|
55
|
+
* GET /@scope/name -> metadata (scope + name)
|
|
56
|
+
* GET /@scope%2fname -> metadata (URL-encoded scope)
|
|
57
|
+
* GET /name -> metadata (unscoped)
|
|
58
|
+
* GET /name/-/name-1.0.0.tgz -> tarball (unscoped)
|
|
59
|
+
* GET /@scope/name/-/name-1.0.0.tgz -> tarball (scoped)
|
|
60
|
+
*/
|
|
61
|
+
export function parseNpmUrl(path) {
|
|
62
|
+
// Clean query string
|
|
63
|
+
const cleanPath = path.split('?')[0];
|
|
64
|
+
// Handle URL-encoded scoped packages: /@scope%2fname -> /@scope/name
|
|
65
|
+
let decodedPath;
|
|
66
|
+
try {
|
|
67
|
+
decodedPath = decodeURIComponent(cleanPath);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Malformed percent-encoding (e.g., %E0%A4) -- fail-closed
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
// Tarball request: contains /-/
|
|
74
|
+
const tarballIndex = decodedPath.indexOf('/-/');
|
|
75
|
+
if (tarballIndex >= 0) {
|
|
76
|
+
const packagePart = decodedPath.substring(1, tarballIndex); // strip leading /
|
|
77
|
+
const filename = decodedPath.substring(tarballIndex + 3); // after /-/
|
|
78
|
+
const version = extractVersionFromNpmTarball(filename, packagePart);
|
|
79
|
+
const parsed = parseNpmPackagePart(packagePart);
|
|
80
|
+
if (!parsed)
|
|
81
|
+
return undefined;
|
|
82
|
+
return { registry: 'npm', ...parsed, version };
|
|
83
|
+
}
|
|
84
|
+
// Metadata request: /@scope/name or /name
|
|
85
|
+
const packagePart = decodedPath.substring(1); // strip leading /
|
|
86
|
+
if (!packagePart || packagePart === '-')
|
|
87
|
+
return undefined;
|
|
88
|
+
const parsed = parseNpmPackagePart(packagePart);
|
|
89
|
+
if (!parsed)
|
|
90
|
+
return undefined;
|
|
91
|
+
return { registry: 'npm', ...parsed };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Parses the package part of an npm URL (without leading /).
|
|
95
|
+
* Returns { name, scope? } or undefined.
|
|
96
|
+
*/
|
|
97
|
+
function parseNpmPackagePart(packagePart) {
|
|
98
|
+
if (packagePart.startsWith('@')) {
|
|
99
|
+
const slashIdx = packagePart.indexOf('/');
|
|
100
|
+
if (slashIdx < 0)
|
|
101
|
+
return undefined;
|
|
102
|
+
const scope = packagePart.substring(1, slashIdx);
|
|
103
|
+
const name = packagePart.substring(slashIdx + 1);
|
|
104
|
+
if (!scope || !name)
|
|
105
|
+
return undefined;
|
|
106
|
+
return { name, scope };
|
|
107
|
+
}
|
|
108
|
+
// Ensure we don't parse paths with extra segments as metadata
|
|
109
|
+
// /name/something should not be a metadata request (except /-/ which is handled above)
|
|
110
|
+
if (packagePart.includes('/'))
|
|
111
|
+
return undefined;
|
|
112
|
+
if (!packagePart)
|
|
113
|
+
return undefined;
|
|
114
|
+
return { name: packagePart };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Extracts version from an npm tarball filename.
|
|
118
|
+
* Format: {name}-{version}.tgz
|
|
119
|
+
*/
|
|
120
|
+
function extractVersionFromNpmTarball(filename, packagePart) {
|
|
121
|
+
// For scoped packages, filename is just name-version.tgz (no scope prefix)
|
|
122
|
+
const name = packagePart.includes('/') ? (packagePart.split('/').pop() ?? packagePart) : packagePart;
|
|
123
|
+
const prefix = `${name}-`;
|
|
124
|
+
if (!filename.startsWith(prefix) || !filename.endsWith('.tgz'))
|
|
125
|
+
return undefined;
|
|
126
|
+
return filename.slice(prefix.length, -4); // remove prefix and .tgz
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Parses a PyPI Simple Repository URL path into a PackageIdentity.
|
|
130
|
+
* Returns undefined if the path doesn't match.
|
|
131
|
+
*
|
|
132
|
+
* Pattern: GET /simple/{name}/
|
|
133
|
+
*/
|
|
134
|
+
export function parsePypiSimpleUrl(path) {
|
|
135
|
+
const cleanPath = path.split('?')[0];
|
|
136
|
+
// Match /simple/package-name/ (with or without trailing slash)
|
|
137
|
+
const match = cleanPath.match(/^\/simple\/([a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?)\/?$/);
|
|
138
|
+
if (!match)
|
|
139
|
+
return undefined;
|
|
140
|
+
return { registry: 'pypi', name: normalizePypiName(match[1]) };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Parses a PyPI tarball URL path into a PackageIdentity.
|
|
144
|
+
* Returns undefined if the path doesn't match.
|
|
145
|
+
*
|
|
146
|
+
* Tarball URLs on files.pythonhosted.org:
|
|
147
|
+
* /packages/.../numpy-1.26.0.tar.gz
|
|
148
|
+
* /packages/.../numpy-1.26.0-cp312-cp312-manylinux_2_17_x86_64.whl
|
|
149
|
+
*/
|
|
150
|
+
export function parsePypiTarballUrl(path) {
|
|
151
|
+
const cleanPath = path.split('?')[0];
|
|
152
|
+
const filename = cleanPath.split('/').pop();
|
|
153
|
+
if (!filename)
|
|
154
|
+
return undefined;
|
|
155
|
+
return extractPypiPackageFromFilename(filename);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Extracts package name and version from a PyPI distribution filename.
|
|
159
|
+
*
|
|
160
|
+
* Handles both sdist and wheel formats:
|
|
161
|
+
* numpy-1.26.0.tar.gz -> { name: 'numpy', version: '1.26.0' }
|
|
162
|
+
* numpy-1.26.0-cp312-*.whl -> { name: 'numpy', version: '1.26.0' }
|
|
163
|
+
*/
|
|
164
|
+
export function extractPypiPackageFromFilename(filename) {
|
|
165
|
+
// Strip known sidecar suffixes (e.g. .whl.metadata -> .whl) before parsing
|
|
166
|
+
const baseFilename = stripPypiSidecarSuffix(filename);
|
|
167
|
+
// Wheel format: {name}-{version}(-{tags}).whl
|
|
168
|
+
// Sdist format: {name}-{version}.tar.gz or {name}-{version}.zip
|
|
169
|
+
// The name-version boundary is the first hyphen followed by a digit
|
|
170
|
+
const match = baseFilename.match(/^([a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?)-(\d[^-]*?)(?:\.tar\.gz|\.zip|-[a-zA-Z].*\.whl)$/);
|
|
171
|
+
if (!match)
|
|
172
|
+
return undefined;
|
|
173
|
+
return {
|
|
174
|
+
registry: 'pypi',
|
|
175
|
+
name: normalizePypiName(match[1]),
|
|
176
|
+
version: match[2],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Normalizes a PyPI package name to lowercase with hyphens.
|
|
181
|
+
* PEP 503: package names are case-insensitive and treat
|
|
182
|
+
* hyphens, underscores, and dots as equivalent.
|
|
183
|
+
*/
|
|
184
|
+
export function normalizePypiName(name) {
|
|
185
|
+
return name.toLowerCase().replace(/[-_.]+/g, '-');
|
|
186
|
+
}
|
|
187
|
+
// ── Debian URL parsing ──────────────────────────────────────────────
|
|
188
|
+
/**
|
|
189
|
+
* Extracts package name and version from a Debian `.deb` filename.
|
|
190
|
+
*
|
|
191
|
+
* Format: {name}_{version}_{arch}.deb
|
|
192
|
+
* Examples:
|
|
193
|
+
* libssl3_3.0.11-1~deb12u2_arm64.deb -> name='libssl3', version='3.0.11-1~deb12u2'
|
|
194
|
+
* gcc-14-base_14.2.0-19_arm64.deb -> name='gcc-14-base', version='14.2.0-19'
|
|
195
|
+
*
|
|
196
|
+
* Epoch versions (e.g., 1:2.3-4) use %3a URL-encoding for the colon.
|
|
197
|
+
*/
|
|
198
|
+
export function extractDebianPackageFromFilename(filename) {
|
|
199
|
+
// Must end with .deb
|
|
200
|
+
if (!filename.endsWith('.deb'))
|
|
201
|
+
return undefined;
|
|
202
|
+
// Strip .deb suffix
|
|
203
|
+
const base = filename.slice(0, -4);
|
|
204
|
+
// Split on underscores: name_version_arch
|
|
205
|
+
const firstUnderscore = base.indexOf('_');
|
|
206
|
+
if (firstUnderscore < 1)
|
|
207
|
+
return undefined;
|
|
208
|
+
const lastUnderscore = base.lastIndexOf('_');
|
|
209
|
+
if (lastUnderscore <= firstUnderscore)
|
|
210
|
+
return undefined;
|
|
211
|
+
const name = base.substring(0, firstUnderscore);
|
|
212
|
+
let version = base.substring(firstUnderscore + 1, lastUnderscore);
|
|
213
|
+
// Decode URL-encoded epoch (e.g., 1%3a2.3-4 -> 1:2.3-4)
|
|
214
|
+
try {
|
|
215
|
+
version = decodeURIComponent(version);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Malformed encoding -- use as-is
|
|
219
|
+
}
|
|
220
|
+
if (!name || !version)
|
|
221
|
+
return undefined;
|
|
222
|
+
return { registry: 'debian', name, version };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Parses a Debian repository URL path into a PackageIdentity.
|
|
226
|
+
* Returns undefined for non-.deb paths (metadata, Release files, etc.).
|
|
227
|
+
*
|
|
228
|
+
* Matches any URL whose last path segment ends with `.deb` — not restricted
|
|
229
|
+
* to `/pool/` paths, since mirrors and security repos may use different layouts.
|
|
230
|
+
* Non-.deb paths (Release, Packages.gz, GPG keys) return undefined for pass-through.
|
|
231
|
+
*/
|
|
232
|
+
export function parseDebianPackageUrl(path) {
|
|
233
|
+
const cleanPath = path.split('?')[0];
|
|
234
|
+
const filename = cleanPath.split('/').pop();
|
|
235
|
+
if (!filename || !filename.endsWith('.deb'))
|
|
236
|
+
return undefined;
|
|
237
|
+
return extractDebianPackageFromFilename(filename);
|
|
238
|
+
}
|
|
239
|
+
// ── Request classification ──────────────────────────────────────────
|
|
240
|
+
/** Returns true if the URL path is an npm metadata request (no /-/ segment). */
|
|
241
|
+
export function isNpmMetadataRequest(path) {
|
|
242
|
+
return parseNpmUrl(path) !== undefined && !path.includes('/-/');
|
|
243
|
+
}
|
|
244
|
+
/** Returns true if the URL path is an npm tarball download. */
|
|
245
|
+
export function isNpmTarballRequest(path) {
|
|
246
|
+
return path.includes('/-/') && parseNpmUrl(path)?.version !== undefined;
|
|
247
|
+
}
|
|
248
|
+
/** Returns true if the URL path is a PyPI Simple Repository request. */
|
|
249
|
+
export function isPypiSimpleRequest(path) {
|
|
250
|
+
return parsePypiSimpleUrl(path) !== undefined;
|
|
251
|
+
}
|
|
252
|
+
// ── Metadata filtering ─────────────────────────────────────────────
|
|
253
|
+
/**
|
|
254
|
+
* Filters an npm packument to remove disallowed versions.
|
|
255
|
+
*
|
|
256
|
+
* The filtered packument has:
|
|
257
|
+
* - Disallowed versions removed from `versions` and `time`
|
|
258
|
+
* - `dist-tags.latest` updated to point to the newest remaining version
|
|
259
|
+
* - `created` and `modified` timestamps preserved in `time`
|
|
260
|
+
*/
|
|
261
|
+
export function filterNpmPackument(packument, validator, packageName, scope) {
|
|
262
|
+
const denied = [];
|
|
263
|
+
const filteredVersions = {};
|
|
264
|
+
const filteredTime = {};
|
|
265
|
+
// Always keep 'created' and 'modified' timestamps
|
|
266
|
+
if (packument.time?.created)
|
|
267
|
+
filteredTime.created = packument.time.created;
|
|
268
|
+
if (packument.time?.modified)
|
|
269
|
+
filteredTime.modified = packument.time.modified;
|
|
270
|
+
for (const [version, manifest] of Object.entries(packument.versions)) {
|
|
271
|
+
const rawDate = packument.time?.[version] ? new Date(packument.time[version]) : undefined;
|
|
272
|
+
// Treat invalid dates (NaN) as missing — prevents fail-open on garbage timestamps
|
|
273
|
+
const publishedAt = rawDate && Number.isFinite(rawDate.getTime()) ? rawDate : undefined;
|
|
274
|
+
const decision = validator.validate({ registry: 'npm', name: packageName, scope, version }, { publishedAt });
|
|
275
|
+
if (decision.status === 'allow') {
|
|
276
|
+
filteredVersions[version] = manifest;
|
|
277
|
+
if (packument.time?.[version]) {
|
|
278
|
+
filteredTime[version] = packument.time[version];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
denied.push({ version, reason: decision.reason });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Update dist-tags to point to allowed versions only
|
|
286
|
+
const filteredDistTags = {};
|
|
287
|
+
for (const [tag, version] of Object.entries(packument['dist-tags'] ?? {})) {
|
|
288
|
+
if (version in filteredVersions) {
|
|
289
|
+
filteredDistTags[tag] = version;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// If 'latest' was removed, recalculate as newest remaining version
|
|
293
|
+
if (!filteredDistTags.latest && Object.keys(filteredVersions).length > 0) {
|
|
294
|
+
const newestVersion = Object.keys(filteredTime)
|
|
295
|
+
.filter((k) => k !== 'created' && k !== 'modified')
|
|
296
|
+
.sort((a, b) => new Date(filteredTime[b]).getTime() - new Date(filteredTime[a]).getTime())[0];
|
|
297
|
+
if (newestVersion) {
|
|
298
|
+
filteredDistTags.latest = newestVersion;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
filtered: {
|
|
303
|
+
...packument,
|
|
304
|
+
versions: filteredVersions,
|
|
305
|
+
'dist-tags': filteredDistTags,
|
|
306
|
+
time: filteredTime,
|
|
307
|
+
},
|
|
308
|
+
denied,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Filters a PyPI Simple Repository HTML page to remove disallowed versions.
|
|
313
|
+
*
|
|
314
|
+
* The HTML contains <a> elements with hrefs pointing to tarballs/wheels.
|
|
315
|
+
* Version is extracted from the filename in each href.
|
|
316
|
+
*
|
|
317
|
+
* Returns the filtered HTML, denied version list, and the set of allowed
|
|
318
|
+
* versions (collected during filtering to avoid re-parsing the HTML).
|
|
319
|
+
*/
|
|
320
|
+
export function filterPypiIndex(html, validator, packageName, versionTimestamps) {
|
|
321
|
+
const denied = [];
|
|
322
|
+
const allowedVersions = new Set();
|
|
323
|
+
// Track which versions we've already denied (avoid duplicate entries)
|
|
324
|
+
const deniedVersions = new Set();
|
|
325
|
+
// Match <a> tags with href attributes pointing to distribution files
|
|
326
|
+
const filtered = html.replace(/<a\s+[^>]*href="[^"]*"[^>]*>[^<]*<\/a>\s*/gi, (match) => {
|
|
327
|
+
const hrefMatch = match.match(/href="([^"]*)"/);
|
|
328
|
+
if (!hrefMatch)
|
|
329
|
+
return match;
|
|
330
|
+
const href = hrefMatch[1];
|
|
331
|
+
const filename = href.split('/').pop()?.split('#')[0]; // strip fragment
|
|
332
|
+
if (!filename)
|
|
333
|
+
return match;
|
|
334
|
+
const parsed = extractPypiPackageFromFilename(filename);
|
|
335
|
+
if (!parsed || !parsed.version)
|
|
336
|
+
return match;
|
|
337
|
+
const publishedAt = versionTimestamps.get(parsed.version);
|
|
338
|
+
const decision = validator.validate({ registry: 'pypi', name: packageName, version: parsed.version }, publishedAt !== undefined ? { publishedAt } : undefined);
|
|
339
|
+
if (decision.status === 'allow') {
|
|
340
|
+
allowedVersions.add(parsed.version);
|
|
341
|
+
return match;
|
|
342
|
+
}
|
|
343
|
+
// Only add to denied list once per version (multiple files per version)
|
|
344
|
+
if (!deniedVersions.has(parsed.version)) {
|
|
345
|
+
deniedVersions.add(parsed.version);
|
|
346
|
+
denied.push({ version: parsed.version, reason: decision.reason });
|
|
347
|
+
}
|
|
348
|
+
return ''; // Remove the link
|
|
349
|
+
});
|
|
350
|
+
return { filtered, denied, allowedVersions };
|
|
351
|
+
}
|
|
352
|
+
// ── AllowedVersionCache helpers ─────────────────────────────────────
|
|
353
|
+
/** Cache TTL: 1 hour. */
|
|
354
|
+
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
355
|
+
/**
|
|
356
|
+
* Gets allowed versions from cache, returning undefined on miss or expiry.
|
|
357
|
+
*/
|
|
358
|
+
export function getCachedVersions(cache, pkg) {
|
|
359
|
+
const key = packageCacheKey(pkg);
|
|
360
|
+
const entry = cache.get(key);
|
|
361
|
+
if (!entry)
|
|
362
|
+
return undefined;
|
|
363
|
+
if (Date.now() - entry.cachedAt.getTime() > CACHE_TTL_MS) {
|
|
364
|
+
cache.delete(key);
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
return entry.allowedVersions;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Stores allowed versions in the cache.
|
|
371
|
+
*/
|
|
372
|
+
export function setCachedVersions(cache, pkg, versions) {
|
|
373
|
+
const key = packageCacheKey(pkg);
|
|
374
|
+
cache.set(key, { allowedVersions: versions, cachedAt: new Date() });
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Handles an HTTP request routed to a registry host.
|
|
378
|
+
* Dispatches to metadata filtering or tarball backstop based on URL pattern.
|
|
379
|
+
*/
|
|
380
|
+
export async function handleRegistryRequest(registry, clientReq, clientRes, host, port, options) {
|
|
381
|
+
const path = clientReq.url ?? '/';
|
|
382
|
+
switch (registry.type) {
|
|
383
|
+
case 'npm':
|
|
384
|
+
if (isNpmMetadataRequest(path)) {
|
|
385
|
+
await handleNpmMetadata(registry, path, clientReq, clientRes, host, port, options);
|
|
386
|
+
}
|
|
387
|
+
else if (path.includes('/-/') && !path.startsWith('/-/')) {
|
|
388
|
+
// Tarball download: /-/ appears after package name (e.g., /express/-/express-1.0.0.tgz)
|
|
389
|
+
await handleTarballDownload(registry, path, clientRes, host, port, options, 'npm');
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
// npm internal endpoints (/-/ping, /-/v1/security/...) or unknown paths -- pass through
|
|
393
|
+
await forwardUpstream(clientReq, clientRes, host, port);
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
case 'pypi':
|
|
397
|
+
if (isPypiSimpleRequest(path)) {
|
|
398
|
+
await handlePypiSimple(registry, path, clientReq, clientRes, host, port, options);
|
|
399
|
+
}
|
|
400
|
+
else if (registry.mirrorHosts?.includes(host)) {
|
|
401
|
+
// Mirror hosts serve tarballs
|
|
402
|
+
await handleTarballDownload(registry, path, clientRes, host, port, options, 'pypi');
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
// All other pypi.org paths -- pass through
|
|
406
|
+
await forwardUpstream(clientReq, clientRes, host, port);
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
case 'debian':
|
|
410
|
+
await handleDebianRequest(registry, path, clientReq, clientRes, host, port, options);
|
|
411
|
+
break;
|
|
412
|
+
default: {
|
|
413
|
+
const _exhaustive = registry.type;
|
|
414
|
+
throw new Error(`Unknown registry type: ${String(_exhaustive)}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// ── Internal handlers ───────────────────────────────────────────────
|
|
419
|
+
async function handleNpmMetadata(_registry, path, clientReq, clientRes, host, port, options) {
|
|
420
|
+
const pkg = parseNpmUrl(path);
|
|
421
|
+
if (!pkg) {
|
|
422
|
+
await forwardUpstream(clientReq, clientRes, host, port);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
const upstream = await fetchUpstreamJson(host, port, path);
|
|
427
|
+
if (!upstream) {
|
|
428
|
+
clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
429
|
+
clientRes.end('Failed to fetch package metadata from upstream');
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const { filtered, denied } = filterNpmPackument(upstream, options.validator, pkg.name, pkg.scope);
|
|
433
|
+
// Cache allowed versions
|
|
434
|
+
const allowedVersions = new Set(Object.keys(filtered.versions));
|
|
435
|
+
setCachedVersions(options.cache, pkg, allowedVersions);
|
|
436
|
+
// Audit log denied versions
|
|
437
|
+
for (const d of denied) {
|
|
438
|
+
writeAuditEntry(options.auditLogPath, {
|
|
439
|
+
timestamp: new Date().toISOString(),
|
|
440
|
+
registry: pkg.registry,
|
|
441
|
+
packageName: pkg.name,
|
|
442
|
+
packageScope: pkg.scope,
|
|
443
|
+
packageVersion: d.version,
|
|
444
|
+
decision: 'deny',
|
|
445
|
+
reason: d.reason,
|
|
446
|
+
source: 'metadata-filter',
|
|
447
|
+
requestPath: path,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
// Audit log allowed versions (summary to avoid per-version verbosity)
|
|
451
|
+
if (allowedVersions.size > 0) {
|
|
452
|
+
writeAuditEntry(options.auditLogPath, {
|
|
453
|
+
timestamp: new Date().toISOString(),
|
|
454
|
+
registry: pkg.registry,
|
|
455
|
+
packageName: pkg.name,
|
|
456
|
+
packageScope: pkg.scope,
|
|
457
|
+
packageVersion: `${allowedVersions.size} version(s)`,
|
|
458
|
+
decision: 'allow',
|
|
459
|
+
reason: 'Passed all validation checks',
|
|
460
|
+
source: 'metadata-filter',
|
|
461
|
+
requestPath: path,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
if (denied.length > 0) {
|
|
465
|
+
logger.info(`[registry-proxy] npm ${canonicalPackageName(pkg)}: filtered ${denied.length} version(s), ${allowedVersions.size} allowed`);
|
|
466
|
+
}
|
|
467
|
+
const body = JSON.stringify(filtered);
|
|
468
|
+
clientRes.writeHead(200, {
|
|
469
|
+
'Content-Type': 'application/json',
|
|
470
|
+
'Content-Length': Buffer.byteLength(body),
|
|
471
|
+
});
|
|
472
|
+
clientRes.end(body);
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
logger.info(`[registry-proxy] npm metadata error for ${canonicalPackageName(pkg)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
476
|
+
clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
477
|
+
clientRes.end('Failed to process package metadata');
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async function handlePypiSimple(_registry, path, clientReq, clientRes, host, port, options) {
|
|
481
|
+
const pkg = parsePypiSimpleUrl(path);
|
|
482
|
+
if (!pkg) {
|
|
483
|
+
await forwardUpstream(clientReq, clientRes, host, port);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
// Fetch Simple Repository HTML and version timestamps concurrently
|
|
488
|
+
const [html, versionTimestamps] = await Promise.all([
|
|
489
|
+
fetchUpstreamText(host, port, path, 'text/html'),
|
|
490
|
+
fetchPypiVersionTimestamps(host, port, pkg.name),
|
|
491
|
+
]);
|
|
492
|
+
if (html === undefined) {
|
|
493
|
+
clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
494
|
+
clientRes.end('Failed to fetch package index from upstream');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const { filtered, denied, allowedVersions } = filterPypiIndex(html, options.validator, pkg.name, versionTimestamps);
|
|
498
|
+
// Cache allowed versions (collected during filtering)
|
|
499
|
+
setCachedVersions(options.cache, pkg, allowedVersions);
|
|
500
|
+
// Audit log denied versions
|
|
501
|
+
for (const d of denied) {
|
|
502
|
+
writeAuditEntry(options.auditLogPath, {
|
|
503
|
+
timestamp: new Date().toISOString(),
|
|
504
|
+
registry: pkg.registry,
|
|
505
|
+
packageName: pkg.name,
|
|
506
|
+
packageVersion: d.version,
|
|
507
|
+
decision: 'deny',
|
|
508
|
+
reason: d.reason,
|
|
509
|
+
source: 'metadata-filter',
|
|
510
|
+
requestPath: path,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
// Audit log allowed versions (summary to avoid per-version verbosity)
|
|
514
|
+
if (allowedVersions.size > 0) {
|
|
515
|
+
writeAuditEntry(options.auditLogPath, {
|
|
516
|
+
timestamp: new Date().toISOString(),
|
|
517
|
+
registry: pkg.registry,
|
|
518
|
+
packageName: pkg.name,
|
|
519
|
+
packageVersion: `${allowedVersions.size} version(s)`,
|
|
520
|
+
decision: 'allow',
|
|
521
|
+
reason: 'Passed all validation checks',
|
|
522
|
+
source: 'metadata-filter',
|
|
523
|
+
requestPath: path,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (denied.length > 0) {
|
|
527
|
+
logger.info(`[registry-proxy] PyPI ${pkg.name}: filtered ${denied.length} version(s), ${allowedVersions.size} allowed`);
|
|
528
|
+
}
|
|
529
|
+
clientRes.writeHead(200, {
|
|
530
|
+
'Content-Type': 'text/html',
|
|
531
|
+
'Content-Length': Buffer.byteLength(filtered),
|
|
532
|
+
});
|
|
533
|
+
clientRes.end(filtered);
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
logger.info(`[registry-proxy] PyPI metadata error for ${pkg.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
537
|
+
clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
538
|
+
clientRes.end('Failed to process package index');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
async function handleTarballDownload(registry, path, clientRes, host, port, options, registryType) {
|
|
542
|
+
const pkg = registryType === 'npm' ? parseNpmUrl(path) : parsePypiTarballUrl(path);
|
|
543
|
+
if (!pkg || !pkg.version) {
|
|
544
|
+
// Can't parse -- fail-closed
|
|
545
|
+
logger.info(`[registry-proxy] tarball backstop: can't parse URL, denying: ${path}`);
|
|
546
|
+
clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
547
|
+
clientRes.end('Forbidden: unable to identify package from URL — ensure the package name and version are correct');
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// Check allowed version cache
|
|
551
|
+
const cached = getCachedVersions(options.cache, pkg);
|
|
552
|
+
if (cached !== undefined) {
|
|
553
|
+
if (cached.has(pkg.version)) {
|
|
554
|
+
// Allowed -- forward upstream
|
|
555
|
+
writeAuditEntry(options.auditLogPath, {
|
|
556
|
+
timestamp: new Date().toISOString(),
|
|
557
|
+
registry: pkg.registry,
|
|
558
|
+
packageName: pkg.name,
|
|
559
|
+
packageScope: pkg.scope,
|
|
560
|
+
packageVersion: pkg.version,
|
|
561
|
+
decision: 'allow',
|
|
562
|
+
reason: 'Version in allowed cache from metadata filtering',
|
|
563
|
+
source: 'tarball-backstop',
|
|
564
|
+
requestPath: path,
|
|
565
|
+
});
|
|
566
|
+
await forwardToUpstream(clientRes, host, port, path, { timeoutMs: 60_000 });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// In cache but not in allowed set -- was filtered during metadata validation
|
|
570
|
+
const name = canonicalPackageName(pkg);
|
|
571
|
+
logger.info(`[registry-proxy] tarball backstop: denied ${name}@${pkg.version}`);
|
|
572
|
+
writeAuditEntry(options.auditLogPath, {
|
|
573
|
+
timestamp: new Date().toISOString(),
|
|
574
|
+
registry: pkg.registry,
|
|
575
|
+
packageName: pkg.name,
|
|
576
|
+
packageScope: pkg.scope,
|
|
577
|
+
packageVersion: pkg.version,
|
|
578
|
+
decision: 'deny',
|
|
579
|
+
reason: 'Version not in allowed cache (filtered from metadata)',
|
|
580
|
+
source: 'tarball-backstop',
|
|
581
|
+
requestPath: path,
|
|
582
|
+
});
|
|
583
|
+
clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
584
|
+
clientRes.end(`Forbidden: ${name}@${pkg.version} was filtered during metadata validation (may be too new, denylisted, or otherwise disallowed). Try an older version.`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// Cache miss -- need to fetch metadata and validate
|
|
588
|
+
try {
|
|
589
|
+
const decision = await fetchAndValidateVersion(registry, pkg, options);
|
|
590
|
+
if (decision.status === 'allow') {
|
|
591
|
+
writeAuditEntry(options.auditLogPath, {
|
|
592
|
+
timestamp: new Date().toISOString(),
|
|
593
|
+
registry: pkg.registry,
|
|
594
|
+
packageName: pkg.name,
|
|
595
|
+
packageScope: pkg.scope,
|
|
596
|
+
packageVersion: pkg.version,
|
|
597
|
+
decision: 'allow',
|
|
598
|
+
reason: 'Version passed validation on cache miss',
|
|
599
|
+
source: 'tarball-backstop',
|
|
600
|
+
requestPath: path,
|
|
601
|
+
});
|
|
602
|
+
await forwardToUpstream(clientRes, host, port, path, { timeoutMs: 60_000 });
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
const name = canonicalPackageName(pkg);
|
|
606
|
+
logger.info(`[registry-proxy] tarball backstop (cache miss): denied ${name}@${pkg.version}: ${decision.reason}`);
|
|
607
|
+
writeAuditEntry(options.auditLogPath, {
|
|
608
|
+
timestamp: new Date().toISOString(),
|
|
609
|
+
registry: pkg.registry,
|
|
610
|
+
packageName: pkg.name,
|
|
611
|
+
packageScope: pkg.scope,
|
|
612
|
+
packageVersion: pkg.version,
|
|
613
|
+
decision: 'deny',
|
|
614
|
+
reason: decision.reason,
|
|
615
|
+
source: 'tarball-backstop',
|
|
616
|
+
requestPath: path,
|
|
617
|
+
});
|
|
618
|
+
clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
619
|
+
clientRes.end(`Forbidden: ${name}@${pkg.version} — ${decision.reason}. Try a different version.`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
// Fail-closed
|
|
624
|
+
logger.info(`[registry-proxy] tarball backstop error: ${err instanceof Error ? err.message : String(err)}`);
|
|
625
|
+
clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
626
|
+
clientRes.end('Forbidden: unable to validate package version (upstream metadata fetch failed). This is a fail-closed response — retry later or use an allowlisted package.');
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function handleDebianRequest(_registry, path, clientReq, clientRes, host, port, options) {
|
|
630
|
+
// parseDebianPackageUrl returns undefined for non-.deb paths (metadata,
|
|
631
|
+
// Release files, GPG keys) — those pass through unmodified since apt
|
|
632
|
+
// verifies them with GPG signatures.
|
|
633
|
+
const pkg = parseDebianPackageUrl(path);
|
|
634
|
+
if (!pkg) {
|
|
635
|
+
// Distinguish "not a .deb file" (pass-through) from "malformed .deb" (fail-closed)
|
|
636
|
+
const filename = path.split('?')[0].split('/').pop() ?? '';
|
|
637
|
+
if (filename.endsWith('.deb')) {
|
|
638
|
+
logger.info(`[registry-proxy] debian backstop: can't parse .deb filename, denying: ${path}`);
|
|
639
|
+
clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
640
|
+
clientRes.end('Forbidden: unable to identify Debian package from URL');
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
await forwardUpstream(clientReq, clientRes, host, port);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
// Debian packages are distro-curated — bypass quarantine by providing
|
|
647
|
+
// an epoch publish date. Only allow/denylist checks apply.
|
|
648
|
+
const decision = options.validator.validate(pkg, { publishedAt: new Date(0) });
|
|
649
|
+
writeAuditEntry(options.auditLogPath, {
|
|
650
|
+
timestamp: new Date().toISOString(),
|
|
651
|
+
registry: 'debian',
|
|
652
|
+
packageName: pkg.name,
|
|
653
|
+
packageVersion: pkg.version,
|
|
654
|
+
decision: decision.status,
|
|
655
|
+
reason: decision.reason,
|
|
656
|
+
source: 'deb-backstop',
|
|
657
|
+
requestPath: path,
|
|
658
|
+
});
|
|
659
|
+
if (decision.status === 'allow') {
|
|
660
|
+
await forwardUpstream(clientReq, clientRes, host, port);
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
logger.info(`[registry-proxy] debian backstop: denied ${pkg.name}_${pkg.version}: ${decision.reason}`);
|
|
664
|
+
clientRes.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
665
|
+
clientRes.end(`Forbidden: Debian package ${pkg.name} (${pkg.version}) — ${decision.reason}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Fetches metadata for a package on cache miss and validates the
|
|
670
|
+
* requested version. Populates the cache as a side effect.
|
|
671
|
+
*/
|
|
672
|
+
async function fetchAndValidateVersion(registry, pkg, options) {
|
|
673
|
+
if (registry.type === 'npm') {
|
|
674
|
+
const metadataPath = pkg.scope ? `/@${pkg.scope}/${pkg.name}` : `/${pkg.name}`;
|
|
675
|
+
const packument = await fetchUpstreamJson(registry.host, 443, metadataPath);
|
|
676
|
+
if (!packument)
|
|
677
|
+
return { status: 'deny', reason: 'Failed to fetch package metadata from upstream' };
|
|
678
|
+
const { filtered, denied } = filterNpmPackument(packument, options.validator, pkg.name, pkg.scope);
|
|
679
|
+
const allowedVersions = new Set(Object.keys(filtered.versions));
|
|
680
|
+
setCachedVersions(options.cache, pkg, allowedVersions);
|
|
681
|
+
if (pkg.version !== undefined && allowedVersions.has(pkg.version)) {
|
|
682
|
+
return { status: 'allow', reason: 'Version passed validation' };
|
|
683
|
+
}
|
|
684
|
+
const deniedEntry = denied.find((d) => d.version === pkg.version);
|
|
685
|
+
return { status: 'deny', reason: deniedEntry?.reason ?? 'Version not found in package metadata' };
|
|
686
|
+
}
|
|
687
|
+
// PyPI: fetch JSON API for timestamps, validate ALL versions, populate cache
|
|
688
|
+
const versionTimestamps = await fetchPypiVersionTimestamps(registry.host, 443, pkg.name);
|
|
689
|
+
const allowedVersions = new Set();
|
|
690
|
+
let requestedVersionReason;
|
|
691
|
+
for (const [version, publishedAt] of versionTimestamps) {
|
|
692
|
+
const decision = options.validator.validate({ registry: 'pypi', name: pkg.name, version }, { publishedAt });
|
|
693
|
+
if (decision.status === 'allow') {
|
|
694
|
+
allowedVersions.add(version);
|
|
695
|
+
}
|
|
696
|
+
else if (version === pkg.version) {
|
|
697
|
+
requestedVersionReason = decision.reason;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
setCachedVersions(options.cache, pkg, allowedVersions);
|
|
701
|
+
if (pkg.version !== undefined && allowedVersions.has(pkg.version)) {
|
|
702
|
+
return { status: 'allow', reason: 'Version passed validation' };
|
|
703
|
+
}
|
|
704
|
+
return { status: 'deny', reason: requestedVersionReason ?? 'Version not found in package metadata' };
|
|
705
|
+
}
|
|
706
|
+
// ── HTTP helpers ────────────────────────────────────────────────────
|
|
707
|
+
/**
|
|
708
|
+
* Fetches a response body from upstream registry via HTTPS.
|
|
709
|
+
* Returns the raw Buffer, or undefined on non-200 or error.
|
|
710
|
+
*/
|
|
711
|
+
async function fetchUpstream(host, port, path, accept) {
|
|
712
|
+
return new Promise((resolve) => {
|
|
713
|
+
const req = https.request({
|
|
714
|
+
hostname: host,
|
|
715
|
+
port,
|
|
716
|
+
path,
|
|
717
|
+
method: 'GET',
|
|
718
|
+
headers: { accept, host },
|
|
719
|
+
}, (res) => {
|
|
720
|
+
const chunks = [];
|
|
721
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
722
|
+
res.on('end', () => {
|
|
723
|
+
if (res.statusCode !== 200) {
|
|
724
|
+
resolve(undefined);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
resolve(Buffer.concat(chunks));
|
|
728
|
+
});
|
|
729
|
+
res.on('error', () => resolve(undefined));
|
|
730
|
+
});
|
|
731
|
+
req.on('error', () => resolve(undefined));
|
|
732
|
+
req.setTimeout(30_000, () => {
|
|
733
|
+
req.destroy();
|
|
734
|
+
resolve(undefined);
|
|
735
|
+
});
|
|
736
|
+
req.end();
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Fetches JSON from upstream registry via HTTPS.
|
|
741
|
+
*/
|
|
742
|
+
async function fetchUpstreamJson(host, port, path) {
|
|
743
|
+
const buf = await fetchUpstream(host, port, path, 'application/json');
|
|
744
|
+
if (!buf)
|
|
745
|
+
return undefined;
|
|
746
|
+
try {
|
|
747
|
+
return JSON.parse(buf.toString());
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
return undefined;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Fetches text content from upstream registry via HTTPS.
|
|
755
|
+
*/
|
|
756
|
+
async function fetchUpstreamText(host, port, path, accept) {
|
|
757
|
+
const buf = await fetchUpstream(host, port, path, accept);
|
|
758
|
+
return buf?.toString();
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Fetches PyPI version timestamps from the JSON API.
|
|
762
|
+
* Returns a map of version -> publish date.
|
|
763
|
+
*/
|
|
764
|
+
async function fetchPypiVersionTimestamps(host, port, packageName) {
|
|
765
|
+
const timestamps = new Map();
|
|
766
|
+
const data = await fetchUpstreamJson(host, port, `/pypi/${packageName}/json`);
|
|
767
|
+
if (!data?.releases)
|
|
768
|
+
return timestamps;
|
|
769
|
+
for (const [version, files] of Object.entries(data.releases)) {
|
|
770
|
+
// Use the earliest upload timestamp among the version's files
|
|
771
|
+
let earliest;
|
|
772
|
+
for (const file of files) {
|
|
773
|
+
if (file.upload_time_iso_8601) {
|
|
774
|
+
const date = new Date(file.upload_time_iso_8601);
|
|
775
|
+
if (!earliest || date < earliest)
|
|
776
|
+
earliest = date;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (earliest)
|
|
780
|
+
timestamps.set(version, earliest);
|
|
781
|
+
}
|
|
782
|
+
return timestamps;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Forwards a request to the upstream HTTPS server and pipes the response
|
|
786
|
+
* back to the client. Supports both pass-through (with client request
|
|
787
|
+
* body piping) and simple GET forwarding modes.
|
|
788
|
+
*/
|
|
789
|
+
async function forwardToUpstream(clientRes, host, port, path, options = {}) {
|
|
790
|
+
const { clientReq, forwardHeaders = !!clientReq, timeoutMs = 30_000 } = options;
|
|
791
|
+
return new Promise((resolve) => {
|
|
792
|
+
const headers = forwardHeaders && clientReq ? { ...clientReq.headers, host } : { host };
|
|
793
|
+
const upstreamReq = https.request({
|
|
794
|
+
hostname: host,
|
|
795
|
+
port,
|
|
796
|
+
method: clientReq?.method ?? 'GET',
|
|
797
|
+
path: clientReq?.url ?? path,
|
|
798
|
+
headers,
|
|
799
|
+
}, (upstreamRes) => {
|
|
800
|
+
clientRes.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
|
801
|
+
upstreamRes.pipe(clientRes);
|
|
802
|
+
upstreamRes.on('end', resolve);
|
|
803
|
+
upstreamRes.on('error', () => {
|
|
804
|
+
if (!clientRes.headersSent) {
|
|
805
|
+
clientRes.writeHead(502);
|
|
806
|
+
clientRes.end('Upstream error');
|
|
807
|
+
}
|
|
808
|
+
resolve();
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
upstreamReq.on('error', () => {
|
|
812
|
+
if (!clientRes.headersSent) {
|
|
813
|
+
clientRes.writeHead(502);
|
|
814
|
+
clientRes.end('Upstream connection error');
|
|
815
|
+
}
|
|
816
|
+
resolve();
|
|
817
|
+
});
|
|
818
|
+
upstreamReq.setTimeout(timeoutMs, () => {
|
|
819
|
+
upstreamReq.destroy();
|
|
820
|
+
if (!clientRes.headersSent) {
|
|
821
|
+
clientRes.writeHead(504);
|
|
822
|
+
clientRes.end('Upstream timeout');
|
|
823
|
+
}
|
|
824
|
+
resolve();
|
|
825
|
+
});
|
|
826
|
+
if (clientReq) {
|
|
827
|
+
clientReq.pipe(upstreamReq);
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
upstreamReq.end();
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Forwards a request upstream without modification (pass-through).
|
|
836
|
+
* Pipes the client request body and forwards all client headers.
|
|
837
|
+
*/
|
|
838
|
+
async function forwardUpstream(clientReq, clientRes, host, port) {
|
|
839
|
+
await forwardToUpstream(clientRes, host, port, clientReq.url ?? '/', {
|
|
840
|
+
clientReq,
|
|
841
|
+
forwardHeaders: true,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
// ── Audit logging ───────────────────────────────────────────────────
|
|
845
|
+
/**
|
|
846
|
+
* Writes a package audit entry to the JSONL audit log.
|
|
847
|
+
* Fire-and-forget: callers should not await the returned promise.
|
|
848
|
+
*/
|
|
849
|
+
function writeAuditEntry(auditLogPath, entry) {
|
|
850
|
+
if (!auditLogPath)
|
|
851
|
+
return;
|
|
852
|
+
fs.appendFile(auditLogPath, JSON.stringify(entry) + '\n').catch((err) => {
|
|
853
|
+
logger.warn(`[registry-proxy] Failed to write audit entry: ${err instanceof Error ? err.message : String(err)}`);
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
//# sourceMappingURL=registry-proxy.js.map
|