diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 53c5a53c2..55c2bfcef 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -6,4 +6,4 @@ "integrity": "sha256:ce078b7bf7d9ef3bcb9813b32103795d8d72172446890b64772cbe1dec6baafd" } } -} +} \ No newline at end of file diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index 930d77d31..2f763a1bb 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -61,10 +61,11 @@ jobs: "src/test/cli.podman.test.ts", "src/test/cli.test.ts", "src/test/cli.up.test.ts", + "src/test/httpOCIRegistry.test.ts", "src/test/imageMetadata.test.ts", "src/test/container-features/containerFeaturesOCIPush.test.ts", # Run all except the above: - "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.podman.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", + "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.podman.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/httpOCIRegistry.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", ] steps: - name: Checkout @@ -89,6 +90,19 @@ jobs: fi cat "$DAEMON_JSON" sudo systemctl restart docker + - name: Install Podman 5.5.2 + if: matrix.mocha-args == 'src/test/cli.podman.test.ts' + run: | + docker build \ + --build-arg PODMAN_VERSION=v5.5.2 \ + --target podman \ + --tag podman-5.5.2-builder \ + 'https://github.com/mgoltzsche/podman-static.git#9ccf992536ecf0a4a3b82ac27fc708f00ab141ab' + container_id=$(docker create podman-5.5.2-builder) + docker cp "${container_id}:/usr/local/bin/podman" podman + docker rm "${container_id}" + sudo install -m 0755 podman /usr/local/bin/podman + podman --version - name: Tools Info run: | docker info diff --git a/.github/workflows/publish-dev-containers.yml b/.github/workflows/publish-dev-containers.yml index 7dae6ca8d..bb5e275bc 100644 --- a/.github/workflows/publish-dev-containers.yml +++ b/.github/workflows/publish-dev-containers.yml @@ -8,6 +8,7 @@ on: permissions: contents: read actions: read + id-token: write jobs: main: @@ -18,7 +19,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v5 with: - node-version: '20.x' + node-version: '24.x' registry-url: 'https://registry.npmjs.org' scope: '@devcontainers' - name: Verify Versions @@ -46,5 +47,3 @@ jobs: path: . - name: Publish TGZ run: npm publish ${TGZ} --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 5ac9743d1..e16642aee 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -18,7 +18,7 @@ permissions: jobs: tests-matrix: name: Tests Matrix (Windows) - runs-on: windows-latest + runs-on: windows-2022 timeout-minutes: 15 strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index aac8d0b78..1231bb8ae 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ src/test/container-features/configs/temp_lifecycle-hooks-alternative-order test-secrets-temp.json src/test/container-*/**/src/**/README.md !src/test/container-features/assets/*.tgz +src/test/fixtures/localhost-cert.pem +src/test/fixtures/localhost-key.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index 66792dd19..3dcbac73a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Notable changes. +## August 2026 + +### [0.89.0] +- Add opt-in OCI authentication hardening with `--oci-auth-hardening`, trusted cross-origin authentication host mappings, and diagnostics for measuring compatibility impact. (https://github.com/devcontainers/cli/pull/1278) + +## June 2026 + +### [0.88.0] +- Add WSLc support (https://github.com/devcontainers/cli/pull/1249) + ## May 2026 ### [0.87.0] diff --git a/package.json b/package.json index b62f6402e..ed05dce90 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@devcontainers/cli", "description": "Dev Containers CLI", - "version": "0.87.0", + "version": "0.89.0", "bin": { "devcontainer": "devcontainer.js" }, @@ -71,7 +71,7 @@ "@typescript-eslint/parser": "^8.56.1", "chai": "^4.5.0", "copyfiles": "^2.4.1", - "esbuild": "^0.27.3", + "esbuild": "^0.28.1", "eslint": "^10.0.2", "event-stream": "^4.0.1", "minimatch": "^10.2.4", diff --git a/scripts/updateUID.Dockerfile b/scripts/updateUID.Dockerfile index 9f6c9a854..3cd1c33fe 100644 --- a/scripts/updateUID.Dockerfile +++ b/scripts/updateUID.Dockerfile @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. -ARG BASE_IMAGE +ARG BASE_IMAGE=placeholder FROM $BASE_IMAGE USER root diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index e7770d8f0..00254aee5 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -13,6 +13,7 @@ import { launch, ShellServer } from './shellServer'; import { ExecFunction, CLIHost, PtyExecFunction, isFile, Exec, PtyExec, getEntPasswdShellCommand } from './commonUtils'; import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event'; import { PackageConfiguration } from '../spec-utils/product'; +import { OCIAuthDiagnostics } from './ociAuth'; import { URI } from 'vscode-uri'; import { containerSubstitute } from './variableSubstitution'; import { delay } from './async'; @@ -69,6 +70,9 @@ export interface ResolverParameters { omitConfigRemotEnvFromMetadata?: boolean; secretsP?: Promise>; omitSyntaxDirective?: boolean; + allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; + ociAuthDiagnostics: OCIAuthDiagnostics; } export interface LifecycleHook { diff --git a/src/spec-common/ociAuth.ts b/src/spec-common/ociAuth.ts new file mode 100644 index 000000000..b0817da38 --- /dev/null +++ b/src/spec-common/ociAuth.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface OCIAuthDiagnostics { + authLookupWouldBeBlocked: boolean; + registryRedirectWouldPreventCredentialForwarding: boolean; + authServerRedirect: boolean; +} + +export function createOCIAuthDiagnostics(): OCIAuthDiagnostics { + return { + authLookupWouldBeBlocked: false, + registryRedirectWouldPreventCredentialForwarding: false, + authServerRedirect: false, + }; +} diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index a2e4ad55f..89b8cfc7b 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -8,6 +8,7 @@ import { Log, LogLevel } from '../spec-utils/log'; import { isLocalFile, mkdirpLocal, readLocalFile, writeLocalFile } from '../spec-utils/pfs'; import { requestEnsureAuthenticated } from './httpOCIRegistry'; import { GoARCH, GoOS, PlatformInfo } from '../spec-common/commonUtils'; +import { OCIAuthDiagnostics } from '../spec-common/ociAuth'; export const DEVCONTAINER_MANIFEST_MEDIATYPE = 'application/vnd.devcontainers'; export const DEVCONTAINER_TAR_LAYER_MEDIATYPE = 'application/vnd.devcontainers.layer.v1+tar'; @@ -18,6 +19,9 @@ export interface CommonParams { env: NodeJS.ProcessEnv; output: Log; cachedAuthHeader?: Record; // + allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; + ociAuthDiagnostics: OCIAuthDiagnostics; } // Represents the unique OCI identifier for a Feature or Template. @@ -25,6 +29,7 @@ export interface CommonParams { // eg: ghcr.io/devcontainers/features/go@sha256:fe73f123927bd9ed1abda190d3009c4d51d0e17499154423c5913cf344af15a3 // Constructed by 'getRef()' export interface OCIRef { + scheme: 'http' | 'https'; registry: string; // 'ghcr.io' owner: string; // 'devcontainers' namespace: string; // 'devcontainers/features' @@ -41,6 +46,7 @@ export interface OCIRef { // eg: ghcr.io/devcontainers/features:latest // Constructed by 'getCollectionRef()' export interface OCICollectionRef { + scheme: 'http' | 'https'; registry: string; // 'ghcr.io' path: string; // 'devcontainers/features' resource: string; // 'ghcr.io/devcontainers/features' @@ -116,6 +122,10 @@ const regexForPath = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)* // MUST be at most 128 characters in length and MUST match the following regular expression: const regexForVersionOrDigest = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; +function getRegistryScheme(registry: string): OCIRef['scheme'] { + return new URL(`https://${registry}`).hostname.toLowerCase() === 'localhost' ? 'http' : 'https'; +} + // https://go.dev/doc/install/source#environment // Expected by OCI Spec as seen here: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions export function mapNodeArchitectureToGOARCH(arch: NodeJS.Architecture): GoARCH { @@ -236,6 +246,7 @@ export function getRef(output: Log, input: string): OCIRef | undefined { output.write(`> digest?: ${digest}`, LogLevel.Trace); return { + scheme: getRegistryScheme(registry), id, owner, namespace, @@ -266,6 +277,7 @@ export function getCollectionRef(output: Log, registry: string, namespace: strin } return { + scheme: getRegistryScheme(registry), registry, path, resource, @@ -291,7 +303,7 @@ export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef if (manifestDigest) { reference = manifestDigest; } - const manifestUrl = `https://${ref.registry}/v2/${ref.path}/manifests/${reference}`; + const manifestUrl = `${ref.scheme}://${ref.registry}/v2/${ref.path}/manifests/${reference}`; output.write(`manifest url: ${manifestUrl}`, LogLevel.Trace); const expectedDigest = manifestDigest || ('digest' in ref ? ref.digest : undefined); const manifestContainer = await getManifest(params, manifestUrl, ref, undefined, expectedDigest); @@ -467,7 +479,7 @@ export async function getVersionsStrictSorted(params: CommonParams, ref: OCIRef) export async function getPublishedTags(params: CommonParams, ref: OCIRef): Promise { const { output } = params; try { - const url = `https://${ref.registry}/v2/${ref.namespace}/${ref.id}/tags/list`; + const url = `${ref.scheme}://${ref.registry}/v2/${ref.namespace}/${ref.id}/tags/list`; const headers = { 'Accept': 'application/json', diff --git a/src/spec-configuration/containerCollectionsOCIPush.ts b/src/spec-configuration/containerCollectionsOCIPush.ts index 24f811663..51e1664fe 100644 --- a/src/spec-configuration/containerCollectionsOCIPush.ts +++ b/src/spec-configuration/containerCollectionsOCIPush.ts @@ -167,7 +167,7 @@ async function putManifestWithTags(params: CommonParams, manifest: ManifestConta const { manifestBuffer, contentDigest } = manifest; for await (const tag of tags) { - const url = `https://${ociRef.registry}/v2/${ociRef.path}/manifests/${tag}`; + const url = `${ociRef.scheme}://${ociRef.registry}/v2/${ociRef.path}/manifests/${tag}`; output.write(`PUT -> '${url}'`, LogLevel.Trace); const httpOptions = { @@ -232,7 +232,7 @@ async function putBlob(params: CommonParams, blobPutLocationUriPath: string, oci if (blobPutLocationUriPath.startsWith('https://') || blobPutLocationUriPath.startsWith('http://')) { url = blobPutLocationUriPath; } else { - url = `https://${ociRef.registry}${blobPutLocationUriPath}`; + url = `${ociRef.scheme}://${ociRef.registry}${blobPutLocationUriPath}`; } // The MAY contain critical query parameters. @@ -332,7 +332,7 @@ export async function calculateDataLayer(output: Log, data: Buffer, basename: st export async function checkIfBlobExists(params: CommonParams, ociRef: OCIRef | OCICollectionRef, digest: string): Promise { const { output } = params; - const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/${digest}`; + const url = `${ociRef.scheme}://${ociRef.registry}/v2/${ociRef.path}/blobs/${digest}`; const res = await requestEnsureAuthenticated(params, { type: 'HEAD', url, headers: {} }, ociRef); if (!res) { output.write('Request failed', LogLevel.Error); @@ -349,7 +349,7 @@ export async function checkIfBlobExists(params: CommonParams, ociRef: OCIRef | O async function postUploadSessionId(params: CommonParams, ociRef: OCIRef | OCICollectionRef): Promise { const { output } = params; - const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/uploads/`; + const url = `${ociRef.scheme}://${ociRef.registry}/v2/${ociRef.path}/blobs/uploads/`; output.write(`Generating Upload URL -> ${url}`, LogLevel.Trace); const res = await requestEnsureAuthenticated(params, { type: 'POST', url, headers: {} }, ociRef); diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 5957d0896..38d93a1a8 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -19,6 +19,7 @@ import { request } from '../spec-utils/httpRequest'; import { fetchOCIFeature, tryGetOCIFeatureSet, fetchOCIFeatureManifestIfExistsFromUserIdentifier } from './containerFeaturesOCI'; import { uriToFsPath } from './configurationCommonUtils'; import { CommonParams, ManifestContainer, OCIManifest, OCIRef, getRef, getVersionsStrictSorted } from './containerCollectionsOCI'; +import { OCIAuthDiagnostics } from '../spec-common/ociAuth'; import { Lockfile, generateLockfile, readLockfile, writeLockfile } from './lockfile'; import { computeDependsOnInstallationOrder } from './containerFeaturesOrder'; import { logFeatureAdvisories } from './featureAdvisories'; @@ -195,6 +196,9 @@ export interface ContainerFeatureInternalParams { platform: NodeJS.Platform; noLockfile?: boolean; frozenLockfile?: boolean; + allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; + ociAuthDiagnostics: OCIAuthDiagnostics; } // TODO: Move to node layer. @@ -203,6 +207,8 @@ export function getContainerFeaturesBaseDockerFile(contentSourceRootPath: string #{nonBuildKitFeatureContentFallback} +ARG _DEV_CONTAINERS_BASE_IMAGE=scratch + FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_feature_content_normalize USER root COPY --from=dev_containers_feature_content_source ${path.posix.join(contentSourceRootPath, 'devcontainer-features.builtin.env')} /tmp/build-features/ @@ -389,7 +395,7 @@ const cleanupIterationFetchAndMerge = async (tempTarballPath: string, output: Lo } }; -function getRequestHeaders(params: CommonParams, sourceInformation: SourceInformation) { +function getRequestHeaders(params: { env: NodeJS.ProcessEnv; output: Log }, sourceInformation: SourceInformation) { const { env, output } = params; let headers: { 'user-agent': string; 'Authorization'?: string; 'Accept'?: string } = { 'user-agent': 'devcontainer' @@ -955,7 +961,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: // throw new Error(`Unsupported feature source type: ${type}`); } -async function fetchFeatures(params: { extensionPath: string; cwd: string; output: Log; env: NodeJS.ProcessEnv }, featuresConfig: FeaturesConfig, dstFolder: string, ociCacheDir: string, lockfile: Lockfile | undefined) { +async function fetchFeatures(params: ContainerFeatureInternalParams, featuresConfig: FeaturesConfig, dstFolder: string, ociCacheDir: string, lockfile: Lockfile | undefined) { const featureSets = featuresConfig.featureSets; for (let idx = 0; idx < featureSets.length; idx++) { // Index represents the previously computed installation order. const featureSet = featureSets[idx]; diff --git a/src/spec-configuration/containerFeaturesOCI.ts b/src/spec-configuration/containerFeaturesOCI.ts index 84fb869cf..c4a0d3cae 100644 --- a/src/spec-configuration/containerFeaturesOCI.ts +++ b/src/spec-configuration/containerFeaturesOCI.ts @@ -61,7 +61,7 @@ export async function fetchOCIFeature(params: CommonParams, featureSet: FeatureS const { featureRef } = featureSet.sourceInformation; const layerDigest = featureSet.sourceInformation.manifest?.layers[0].digest; - const blobUrl = `https://${featureSet.sourceInformation.featureRef.registry}/v2/${featureSet.sourceInformation.featureRef.path}/blobs/${layerDigest}`; + const blobUrl = `${featureRef.scheme}://${featureRef.registry}/v2/${featureRef.path}/blobs/${layerDigest}`; output.write(`blob url: ${blobUrl}`, LogLevel.Trace); const blobResult = await getBlob(params, blobUrl, ociCacheDir, featCachePath, featureRef, layerDigest, undefined, metadataFile); diff --git a/src/spec-configuration/containerTemplatesOCI.ts b/src/spec-configuration/containerTemplatesOCI.ts index 4c5c27755..18cab0442 100644 --- a/src/spec-configuration/containerTemplatesOCI.ts +++ b/src/spec-configuration/containerTemplatesOCI.ts @@ -43,7 +43,7 @@ export async function fetchTemplate(params: CommonParams, selectedTemplate: Sele return; } - const blobUrl = `https://${templateRef.registry}/v2/${templateRef.path}/blobs/${blobDigest}`; + const blobUrl = `${templateRef.scheme}://${templateRef.registry}/v2/${templateRef.path}/blobs/${blobDigest}`; output.write(`blob url: ${blobUrl}`, LogLevel.Trace); const tmpDir = userProvidedTmpDir || path.join(os.tmpdir(), 'vsch-template-temp', `${Date.now()}`); diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 2bebba82e..57b8dc6c7 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -3,10 +3,11 @@ import * as path from 'path'; import * as jsonc from 'jsonc-parser'; import { runCommandNoPty, plainExec } from '../spec-common/commonUtils'; -import { requestResolveHeaders } from '../spec-utils/httpRequest'; +import { requestResolveHeaders, requestResolveHeadersNoRedirects } from '../spec-utils/httpRequest'; import { LogLevel } from '../spec-utils/log'; import { isLocalFile, readLocalFile } from '../spec-utils/pfs'; import { CommonParams, OCICollectionRef, OCIRef } from './containerCollectionsOCI'; +import { OCIAuthDiagnostics } from '../spec-common/ociAuth'; export type HEADERS = { 'authorization'?: string; 'user-agent'?: string; 'content-type'?: string; 'Accept'?: string; 'content-length'?: string }; @@ -35,6 +36,121 @@ const realmRegex = /realm="([^"]+)"/; const serviceRegex = /service="([^"]+)"/; const scopeRegex = /scope="([^"]+)"/; +const builtInCrossOriginAuthHosts = [ + 'registry-1.docker.io=auth.docker.io', + 'registry.docker.io=auth.docker.io', + 'docker.io=auth.docker.io', + 'index.docker.io=auth.docker.io', + 'registry.gitlab.com=gitlab.com', +]; + +const dockerHubRegistryHosts = new Set([ + 'registry-1.docker.io', + 'registry.docker.io', + 'docker.io', + 'index.docker.io', +]); + +function normalizeHttpsAuthority(authority: string): string { + let parsed: URL; + try { + parsed = new URL(`https://${authority}`); + } catch { + throw new Error(`Invalid authority '${authority}'.`); + } + if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) { + throw new Error(`Invalid authority '${authority}'.`); + } + return parsed.host.toLowerCase(); +} + +export function parseCrossOriginAuthHosts(entries: readonly string[]): Map> { + const result = new Map>(); + for (const entry of entries) { + const separator = entry.indexOf('='); + if (separator <= 0 || separator !== entry.lastIndexOf('=') || separator === entry.length - 1) { + throw new Error(`Invalid cross-origin auth host '${entry}'. Expected '='.`); + } + const registry = normalizeHttpsAuthority(entry.slice(0, separator)); + const authHost = normalizeHttpsAuthority(entry.slice(separator + 1)); + const authHosts = result.get(registry) || new Set(); + authHosts.add(authHost); + result.set(registry, authHosts); + } + return result; +} + +function isConfiguredCrossOriginAuthHost(registryUrl: URL, realmUrl: URL, crossOriginAuthHosts: Map>) { + return crossOriginAuthHosts.get(registryUrl.host.toLowerCase())?.has(realmUrl.host.toLowerCase()) || false; +} + +function isAllowedSameAuthorityRealm(registryUrl: URL, realmUrl: URL) { + if (registryUrl.host.toLowerCase() !== realmUrl.host.toLowerCase()) { + return false; + } + return realmUrl.protocol === 'https:' + || realmUrl.protocol === 'http:' && realmUrl.hostname.toLowerCase() === 'localhost'; +} + +function isAllowedTokenServiceRealmForPolicy(realmUrl: URL, registryUrl: URL, crossOriginAuthHosts: Map>): boolean { + if (isAllowedSameAuthorityRealm(registryUrl, realmUrl)) { + return true; + } + + return realmUrl.protocol === 'https:' + && isConfiguredCrossOriginAuthHost(registryUrl, realmUrl, crossOriginAuthHosts); +} + +export function isOCIRegistryOrigin(url: URL, ociRef: OCIRef | OCICollectionRef) { + const registryUrl = new URL(`${ociRef.scheme}://${ociRef.registry}`); + if (url.origin.toLowerCase() === registryUrl.origin.toLowerCase()) { + return true; + } + // Docker Hub references and distribution requests use several equivalent authorities. + return url.protocol === 'https:' + && registryUrl.protocol === 'https:' + && dockerHubRegistryHosts.has(url.host.toLowerCase()) + && dockerHubRegistryHosts.has(registryUrl.host.toLowerCase()); +} + +// Pin registry-directed token requests to the registry authority or an explicitly trusted auth host. +export function isAllowedTokenServiceRealm(realm: string, registryUrl: string, configuredEntries: readonly string[] = []): boolean { + try { + return isAllowedTokenServiceRealmForPolicy( + new URL(realm), + new URL(registryUrl), + parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...configuredEntries]) + ); + } catch { + return false; + } +} + +function recordOCIAuthDiagnostic(params: CommonParams, key: keyof OCIAuthDiagnostics, message: string) { + if (!params.ociAuthDiagnostics[key]) { + params.ociAuthDiagnostics[key] = true; + params.output.write(`[httpOci] OCI auth diagnostics: ${message}`, LogLevel.Info); + } +} + +function withOCIAuthDiagnostics(params: CommonParams, result: T) { + return { + ...result, + ociAuthDiagnostics: { ...params.ociAuthDiagnostics }, + }; +} + +function recordAuthServerRedirect(params: CommonParams, requestedUrl: string, response: { responseUrl: string; redirected: boolean }) { + if (response.redirected) { + const requestedOrigin = new URL(requestedUrl).origin; + const responseOrigin = new URL(response.responseUrl).origin; + const redirectDescription = requestedOrigin === responseOrigin + ? `within origin '${requestedOrigin}'` + : `from origin '${requestedOrigin}' to '${responseOrigin}'`; + recordOCIAuthDiagnostic(params, 'authServerRedirect', `Authentication server redirected a token request ${redirectDescription}.`); + } +} + // https://docs.docker.com/registry/spec/auth/token/#how-to-authenticate export async function requestEnsureAuthenticated(params: CommonParams, httpOptions: { type: string; url: string; headers: HEADERS; data?: Buffer }, ociRef: OCIRef | OCICollectionRef) { // If needed, Initialize the Authorization header cache. @@ -45,23 +161,34 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio // -- Update headers httpOptions.headers['user-agent'] = 'devcontainer'; + const requestedRegistryUrl = new URL(httpOptions.url); + const requestCanUseRegistryCredentials = isOCIRegistryOrigin(requestedRegistryUrl, ociRef); + if (params.ociAuthHardening && !requestCanUseRegistryCredentials) { + delete httpOptions.headers.authorization; + } // If the user has a cached auth token, attempt to use that first. - const maybeCachedAuthHeader = cachedAuthHeader[ociRef.registry]; + const maybeCachedAuthHeader = !params.ociAuthHardening || requestCanUseRegistryCredentials + ? cachedAuthHeader[ociRef.registry] + : undefined; if (maybeCachedAuthHeader) { output.write(`[httpOci] Applying cachedAuthHeader for registry ${ociRef.registry}...`, LogLevel.Trace); httpOptions.headers.authorization = maybeCachedAuthHeader; } - const initialAttemptRes = await requestResolveHeaders(httpOptions, output); + const registryUrl = new URL(initialAttemptRes.responseUrl); + const challengeFromOCIRegistry = isOCIRegistryOrigin(registryUrl, ociRef); // For anything except a 401 (invalid/no token) or 403 (insufficient scope) // response simply return the original response to the caller. if (initialAttemptRes.statusCode !== 401 && initialAttemptRes.statusCode !== 403) { output.write(`[httpOci] ${initialAttemptRes.statusCode} (${maybeCachedAuthHeader ? 'Cached' : 'NoAuth'}): ${httpOptions.url}`, LogLevel.Trace); - return initialAttemptRes; + return withOCIAuthDiagnostics(params, initialAttemptRes); } // -- 'responseAttempt' status code was 401 or 403 at this point. + if (!requestCanUseRegistryCredentials || !challengeFromOCIRegistry) { + recordOCIAuthDiagnostic(params, 'registryRedirectWouldPreventCredentialForwarding', `Request to '${requestedRegistryUrl.host}' with authentication challenge from '${registryUrl.host}' would prevent forwarding registry '${ociRef.registry}' credentials with OCI auth hardening.`); + } // Attempt to authenticate via WWW-Authenticate Header. const wwwAuthenticate = initialAttemptRes.resHeaders['WWW-Authenticate'] || initialAttemptRes.resHeaders['www-authenticate']; @@ -77,6 +204,10 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] Attempting to authenticate via 'Basic' auth.`, LogLevel.Trace); + if (params.ociAuthHardening && (!requestCanUseRegistryCredentials || !challengeFromOCIRegistry)) { + output.write(`[httpOci] ERR: Refusing to send registry '${ociRef.registry}' credentials to '${registryUrl.host}'.`, LogLevel.Error); + return; + } const credential = await getCredential(params, ociRef); const basicAuthCredential = credential?.base64EncodedCredential; if (!basicAuthCredential) { @@ -100,14 +231,35 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] WWW-Authenticate header is not in expected format. Got: ${wwwAuthenticate}`, LogLevel.Trace); return; } + let realmUrl: URL; + try { + realmUrl = new URL(realmGroup[1]); + const crossOriginAuthHosts = parseCrossOriginAuthHosts([...builtInCrossOriginAuthHosts, ...(params.allowedCrossOriginAuthHosts || [])]); + const authLookupWouldBeBlocked = !isAllowedTokenServiceRealmForPolicy(realmUrl, registryUrl, crossOriginAuthHosts); + if (authLookupWouldBeBlocked) { + recordOCIAuthDiagnostic(params, 'authLookupWouldBeBlocked', `Authentication lookup from registry '${registryUrl.host}' to realm origin '${realmUrl.origin}' would be blocked by OCI auth hardening.`); + if (params.ociAuthHardening) { + delete cachedAuthHeader[ociRef.registry]; + const allowHint = realmUrl.protocol === 'https:' + ? ` Use '--allow-cross-origin-auth-host ${registryUrl.host}=${realmUrl.host}' to trust this registry-to-auth-host mapping.` + : ''; + output.write(`[httpOci] ERR: Registry '${registryUrl.host}' requested authentication from untrusted realm '${realmGroup[1]}'.${allowHint}`, LogLevel.Error); + return; + } + } + } catch (err) { + output.write(`[httpOci] ERR: ${err}`, LogLevel.Error); + return; + } const wwwAuthenticateData = { - realm: realmGroup[1], + realm: realmUrl, service: serviceGroup[1], scope: scopeGroup ? scopeGroup[1] : '', }; - const bearerToken = await fetchRegistryBearerToken(params, ociRef, wwwAuthenticateData); + const challengeCanUseRequestedRegistryCredentials = !params.ociAuthHardening || (requestCanUseRegistryCredentials && challengeFromOCIRegistry); + const bearerToken = await fetchRegistryBearerToken(params, ociRef, challengeCanUseRequestedRegistryCredentials, wwwAuthenticateData); if (!bearerToken) { output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); return; @@ -126,11 +278,11 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio output.write(`[httpOci] ${reattemptRes.statusCode} on reattempt after auth: ${httpOptions.url}`, LogLevel.Trace); // Cache the auth header if the request did not result in an unauthorized response. - if (reattemptRes.statusCode !== 401) { + if (reattemptRes.statusCode !== 401 && (!params.ociAuthHardening || (requestCanUseRegistryCredentials && challengeFromOCIRegistry))) { params.cachedAuthHeader[ociRef.registry] = httpOptions.headers.authorization; } - return reattemptRes; + return withOCIAuthDiagnostics(params, reattemptRes); } // Attempts to get the Basic auth credentials for the provided registry. @@ -331,30 +483,42 @@ async function getCredentialFromHelper(params: CommonParams, registry: string, c } // https://docs.docker.com/registry/spec/auth/token/#requesting-a-token -async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | OCICollectionRef, wwwAuthenticateData: { realm: string; service: string; scope: string }): Promise { +async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | OCICollectionRef, challengeCanUseRequestedRegistryCredentials: boolean, wwwAuthenticateData: { realm: URL; service: string; scope: string }): Promise { const { output } = params; const { realm, service, scope } = wwwAuthenticateData; - // TODO: Remove this. - if (realm.includes('mcr.microsoft.com')) { - return undefined; - } - - const headers: HEADERS = { - 'user-agent': 'devcontainer' - }; - // The token server should first attempt to authenticate the client using any authentication credentials provided with the request. // From Docker 1.11 the Docker engine supports both Basic Authentication and OAuth2 for getting tokens. // Docker 1.10 and before, the registry client in the Docker Engine only supports Basic Authentication. // If an attempt to authenticate to the token server fails, the token server should return a 401 Unauthorized response // indicating that the provided credentials are invalid. // > https://docs.docker.com/registry/spec/auth/token/#requesting-a-token - const userCredential = await getCredential(params, ociRef); + const userCredential = challengeCanUseRequestedRegistryCredentials ? await getCredential(params, ociRef) : undefined; const basicAuthCredential = userCredential?.base64EncodedCredential; const refreshToken = userCredential?.refreshToken; let httpOptions: { type: string; url: string; headers: Record; data?: Buffer }; + let sentCredentials = false; + + const createGetHttpOptions = (authorization?: string) => { + // URLSearchParams preserves existing realm parameters and encodes challenge values. + const url = new URL(realm); + url.searchParams.set('service', service); + url.searchParams.set('scope', scope); + + const headers: Record = { + 'user-agent': 'devcontainer', + }; + if (authorization) { + headers.authorization = authorization; + } + + return { + type: 'GET', + url: url.toString(), + headers, + }; + }; // There are several different ways registries expect to handle the oauth token exchange. // Depending on the type of credential available, use the most reasonable method. @@ -366,51 +530,56 @@ async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | O form_url_encoded.append('scope', scope); form_url_encoded.append('refresh_token', refreshToken); - headers['content-type'] = 'application/x-www-form-urlencoded'; - - const url = realm; + const url = realm.toString(); output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); httpOptions = { type: 'POST', url, - headers: headers, + headers: { + 'user-agent': 'devcontainer', + 'content-type': 'application/x-www-form-urlencoded', + }, data: Buffer.from(form_url_encoded.toString()) }; + sentCredentials = true; } else { - if (basicAuthCredential) { - headers['authorization'] = `Basic ${basicAuthCredential}`; - } - // realm="https://auth.docker.io/token" // service="registry.docker.io" // scope="repository:samalba/my-app:pull,push" // Example: // https://auth.docker.io/token?service=registry.docker.io&scope=repository:samalba/my-app:pull,push - const url = `${realm}?service=${service}&scope=${scope}`; - output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); - - httpOptions = { - type: 'GET', - url: url, - headers: headers, - }; + const authorization = basicAuthCredential + ? `Basic ${basicAuthCredential}` + : undefined; + httpOptions = createGetHttpOptions(authorization); + sentCredentials = !!authorization; + output.write(`[httpOci] Attempting to fetch bearer token from: ${httpOptions.url}`, LogLevel.Trace); } - let res = await requestResolveHeaders(httpOptions, output); - if (res && res.statusCode === 401 || res.statusCode === 403) { - output.write(`[httpOci] ${res.statusCode}: Credentials for '${service}' may be expired. Attempting request anonymously.`, LogLevel.Info); - const body = res.resBody?.toString(); - if (body) { - output.write(`${res.resBody.toString()}.`, LogLevel.Info); - } + const requestToken = params.ociAuthHardening ? requestResolveHeadersNoRedirects : requestResolveHeaders; + let res: Awaited>; + try { + res = await requestToken(httpOptions, output); + recordAuthServerRedirect(params, httpOptions.url, res); + if (sentCredentials && (res.statusCode === 401 || res.statusCode === 403)) { + output.write(`[httpOci] ${res.statusCode}: Credentials for '${service}' may be expired. Attempting request anonymously.`, LogLevel.Info); + const body = res.resBody?.toString(); + if (body) { + output.write(`${res.resBody.toString()}.`, LogLevel.Info); + } - // Try again without user credentials. If we're here, their creds are likely expired. - delete headers['authorization']; - res = await requestResolveHeaders(httpOptions, output); + // Build a fresh GET so neither an Authorization header nor a refresh-token POST body is reused. + httpOptions = createGetHttpOptions(); + res = await requestToken(httpOptions, output); + recordAuthServerRedirect(params, httpOptions.url, res); + } + } catch (err) { + output.write(`[httpOci] Failed to request bearer token for '${service}': ${err}`, LogLevel.Error); + return; } - if (!res || res.statusCode > 299 || !res.resBody) { + if (res.statusCode > 299 || !res.resBody) { output.write(`[httpOci] ${res.statusCode}: Failed to fetch bearer token for '${service}': ${res.resBody.toString()}`, LogLevel.Error); return; } diff --git a/src/spec-node/configContainer.ts b/src/spec-node/configContainer.ts index c0b21eb82..3ee8873ee 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -60,7 +60,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu const { dockerCLI, dockerComposeCLI } = params; const { env } = common; - const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; await ensureNoDisallowedFeatures(cliParams, config, additionalFeatures, idLabels); await runInitializeCommand({ ...params, common: { ...common, output: common.lifecycleHook.output } }, config.initializeCommand, common.lifecycleHook.onDidInput); diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index d8912967c..eebb793f2 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { DevContainerConfig } from '../spec-configuration/configuration'; -import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters } from '../spec-shutdown/dockerUtils'; +import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters, CLIVariant } from '../spec-shutdown/dockerUtils'; import { LogLevel, makeLog } from '../spec-utils/log'; import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration'; import { readLocalFile } from '../spec-utils/pfs'; @@ -206,7 +206,7 @@ ${getDevcontainerMetadataLabel(getDevcontainerMetadata(imageBuildInfo.metadata, `, overrideTarget: 'dev_containers_target_stage', dockerfilePrefixContent: `${syntax ? `# syntax=${syntax}` : ''} - ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder + ARG _DEV_CONTAINERS_BASE_IMAGE=scratch `, buildArgs: { _DEV_CONTAINERS_BASE_IMAGE: baseName, @@ -274,7 +274,7 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont skipDefaultSyntax ? (syntax ? `# syntax=${syntax}` : '') : useBuildKitBuildContexts && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ? '# syntax=docker/dockerfile:1.4' : syntax ? `# syntax=${syntax}` : ''} -ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder +ARG _DEV_CONTAINERS_BASE_IMAGE=scratch `; // Build devcontainer-features.env and devcontainer-features-install.sh file(s) for each features source folder @@ -364,7 +364,7 @@ async function isUsingSELinuxLabels(params: DockerResolverParameters): Promise Promise | undefined)[]) { @@ -92,6 +95,7 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string remoteWorkspaceFolder: result.properties.remoteWorkspaceFolder, configuration: options.includeConfig ? result.config : undefined, mergedConfiguration: options.includeMergedConfig ? result.mergedConfig : undefined, + ociAuthDiagnostics: params.common.ociAuthDiagnostics, finishBackgroundTasks: async () => { try { await finishBackgroundTasks(result.params.backgroundTasks); @@ -162,6 +166,9 @@ export async function createDockerParams(options: ProvisionOptions, disposables: targetPath: options.dotfiles.targetPath || '~/dotfiles', }, omitSyntaxDirective: options.omitSyntaxDirective, + allowedCrossOriginAuthHosts: options.allowedCrossOriginAuthHosts, + ociAuthHardening: options.ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const dockerPath = options.dockerPath || 'docker'; @@ -210,9 +217,12 @@ export async function createDockerParams(options: ProvisionOptions, disposables: env: cliHost.env, output, buildPlatformInfo, - targetPlatformInfo + targetPlatformInfo, + ociAuthDiagnostics: common.ociAuthDiagnostics, })); + const cliVariant = await lookupCLIVariant({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }); + const dockerEngineVer = await dockerEngineVersion({ cliHost, dockerCLI: dockerPath, @@ -220,14 +230,15 @@ export async function createDockerParams(options: ProvisionOptions, disposables: env: cliHost.env, output, buildPlatformInfo, - targetPlatformInfo - }); + targetPlatformInfo, + ociAuthDiagnostics: common.ociAuthDiagnostics, + }, { useSimpleVersion: cliVariant === CLIVariant.Wslc }); return { common, parsedAuthority, dockerCLI: dockerPath, - isPodman: await isPodman({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }), + cliVariant, dockerComposeCLI: dockerComposeCLI, dockerEnv: cliHost.env, workspaceMountConsistencyDefault: workspaceMountConsistency, diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 832e9603f..782232e11 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -44,7 +44,9 @@ import { readFeaturesConfig } from './featureUtils'; import { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './featuresCLI/generateDocs'; import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs'; import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; +import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; +import { parseCrossOriginAuthHosts } from '../spec-configuration/httpOCIRegistry'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -66,6 +68,28 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa .scriptName('devcontainer') .version(version) .demandCommand() + .option('oci-auth-hardening', { + type: 'boolean', + default: false, + global: true, + description: 'Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects.', + }) + .option('allow-cross-origin-auth-host', { + type: 'string', + array: true, + nargs: 1, + global: true, + description: 'Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: =. May be repeated.', + }) + .check(args => { + const ociAuthArgs = args as OciAuthArgs; + const allowedCrossOriginAuthHosts = getAllowedCrossOriginAuthHosts(ociAuthArgs); + if (allowedCrossOriginAuthHosts.length && !ociAuthArgs['oci-auth-hardening']) { + throw new Error('--allow-cross-origin-auth-host requires --oci-auth-hardening.'); + } + parseCrossOriginAuthHosts(allowedCrossOriginAuthHosts); + return true; + }) .strict(); y.wrap(Math.min(120, y.terminalWidth())); y.command('up', 'Create and run dev container', provisionOptions, provisionHandler); @@ -96,6 +120,14 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa })().catch(console.error); export type UnpackArgv = T extends Argv ? U : T; +export type OciAuthArgs = { + 'allow-cross-origin-auth-host'?: string[]; + 'oci-auth-hardening'?: boolean; +}; + +export function getAllowedCrossOriginAuthHosts(args: OciAuthArgs) { + return args['allow-cross-origin-auth-host'] || []; +} function provisionOptions(y: Argv) { return y.options({ @@ -176,7 +208,7 @@ function provisionOptions(y: Argv) { }); } -type ProvisionArgs = UnpackArgv>; +type ProvisionArgs = UnpackArgv> & OciAuthArgs; function provisionHandler(args: ProvisionArgs) { runAsyncHandler(provision.bind(null, args)); @@ -229,6 +261,8 @@ async function provision({ 'omit-syntax-directive': omitSyntaxDirective, 'include-configuration': includeConfig, 'include-merged-configuration': includeMergedConfig, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: ProvisionArgs) { warnDeprecatedLockfileFlags(experimentalLockfile, experimentalFrozenLockfile); @@ -303,6 +337,8 @@ async function provision({ omitSyntaxDirective, includeConfig, includeMergedConfig, + allowedCrossOriginAuthHosts, + ociAuthHardening, }; const result = await doProvision(options, providedIdLabels); @@ -383,7 +419,7 @@ function setUpOptions(y: Argv) { }); } -type SetUpArgs = UnpackArgv>; +type SetUpArgs = UnpackArgv> & OciAuthArgs; function setUpHandler(args: SetUpArgs) { runAsyncHandler(setUp.bind(null, args)); @@ -420,6 +456,8 @@ async function doSetUp({ 'container-session-data-folder': containerSessionDataFolder, 'include-configuration': includeConfig, 'include-merged-configuration': includeMergedConfig, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: SetUpArgs) { const disposables: (() => Promise | undefined)[] = []; @@ -470,6 +508,8 @@ async function doSetUp({ installCommand: dotfilesInstallCommand, targetPath: dotfilesTargetPath, }, + allowedCrossOriginAuthHosts, + ociAuthHardening, }, disposables); const { common } = params; @@ -561,7 +601,7 @@ function buildOptions(y: Argv) { }); } -type BuildArgs = UnpackArgv>; +type BuildArgs = UnpackArgv> & OciAuthArgs; function buildHandler(args: BuildArgs) { runAsyncHandler(build.bind(null, args)); @@ -602,6 +642,8 @@ async function doBuild({ 'no-lockfile': noLockfile, 'frozen-lockfile': frozenLockfile, 'omit-syntax-directive': omitSyntaxDirective, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: BuildArgs) { warnDeprecatedLockfileFlags(experimentalLockfile, experimentalFrozenLockfile); const effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile; @@ -655,6 +697,8 @@ async function doBuild({ noLockfile, frozenLockfile: effectiveFrozenLockfile, omitSyntaxDirective, + allowedCrossOriginAuthHosts, + ociAuthHardening, }, disposables); const { common, dockerComposeCLI } = params; @@ -676,7 +720,7 @@ async function doBuild({ throw new ContainerError({ description: '--push true cannot be used with --output.' }); } - const buildParams: DockerCLIParameters = { cliHost, dockerCLI: params.dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI: params.dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: params.common.ociAuthDiagnostics }; await ensureNoDisallowedFeatures(buildParams, config, additionalFeatures, undefined); // Support multiple use of `--image-name` @@ -763,6 +807,7 @@ async function doBuild({ return { outcome: 'success' as 'success', imageName: imageNameResult, + ociAuthDiagnostics: params.common.ociAuthDiagnostics, dispose, }; } catch (originalError) { @@ -830,7 +875,7 @@ function runUserCommandsOptions(y: Argv) { }); } -type RunUserCommandsArgs = UnpackArgv>; +type RunUserCommandsArgs = UnpackArgv> & OciAuthArgs; function runUserCommandsHandler(args: RunUserCommandsArgs) { runAsyncHandler(runUserCommands.bind(null, args)); @@ -1023,7 +1068,7 @@ function readConfigurationOptions(y: Argv) { }); } -type ReadConfigurationArgs = UnpackArgv>; +type ReadConfigurationArgs = UnpackArgv> & OciAuthArgs; function readConfigurationHandler(args: ReadConfigurationArgs) { runAsyncHandler(readConfiguration.bind(null, args)); @@ -1048,6 +1093,8 @@ async function readConfiguration({ 'include-merged-configuration': includeMergedConfig, 'additional-features': additionalFeaturesJson, 'skip-feature-auto-mapping': skipFeatureAutoMapping, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: ReadConfigurationArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -1104,7 +1151,10 @@ async function readConfiguration({ env: cliHost.env, output, buildPlatformInfo, - targetPlatformInfo: buildPlatformInfo + targetPlatformInfo: buildPlatformInfo, + allowedCrossOriginAuthHosts, + ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const { container, idLabels } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); if (container) { @@ -1134,6 +1184,7 @@ async function readConfiguration({ workspace: configs?.workspaceConfig, featuresConfiguration, mergedConfiguration: mergedConfig, + ociAuthDiagnostics: params.ociAuthDiagnostics, }) + '\n', err => err ? reject(err) : resolve()); }); } catch (err) { @@ -1162,7 +1213,7 @@ function outdatedOptions(y: Argv) { }); } -type OutdatedArgs = UnpackArgv>; +type OutdatedArgs = UnpackArgv> & OciAuthArgs; function outdatedHandler(args: OutdatedArgs) { runAsyncHandler(outdated.bind(null, args)); @@ -1177,6 +1228,8 @@ async function outdated({ 'log-format': logFormat, 'terminal-rows': terminalRows, 'terminal-columns': terminalColumns, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: OutdatedArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -1213,6 +1266,9 @@ async function outdated({ env: cliHost.env, skipFeatureAutoMapping: false, platform: cliHost.platform, + allowedCrossOriginAuthHosts, + ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const outdated = await loadVersionInfo(params, configs.config.config); diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index 8093464cc..8e7750040 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -11,7 +11,7 @@ import { ContainerProperties, setupInContainer, ResolverProgress } from '../spec import { ContainerError } from '../spec-common/errors'; import { Workspace } from '../spec-utils/workspaces'; import { equalPaths, parseVersion, isEarlierVersion, CLIHost } from '../spec-common/commonUtils'; -import { ContainerDetails, inspectContainer, listContainers, DockerCLIParameters, dockerComposeCLI, dockerComposePtyCLI, PartialExecParameters, DockerComposeCLI, ImageDetails, toExecParameters, toPtyExecParameters, removeContainer } from '../spec-shutdown/dockerUtils'; +import { ContainerDetails, inspectContainer, listContainers, DockerCLIParameters, dockerComposeCLI, dockerComposePtyCLI, PartialExecParameters, DockerComposeCLI, ImageDetails, toExecParameters, toPtyExecParameters, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils'; import { DevContainerFromDockerComposeConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration'; import { Log, LogLevel, makeLog, terminalEscapeSequences } from '../spec-utils/log'; import { getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures'; @@ -27,7 +27,7 @@ const serviceLabel = 'com.docker.compose.service'; export async function openDockerComposeDevContainer(params: DockerResolverParameters, workspace: Workspace, config: SubstitutedConfig, idLabels: string[], additionalFeatures: Record>): Promise { const { common, dockerCLI, dockerComposeCLI } = params; const { cliHost, env, output } = common; - const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; return _openDockerComposeDevContainer(params, buildParams, workspace, config, getRemoteWorkspaceFolder(config.config), idLabels, additionalFeatures); } @@ -155,7 +155,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf const { cliHost, env, output } = common; const { config } = configWithRaw; - const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics: common.ociAuthDiagnostics }; const composeConfig = await readDockerComposeConfig(cliParams, localComposeFiles, envFile); const composeService = composeConfig.services[config.service]; @@ -188,7 +188,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf // determine whether we need to extend with features const version = parseVersion((await params.dockerComposeCLI()).version); - const supportsAdditionalBuildContexts = !params.isPodman && version && !isEarlierVersion(version, [2, 17, 0]); + const supportsAdditionalBuildContexts = params.cliVariant === CLIVariant.Docker && version && !isEarlierVersion(version, [2, 17, 0]); const optionalBuildKitParams = supportsAdditionalBuildContexts ? params : { ...params, buildKitVersion: undefined }; const extendImageBuildInfo = await getExtendImageBuildInfo(optionalBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer); diff --git a/src/spec-node/featureUtils.ts b/src/spec-node/featureUtils.ts index a36205295..f52ca0be7 100644 --- a/src/spec-node/featureUtils.ts +++ b/src/spec-node/featureUtils.ts @@ -9,5 +9,17 @@ export async function readFeaturesConfig(params: DockerCLIParameters, pkg: Packa const { cwd, env, platform } = cliHost; const featuresTmpFolder = await createFeaturesTempFolder({ cliHost, package: pkg }); const cacheFolder = await getCacheFolder(cliHost); - return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, skipFeatureAutoMapping, platform, noLockfile: true }, featuresTmpFolder, config, additionalFeatures); + return generateFeaturesConfig({ + extensionPath, + cacheFolder, + cwd, + output, + env, + skipFeatureAutoMapping, + platform, + noLockfile: true, + allowedCrossOriginAuthHosts: params.allowedCrossOriginAuthHosts, + ociAuthHardening: params.ociAuthHardening, + ociAuthDiagnostics: params.ociAuthDiagnostics, + }, featuresTmpFolder, config, additionalFeatures); } \ No newline at end of file diff --git a/src/spec-node/featuresCLI/info.ts b/src/spec-node/featuresCLI/info.ts index 9d721b651..2ee958726 100644 --- a/src/spec-node/featuresCLI/info.ts +++ b/src/spec-node/featuresCLI/info.ts @@ -1,13 +1,14 @@ import { Argv } from 'yargs'; -import { OCIManifest, OCIRef, fetchOCIManifestIfExists, getPublishedTags, getRef } from '../../spec-configuration/containerCollectionsOCI'; -import { Log, LogLevel, mapLogLevel } from '../../spec-utils/log'; +import { CommonParams, OCIManifest, OCIRef, fetchOCIManifestIfExists, getPublishedTags, getRef } from '../../spec-configuration/containerCollectionsOCI'; +import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { buildDependencyGraph, generateMermaidDiagram } from '../../spec-configuration/containerFeaturesOrder'; import { DevContainerFeature } from '../../spec-configuration/configuration'; import { processFeatureIdentifier } from '../../spec-configuration/containerFeaturesConfiguration'; import { runAsyncHandler } from '../utils'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; export function featuresInfoOptions(y: Argv) { return y @@ -19,7 +20,7 @@ export function featuresInfoOptions(y: Argv) { .positional('feature', { type: 'string', demandOption: true, description: 'Feature Identifier' }); } -export type FeaturesInfoArgs = UnpackArgv>; +export type FeaturesInfoArgs = UnpackArgv> & OciAuthArgs; export function featuresInfoHandler(args: FeaturesInfoArgs) { runAsyncHandler(featuresInfo.bind(null, args)); @@ -36,6 +37,8 @@ async function featuresInfo({ 'feature': featureId, 'log-level': inputLogLevel, 'output-format': outputFormat, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: FeaturesInfoArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -51,7 +54,7 @@ async function featuresInfo({ terminalDimensions: undefined, }, pkg, new Date(), disposables, true); - const params = { output, env: process.env, outputFormat }; + const params = { output, env: process.env, outputFormat, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }; const jsonOutput: InfoJsonOutput = {}; @@ -129,7 +132,7 @@ async function featuresInfo({ } -async function getManifest(params: { output: Log; env: NodeJS.ProcessEnv; outputFormat: string }, featureRef: OCIRef) { +async function getManifest(params: CommonParams & { outputFormat: string }, featureRef: OCIRef) { const { outputFormat } = params; const manifestContainer = await fetchOCIManifestIfExists(params, featureRef, undefined); @@ -144,7 +147,7 @@ async function getManifest(params: { output: Log; env: NodeJS.ProcessEnv; output return manifestContainer; } -async function getTags(params: { output: Log; env: NodeJS.ProcessEnv; outputFormat: string }, featureRef: OCIRef) { +async function getTags(params: CommonParams & { outputFormat: string }, featureRef: OCIRef) { const { outputFormat } = params; const publishedTags = await getPublishedTags(params, featureRef); if (!publishedTags || publishedTags.length === 0) { diff --git a/src/spec-node/featuresCLI/publish.ts b/src/spec-node/featuresCLI/publish.ts index 42259a057..46f38ea14 100644 --- a/src/spec-node/featuresCLI/publish.ts +++ b/src/spec-node/featuresCLI/publish.ts @@ -5,7 +5,7 @@ import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { rmLocal } from '../../spec-utils/pfs'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { doFeaturesPackageCommand } from './packageCommandImpl'; import { getCLIHost } from '../../spec-common/cliHost'; import { loadNativeModule } from '../../spec-common/commonUtils'; @@ -15,13 +15,14 @@ import { publishOptions } from '../collectionCommonUtils/publish'; import { getCollectionRef, getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI'; import { doPublishCommand, doPublishMetadata } from '../collectionCommonUtils/publishCommandImpl'; import { runAsyncHandler } from '../utils'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; const collectionType = 'feature'; export function featuresPublishOptions(y: Argv) { return publishOptions(y, 'feature'); } -export type FeaturesPublishArgs = UnpackArgv>; +export type FeaturesPublishArgs = UnpackArgv> & OciAuthArgs; export function featuresPublishHandler(args: FeaturesPublishArgs) { runAsyncHandler(featuresPublish.bind(null, args)); @@ -31,7 +32,9 @@ async function featuresPublish({ 'target': targetFolder, 'log-level': inputLogLevel, 'registry': registry, - 'namespace': namespace + 'namespace': namespace, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: FeaturesPublishArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -49,7 +52,7 @@ async function featuresPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }; // Package features const outputDir = path.join(os.tmpdir(), `/features-output-${Date.now()}`); diff --git a/src/spec-node/featuresCLI/resolveDependencies.ts b/src/spec-node/featuresCLI/resolveDependencies.ts index 3c24c3788..1c183ba30 100644 --- a/src/spec-node/featuresCLI/resolveDependencies.ts +++ b/src/spec-node/featuresCLI/resolveDependencies.ts @@ -3,7 +3,7 @@ import { Argv } from 'yargs'; import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { isLocalFile } from '../../spec-utils/pfs'; import { DevContainerFeature } from '../../spec-configuration/configuration'; import { buildDependencyGraph, computeDependsOnInstallationOrder, generateMermaidDiagram } from '../../spec-configuration/containerFeaturesOrder'; @@ -17,6 +17,7 @@ import { uriToFsPath } from '../../spec-configuration/configurationCommonUtils'; import { workspaceFromPath } from '../../spec-utils/workspaces'; import { readDevContainerConfigFile } from '../configContainer'; import { URI } from 'vscode-uri'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; interface JsonOutput { @@ -34,7 +35,7 @@ export function featuresResolveDependenciesOptions(y: Argv) { }); } -export type featuresResolveDependenciesArgs = UnpackArgv>; +export type featuresResolveDependenciesArgs = UnpackArgv> & OciAuthArgs; export function featuresResolveDependenciesHandler(args: featuresResolveDependenciesArgs) { runAsyncHandler(featuresResolveDependencies.bind(null, args)); @@ -43,6 +44,8 @@ export function featuresResolveDependenciesHandler(args: featuresResolveDependen async function featuresResolveDependencies({ 'workspace-folder': workspaceFolderArg, 'log-level': inputLogLevel, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: featuresResolveDependenciesArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -73,6 +76,9 @@ async function featuresResolveDependencies({ const params = { output, env: process.env, + allowedCrossOriginAuthHosts, + ociAuthHardening, + ociAuthDiagnostics: createOCIAuthDiagnostics(), }; const cwd = workspaceFolder || process.cwd(); diff --git a/src/spec-node/featuresCLI/test.ts b/src/spec-node/featuresCLI/test.ts index 0fc40999f..e0d41cd99 100644 --- a/src/spec-node/featuresCLI/test.ts +++ b/src/spec-node/featuresCLI/test.ts @@ -3,7 +3,7 @@ import { CLIHost, getCLIHost } from '../../spec-common/cliHost'; import { loadNativeModule } from '../../spec-common/commonUtils'; import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig, PackageConfiguration } from '../../spec-utils/product'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { doFeaturesTestCommand } from './testCommandImpl'; import { runAsyncHandler } from '../utils'; @@ -47,7 +47,7 @@ export function featuresTestOptions(y: Argv) { }); } -export type FeaturesTestArgs = UnpackArgv>; +export type FeaturesTestArgs = UnpackArgv> & OciAuthArgs; export interface FeaturesTestCommandInput { cliHost: CLIHost; pkg: PackageConfiguration; @@ -64,6 +64,8 @@ export interface FeaturesTestCommandInput { logLevel: LogLevel; preserveTestContainers: boolean; quiet: boolean; + allowedCrossOriginAuthHosts: string[]; + ociAuthHardening: boolean; disposables: (() => Promise | undefined)[]; } @@ -86,6 +88,8 @@ async function featuresTest({ 'log-level': inputLogLevel, 'preserve-test-containers': preserveTestContainers, quiet, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts = [], + 'oci-auth-hardening': ociAuthHardening = false, }: FeaturesTestArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -117,6 +121,8 @@ async function featuresTest({ permitRandomization, remoteUser, preserveTestContainers, + allowedCrossOriginAuthHosts, + ociAuthHardening, disposables }; diff --git a/src/spec-node/featuresCLI/testCommandImpl.ts b/src/spec-node/featuresCLI/testCommandImpl.ts index d119885a8..ecf251b66 100644 --- a/src/spec-node/featuresCLI/testCommandImpl.ts +++ b/src/spec-node/featuresCLI/testCommandImpl.ts @@ -559,6 +559,8 @@ async function launchProject(params: DockerResolverParameters, workspaceFolder: skipFeatureAutoMapping: common.skipFeatureAutoMapping, skipPersistingCustomizationsFromFeatures: common.skipPersistingCustomizationsFromFeatures, omitConfigRemotEnvFromMetadata: common.omitConfigRemotEnvFromMetadata, + allowedCrossOriginAuthHosts: common.allowedCrossOriginAuthHosts, + ociAuthHardening: common.ociAuthHardening, log: text => quiet ? null : process.stderr.write(text), dotfiles: {} }; @@ -625,7 +627,7 @@ async function exec(cmd: string, args: string[], workspaceFolder: string, inject } async function generateDockerParams(workspaceFolder: string, args: FeaturesTestCommandInput): Promise { - const { logLevel, quiet, disposables } = args; + const { logLevel, quiet, allowedCrossOriginAuthHosts, ociAuthHardening, disposables } = args; return await createDockerParams({ workspaceFolder, additionalLabels: [], @@ -662,6 +664,8 @@ async function generateDockerParams(workspaceFolder: string, args: FeaturesTestC skipFeatureAutoMapping: false, skipPostAttach: false, skipPersistingCustomizationsFromFeatures: false, + allowedCrossOriginAuthHosts, + ociAuthHardening, dotfiles: {} }, disposables); } diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index 60884592e..3f10914af 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -350,7 +350,8 @@ export async function getImageBuildInfo(params: DockerResolverParameters | Docke const cwdEnvFile = cliHost.path.join(cliHost.cwd, '.env'); const envFile = Array.isArray(config.dockerComposeFile) && config.dockerComposeFile.length === 0 && await cliHost.isFile(cwdEnvFile) ? cwdEnvFile : undefined; const composeFiles = await getDockerComposeFilePaths(cliHost, config, cliHost.env, cliHost.cwd); - const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env: cliHost.env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; + const ociAuthDiagnostics = 'cliHost' in params ? params.ociAuthDiagnostics : params.common.ociAuthDiagnostics; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env: cliHost.env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo, ociAuthDiagnostics }; const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); const services = Object.keys(composeConfig.services || {}); diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 1c3669f74..362559c2e 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -7,7 +7,7 @@ import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, getDockerfilePath, getDockerContextPath, DockerResolverParameters, isDockerFileConfig, uriToWSLFsPath, WorkspaceConfiguration, getFolderImageName, inspectDockerImage, logUMask, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError, isBuildxCacheToInline } from './utils'; import { ContainerProperties, setupInContainer, ResolverProgress, ResolverParameters } from '../spec-common/injectHeadless'; import { ContainerError, toErrorText } from '../spec-common/errors'; -import { ContainerDetails, listContainers, DockerCLIParameters, inspectContainers, dockerCLI, dockerPtyCLI, toPtyExecParameters, ImageDetails, toExecParameters, removeContainer } from '../spec-shutdown/dockerUtils'; +import { ContainerDetails, listContainers, DockerCLIParameters, inspectContainers, dockerCLI, dockerPtyCLI, toPtyExecParameters, ImageDetails, toExecParameters, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils'; import { DevContainerConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig } from '../spec-configuration/configuration'; import { LogLevel, Log, makeLog } from '../spec-utils/log'; import { extendImage, getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures'; @@ -347,8 +347,8 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const exposedPorts = typeof appPort === 'number' || typeof appPort === 'string' ? [appPort] : appPort || []; const exposed = ([]).concat(...exposedPorts.map(port => ['-p', typeof port === 'number' ? `127.0.0.1:${port}:${port}` : port])); - const cwdMount = workspaceMount ? ['--mount', workspaceMount] : []; - const additionalMount = additionalMountString ? ['--mount', additionalMountString] : []; + const cwdMount = workspaceMount ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(workspaceMount) : ['--mount', workspaceMount]) : []; + const additionalMount = additionalMountString ? (params.cliVariant === CLIVariant.Wslc ? convertMountToVolume(additionalMountString) : ['--mount', additionalMountString]) : []; const envObj = mergedConfig.containerEnv || {}; const containerEnv = Object.keys(envObj) @@ -360,24 +360,30 @@ export async function spawnDevContainer(params: DockerResolverParameters, config const containerUserArgs = containerUser ? ['-u', containerUser] : []; const featureArgs: string[] = []; - if (mergedConfig.init) { - featureArgs.push('--init'); - } - if (mergedConfig.privileged) { - featureArgs.push('--privileged'); - } - for (const cap of mergedConfig.capAdd || []) { - featureArgs.push('--cap-add', cap); - } - for (const securityOpt of mergedConfig.securityOpt || []) { - featureArgs.push('--security-opt', securityOpt); + // wslc does not support --init, --privileged, --cap-add, or --security-opt + if (params.cliVariant !== CLIVariant.Wslc) { + if (mergedConfig.init) { + featureArgs.push('--init'); + } + if (mergedConfig.privileged) { + featureArgs.push('--privileged'); + } + for (const cap of mergedConfig.capAdd || []) { + featureArgs.push('--cap-add', cap); + } + for (const securityOpt of mergedConfig.securityOpt || []) { + featureArgs.push('--security-opt', securityOpt); + } } const featureMounts = ([] as string[]).concat( ...[ ...mergedConfig.mounts || [], ...params.additionalMounts, - ].map(m => generateMountCommand(m)) + ].map(m => { + const mountArgs = generateMountCommand(m); + return params.cliVariant === CLIVariant.Wslc ? convertMountArgsToVolume(mountArgs) : mountArgs; + }) ); const customEntrypoints = mergedConfig.entrypoints || []; @@ -396,9 +402,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t const args = [ 'run', - '--sig-proxy=false', - '-a', 'STDOUT', - '-a', 'STDERR', + ...(params.cliVariant === CLIVariant.Wslc ? [] : ['--sig-proxy=false', '-a', 'STDOUT', '-a', 'STDERR']), ...exposed, ...cwdMount, ...additionalMount, @@ -432,7 +436,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t } async function getPodmanArgs(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageDetails: () => Promise): Promise { - if (params.isPodman && params.common.cliHost.platform === 'linux') { + if (params.cliVariant === CLIVariant.Podman && params.common.cliHost.platform === 'linux') { const args = ['--security-opt', 'label=disable']; const hasIdMapping = (config.runArgs || []).some(arg => /--[ug]idmap(=|$)/.test(arg)); if (!hasIdMapping) { @@ -446,6 +450,32 @@ async function getPodmanArgs(params: DockerResolverParameters, config: DevContai return []; } +// Convert a --mount string (e.g., "type=bind,source=/a,target=/b,consistency=cached") to -v syntax for wslc. +function convertMountToVolume(mountStr: string): string[] { + const parts = new Map(mountStr.split(',').map(p => { + const eq = p.indexOf('='); + return eq === -1 ? [p, ''] : [p.substring(0, eq), p.substring(eq + 1)]; + })); + const source = parts.get('source') || parts.get('src') || ''; + const target = parts.get('target') || parts.get('dst') || parts.get('destination') || ''; + if (source && target) { + return ['-v', `${source}:${target}`]; + } + if (target) { + return ['-v', target]; + } + // Fallback: pass as --mount and let the runtime handle it. + return ['--mount', mountStr]; +} + +// Convert --mount args array (e.g., ['--mount', 'type=bind,...']) to -v syntax for wslc. +function convertMountArgsToVolume(args: string[]): string[] { + if (args.length === 2 && args[0] === '--mount') { + return convertMountToVolume(args[1]); + } + return args; +} + function getLabels(labels: string[]): string[] { let result: string[] = []; labels.forEach(each => result.push('-l', each)); diff --git a/src/spec-node/templatesCLI/apply.ts b/src/spec-node/templatesCLI/apply.ts index 0fb25c932..507c74f97 100644 --- a/src/spec-node/templatesCLI/apply.ts +++ b/src/spec-node/templatesCLI/apply.ts @@ -3,10 +3,11 @@ import { Log, LogLevel, mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; import * as jsonc from 'jsonc-parser'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { fetchTemplate, SelectedTemplate, TemplateFeatureOption, TemplateOptions } from '../../spec-configuration/containerTemplatesOCI'; import { runAsyncHandler } from '../utils'; import path from 'path'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; export function templateApplyOptions(y: Argv) { return y @@ -24,7 +25,7 @@ export function templateApplyOptions(y: Argv) { }); } -export type TemplateApplyArgs = UnpackArgv>; +export type TemplateApplyArgs = UnpackArgv> & OciAuthArgs; export function templateApplyHandler(args: TemplateApplyArgs) { runAsyncHandler(templateApply.bind(null, args)); @@ -38,6 +39,8 @@ async function templateApply({ 'log-level': inputLogLevel, 'tmp-dir': userProvidedTmpDir, 'omit-paths': omitPathsArg, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: TemplateApplyArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -87,7 +90,7 @@ async function templateApply({ omitPaths, }; - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, workspaceFolder, userProvidedTmpDir); + const files = await fetchTemplate({ output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }, selectedTemplate, workspaceFolder, userProvidedTmpDir); if (!files) { output.write(`Failed to fetch template '${id}'.`, LogLevel.Error); process.exit(1); @@ -152,4 +155,3 @@ function hasJsonParseError(output: Log, errors: jsonc.ParseError[]) { } return errors.length > 0; } - diff --git a/src/spec-node/templatesCLI/metadata.ts b/src/spec-node/templatesCLI/metadata.ts index 6a98848d6..3fea84f89 100644 --- a/src/spec-node/templatesCLI/metadata.ts +++ b/src/spec-node/templatesCLI/metadata.ts @@ -4,8 +4,9 @@ import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; import { fetchOCIManifestIfExists, getRef } from '../../spec-configuration/containerCollectionsOCI'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { runAsyncHandler } from '../utils'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; export function templateMetadataOptions(y: Argv) { return y @@ -15,7 +16,7 @@ export function templateMetadataOptions(y: Argv) { .positional('templateId', { type: 'string', demandOption: true, description: 'Template Identifier' }); } -export type TemplateMetadataArgs = UnpackArgv>; +export type TemplateMetadataArgs = UnpackArgv> & OciAuthArgs; export function templateMetadataHandler(args: TemplateMetadataArgs) { runAsyncHandler(templateMetadata.bind(null, args)); @@ -24,6 +25,8 @@ export function templateMetadataHandler(args: TemplateMetadataArgs) { async function templateMetadata({ 'log-level': inputLogLevel, 'templateId': templateId, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: TemplateMetadataArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -39,7 +42,7 @@ async function templateMetadata({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }; output.write(`Fetching metadata for ${templateId}`, LogLevel.Trace); const templateRef = getRef(output, templateId); diff --git a/src/spec-node/templatesCLI/publish.ts b/src/spec-node/templatesCLI/publish.ts index cff9bb1f0..1e6ad3c9e 100644 --- a/src/spec-node/templatesCLI/publish.ts +++ b/src/spec-node/templatesCLI/publish.ts @@ -5,7 +5,7 @@ import { LogLevel, mapLogLevel } from '../../spec-utils/log'; import { rmLocal } from '../../spec-utils/pfs'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; -import { UnpackArgv } from '../devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from '../devContainersSpecCLI'; import { publishOptions } from '../collectionCommonUtils/publish'; import { getCLIHost } from '../../spec-common/cliHost'; import { loadNativeModule } from '../../spec-common/commonUtils'; @@ -15,6 +15,7 @@ import { packageTemplates } from './packageImpl'; import { getCollectionRef, getRef, OCICollectionRef } from '../../spec-configuration/containerCollectionsOCI'; import { doPublishCommand, doPublishMetadata } from '../collectionCommonUtils/publishCommandImpl'; import { runAsyncHandler } from '../utils'; +import { createOCIAuthDiagnostics } from '../../spec-common/ociAuth'; const collectionType = 'template'; @@ -22,7 +23,7 @@ export function templatesPublishOptions(y: Argv) { return publishOptions(y, 'template'); } -export type TemplatesPublishArgs = UnpackArgv>; +export type TemplatesPublishArgs = UnpackArgv> & OciAuthArgs; export function templatesPublishHandler(args: TemplatesPublishArgs) { runAsyncHandler(templatesPublish.bind(null, args)); @@ -32,7 +33,9 @@ async function templatesPublish({ 'target': targetFolder, 'log-level': inputLogLevel, 'registry': registry, - 'namespace': namespace + 'namespace': namespace, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: TemplatesPublishArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -50,7 +53,7 @@ async function templatesPublish({ terminalDimensions: undefined, }, pkg, new Date(), disposables); - const params = { output, env: process.env }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics: createOCIAuthDiagnostics() }; // Package templates const outputDir = path.join(os.tmpdir(), `/templates-output-${Date.now()}`); diff --git a/src/spec-node/upgradeCommand.ts b/src/spec-node/upgradeCommand.ts index 8773fd5de..dbb123cf6 100644 --- a/src/spec-node/upgradeCommand.ts +++ b/src/spec-node/upgradeCommand.ts @@ -1,5 +1,5 @@ import { Argv } from 'yargs'; -import { UnpackArgv } from './devContainersSpecCLI'; +import { OciAuthArgs, UnpackArgv } from './devContainersSpecCLI'; import { dockerComposeCLIConfig } from './dockerCompose'; import { Log, LogLevel, mapLogLevel } from '../spec-utils/log'; import { createLog } from './devContainers'; @@ -19,6 +19,7 @@ import { isLocalFile, readLocalFile, writeLocalFile } from '../spec-utils/pfs'; import { readFeaturesConfig } from './featureUtils'; import { DevContainerConfig } from '../spec-configuration/configuration'; import { mapNodeArchitectureToGOARCH, mapNodeOSToGOOS } from '../spec-configuration/containerCollectionsOCI'; +import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; export function featuresUpgradeOptions(y: Argv) { return y @@ -47,7 +48,7 @@ export function featuresUpgradeOptions(y: Argv) { }); } -export type FeaturesUpgradeArgs = UnpackArgv>; +export type FeaturesUpgradeArgs = UnpackArgv> & OciAuthArgs; export function featuresUpgradeHandler(args: FeaturesUpgradeArgs) { runAsyncHandler(featuresUpgrade.bind(null, args)); @@ -62,6 +63,8 @@ async function featuresUpgrade({ 'dry-run': dryRun, feature: feature, 'target-version': targetVersion, + 'allow-cross-origin-auth-host': allowedCrossOriginAuthHosts, + 'oci-auth-hardening': ociAuthHardening, }: FeaturesUpgradeArgs) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { @@ -90,6 +93,7 @@ async function featuresUpgrade({ os: mapNodeOSToGOOS(cliHost.platform), arch: mapNodeArchitectureToGOARCH(cliHost.arch), }; + const ociAuthDiagnostics = createOCIAuthDiagnostics(); const dockerParams: DockerCLIParameters = { cliHost, dockerCLI: dockerPath, @@ -98,6 +102,9 @@ async function featuresUpgrade({ output, buildPlatformInfo, targetPlatformInfo: buildPlatformInfo, + allowedCrossOriginAuthHosts, + ociAuthHardening, + ociAuthDiagnostics, }; const workspace = workspaceFromPath(cliHost.path, workspaceFolder); @@ -112,6 +119,9 @@ async function featuresUpgrade({ env: cliHost.env, skipFeatureAutoMapping: false, platform: cliHost.platform, + allowedCrossOriginAuthHosts, + ociAuthHardening, + ociAuthDiagnostics, }; if (feature && targetVersion) { diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 515294e7b..9a62da347 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -10,12 +10,13 @@ import * as os from 'os'; import { ContainerError, toErrorText } from '../spec-common/errors'; import { CLIHost, runCommandNoPty, runCommand, getLocalUsername, PlatformInfo } from '../spec-common/commonUtils'; import { Log, LogLevel, makeLog, nullLog } from '../spec-utils/log'; +import { delay } from '../spec-common/async'; import { CommonDevContainerConfig, ContainerProperties, getContainerProperties, LifecycleCommand, ResolverParameters } from '../spec-common/injectHeadless'; import { Workspace } from '../spec-utils/workspaces'; import { URI } from 'vscode-uri'; import { ShellServer } from '../spec-common/shellServer'; -import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer } from '../spec-shutdown/dockerUtils'; +import { inspectContainer, inspectContainers, inspectImage, getEvents, listContainers, ContainerDetails, DockerCLIParameters, dockerExecFunction, dockerPtyCLI, dockerPtyExecFunction, toDockerImageName, DockerComposeCLI, ImageDetails, dockerCLI, removeContainer, CLIVariant } from '../spec-shutdown/dockerUtils'; import { getRemoteWorkspaceFolder } from './dockerCompose'; import { findGitRootFolder } from '../spec-common/git'; import { parentURI, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; @@ -26,6 +27,7 @@ import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; import { PackageConfiguration } from '../spec-utils/product'; import { ImageMetadataEntry, MergedDevContainerConfig } from './imageMetadata'; import { getImageIndexEntryForPlatform, getManifest, getRef } from '../spec-configuration/containerCollectionsOCI'; +import { createOCIAuthDiagnostics, OCIAuthDiagnostics } from '../spec-common/ociAuth'; import { requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; import { configFileLabel, findDevContainer, hostFolderLabel } from './singleContainer'; export { getConfigFilePath, getDockerfilePath, isDockerFileConfig } from '../spec-configuration/configuration'; @@ -108,7 +110,7 @@ export interface DockerResolverParameters { common: ResolverParameters; parsedAuthority: ParsedAuthority | undefined; dockerCLI: string; - isPodman: boolean; + cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; workspaceMountConsistencyDefault: BindMountConsistency; @@ -170,6 +172,9 @@ export function addSubstitution, canceled: Promise, output: Log, trace: boolean) { + if (params.cliVariant === CLIVariant.Wslc) { + return startEventSeenPolling(params, labels, canceled, output, trace); + } const eventsProcess = await getEvents(params, { event: ['start'] }); return { started: new Promise((resolve, reject) => { @@ -209,6 +214,36 @@ export async function startEventSeen(params: DockerResolverParameters, labels: R }; } +// Polling-based fallback for runtimes that don't support `events` (e.g., wslc). +function startEventSeenPolling(params: DockerResolverParameters, labels: Record, canceled: Promise, output: Log, trace: boolean) { + let stopped = false; + canceled.catch(() => { stopped = true; }); + const labelFilters = Object.entries(labels).map(([k, v]) => `${k}=${v}`); + return { + started: new Promise((resolve, reject) => { + canceled.catch(reject); + const poll = async () => { + while (!stopped) { + try { + const containers = await listContainers(params, false, labelFilters); + if (trace) { + output.write(`Log: startEventSeenPolling found ${containers.length} container(s)\r\n`); + } + if (containers.length > 0) { + resolve(); + return; + } + } catch (e) { + // Ignore transient errors during polling. + } + await delay(500); + } + }; + poll(); + }) + }; +} + async function hasLabels(params: DockerResolverParameters, info: any, expectedLabels: Record) { const actualLabels = info.Actor?.Attributes // Docker uses 'id', Podman 'ID'. @@ -251,7 +286,10 @@ export async function inspectDockerImage(params: DockerResolverParameters | Dock throw inspectErr; } try { - return await inspectImageInRegistry(output, params.targetPlatformInfo, imageName); + const allowedCrossOriginAuthHosts = 'cliHost' in params ? params.allowedCrossOriginAuthHosts : params.common.allowedCrossOriginAuthHosts; + const ociAuthHardening = 'cliHost' in params ? params.ociAuthHardening : params.common.ociAuthHardening; + const ociAuthDiagnostics = 'cliHost' in params ? params.ociAuthDiagnostics : params.common.ociAuthDiagnostics; + return await inspectImageInRegistry(output, params.targetPlatformInfo, imageName, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics); } catch (inspectErr2) { output.write(`Error fetching image details: ${inspectErr2?.message}`, LogLevel.Info); } @@ -283,16 +321,17 @@ function logErrorStdoutStderr(err: any, output: Log) { } } -export async function inspectImageInRegistry(output: Log, platformInfo: PlatformInfo, name: string): Promise { +export async function inspectImageInRegistry(output: Log, platformInfo: PlatformInfo, name: string, allowedCrossOriginAuthHosts?: string[], ociAuthHardening?: boolean, ociAuthDiagnostics: OCIAuthDiagnostics = createOCIAuthDiagnostics()): Promise { const resourceAndVersion = qualifyImageName(name); - const params = { output, env: process.env }; + const params = { output, env: process.env, allowedCrossOriginAuthHosts, ociAuthHardening, ociAuthDiagnostics }; const ref = getRef(output, resourceAndVersion); if (!ref) { throw new Error(`Could not parse image name '${name}'`); } const registryServer = ref.registry === 'docker.io' ? 'registry-1.docker.io' : ref.registry; - const manifestUrl = `https://${registryServer}/v2/${ref.path}/manifests/${ref.version}`; + const registryOrigin = `${ref.scheme}://${registryServer}`; + const manifestUrl = `${registryOrigin}/v2/${ref.path}/manifests/${ref.version}`; output.write(`manifest url: ${manifestUrl}`, LogLevel.Trace); let targetDigest: string | undefined = undefined; @@ -304,7 +343,7 @@ export async function inspectImageInRegistry(output: Log, platformInfo: Platform // Spec: https://github.com/opencontainers/image-spec/blob/main/image-index.md const imageIndexEntry = await getImageIndexEntryForPlatform(params, manifestUrl, ref, platformInfo); if (imageIndexEntry) { - const manifestUrl = `https://${registryServer}/v2/${ref.path}/manifests/${imageIndexEntry.digest}`; + const manifestUrl = `${registryOrigin}/v2/${ref.path}/manifests/${imageIndexEntry.digest}`; const a = await getManifest(params, manifestUrl, ref); if (a) { targetDigest = a.manifestObj.config.digest; @@ -316,7 +355,7 @@ export async function inspectImageInRegistry(output: Log, platformInfo: Platform throw new Error(`No manifest found for ${resourceAndVersion}.`); } - const blobUrl = `https://${registryServer}/v2/${ref.path}/blobs/${targetDigest}`; + const blobUrl = `${registryOrigin}/v2/${ref.path}/blobs/${targetDigest}`; output.write(`blob url: ${blobUrl}`, LogLevel.Trace); const httpOptions = { diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 9f0bce850..3a15aa6b7 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -10,6 +10,7 @@ import { Log, makeLog } from '../spec-utils/log'; import { Event } from '../spec-utils/event'; import { escapeRegExCharacters } from '../spec-utils/strings'; import { delay } from '../spec-common/async'; +import { OCIAuthDiagnostics } from '../spec-common/ociAuth'; export interface ContainerDetails { Id: string; @@ -54,6 +55,9 @@ export interface DockerCLIParameters { output: Log; buildPlatformInfo: PlatformInfo; targetPlatformInfo: PlatformInfo; + allowedCrossOriginAuthHosts?: string[]; + ociAuthHardening?: boolean; + ociAuthDiagnostics: OCIAuthDiagnostics; } export interface PartialExecParameters { @@ -77,7 +81,7 @@ export interface PartialPtyExecParameters { interface DockerResolverParameters { dockerCLI: string; - isPodman: boolean; + cliVariant: CLIVariant; dockerComposeCLI: () => Promise; dockerEnv: NodeJS.ProcessEnv; common: { @@ -171,6 +175,7 @@ export async function listContainers(params: DockerCLIParameters | PartialExecPa } export async function removeContainer(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, nameOrId: string) { + const useEvents = !('cliVariant' in params && params.cliVariant === CLIVariant.Wslc); let eventsProcess: Exec | undefined; let removedSeenP: Promise | undefined; try { @@ -184,7 +189,7 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP if (i === n - 1 || !stderr.includes('already in progress')) { throw err; } - if (!removedSeenP) { + if (useEvents && !removedSeenP) { eventsProcess = await getEvents(params, { container: [nameOrId], event: ['destroy'], @@ -197,7 +202,11 @@ export async function removeContainer(params: DockerCLIParameters | PartialExecP }); }); } - await Promise.race([removedSeenP, delay(1000)]); + if (removedSeenP) { + await Promise.race([removedSeenP, delay(1000)]); + } else { + await delay(1000); + } } } } finally { @@ -215,7 +224,7 @@ export async function getEvents(params: DockerCLIParameters | PartialExecParamet filterArgs.push('--filter', `${filter}=${value}`); } } - const format = 'isPodman' in params && params.isPodman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981 + const format = 'cliVariant' in params && params.cliVariant === CLIVariant.Podman ? 'json' : '{{json .}}'; // https://github.com/containers/libpod/issues/5981 const combinedArgs = (args || []).concat(['events', '--format', format, ...filterArgs]); const p = await exec({ @@ -260,13 +269,16 @@ export async function dockerBuildKitVersion(params: DockerCLIParameters | Partia } } -export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters): Promise<{ versionString: string; versionMatch?: string } | undefined> { +export async function dockerEngineVersion(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, options?: { useSimpleVersion?: boolean }): Promise<{ versionString: string; versionMatch?: string } | undefined> { try { const execParams = { ...toExecParameters(params), print: true, }; - const result = await dockerCLI(execParams, 'version', '--format', '{{.Server.Version}}'); + const args: string[] = options?.useSimpleVersion + ? ['version'] + : ['version', '--format', '{{.Server.Version}}']; + const result = await dockerCLI(execParams, ...args); const versionString = result.stdout.toString().trim(); const versionMatch = versionString.match(/(?[0-9]+)\.(?[0-9]+)\.(?[0-9]+)/); if (!versionMatch) { @@ -286,13 +298,26 @@ export async function dockerCLI(params: DockerCLIParameters | PartialExecParamet }); } -export async function isPodman(params: PartialExecParameters) { +export enum CLIVariant { + Docker = 'docker', + Podman = 'podman', + Wslc = 'wslc', +} + +export async function lookupCLIVariant(params: PartialExecParameters): Promise { try { const { stdout } = await dockerCLI(params, '-v'); - return stdout.toString().toLowerCase().indexOf('podman') !== -1; - } catch (err) { - return false; + const lower = stdout.toString().toLowerCase(); + if (lower.indexOf('wslc') !== -1) { + return CLIVariant.Wslc; + } + if (lower.indexOf('podman') !== -1) { + return CLIVariant.Podman; + } + } catch (_err) { + // fall through } + return CLIVariant.Docker; } export async function dockerPtyCLI(params: PartialPtyExecParameters | DockerResolverParameters | DockerCLIParameters, ...args: string[]) { diff --git a/src/spec-utils/httpRequest.ts b/src/spec-utils/httpRequest.ts index 162c55cc5..64cdc1daa 100644 --- a/src/spec-utils/httpRequest.ts +++ b/src/spec-utils/httpRequest.ts @@ -25,7 +25,7 @@ export async function request(options: { type: string; url: string; headers: Rec secureContext, }; - const plainHTTP = parsed.protocol === 'http:' || parsed.hostname === 'localhost'; + const plainHTTP = parsed.protocol === 'http:'; if (plainHTTP) { output.write('Sending as plain HTTP request', LogLevel.Warning); } @@ -64,7 +64,7 @@ export async function headRequest(options: { url: string; headers: Record; + data?: Buffer; +}; + // Send HTTP Request. // Does not throw on status code, but rather always returns 'statusCode', 'resHeaders', and 'resBody'. -export async function requestResolveHeaders(options: { type: string; url: string; headers: Record; data?: Buffer }, output: Log) { +export async function requestResolveHeaders(options: RequestResolveHeadersOptions, output: Log) { + return requestResolveHeadersInternal(options, output); +} + +// Token endpoints must not redirect around their validated authority boundary. +export async function requestResolveHeadersNoRedirects(options: RequestResolveHeadersOptions, output: Log) { + return requestResolveHeadersInternal(options, output, 0); +} + +async function requestResolveHeadersInternal(options: RequestResolveHeadersOptions, output: Log, maxRedirects?: number) { const secureContext = await secureContextWithExtraCerts(output); - return new Promise<{ statusCode: number; resHeaders: Record; resBody: Buffer }>((resolve, reject) => { + return new Promise<{ statusCode: number; resHeaders: Record; resBody: Buffer; responseUrl: string; redirected: boolean }>((resolve, reject) => { const parsed = new url.URL(options.url); const reqOptions: RequestOptions & tls.CommonConnectionOptions & FollowOptions = { hostname: parsed.hostname, @@ -94,9 +110,13 @@ export async function requestResolveHeaders(options: { type: string; url: string headers: options.headers, agent: new ProxyAgent(), secureContext, + trackRedirects: true, }; + if (maxRedirects !== undefined) { + reqOptions.maxRedirects = maxRedirects; + } - const plainHTTP = parsed.protocol === 'http:' || parsed.hostname === 'localhost'; + const plainHTTP = parsed.protocol === 'http:'; if (plainHTTP) { output.write('Sending as plain HTTP request', LogLevel.Warning); } @@ -111,7 +131,9 @@ export async function requestResolveHeaders(options: { type: string; url: string resolve({ statusCode: res.statusCode!, resHeaders: res.headers! as Record, - resBody: Buffer.concat(chunks) + resBody: Buffer.concat(chunks), + responseUrl: res.responseUrl, + redirected: res.redirects.length > 1, }); }); }); diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index f409cb0fd..a33220716 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -27,6 +27,11 @@ describe('Dev Containers CLI', function () { assert.ok(res.stdout.indexOf('run-user-commands'), 'Help text is not mentioning run-user-commands.'); }); + it('Global options consume exactly one argument', async () => { + const res = await shellExec(`${cli} --oci-auth-hardening --allow-cross-origin-auth-host registry.example=auth.example features info --help`); + assert.ok(res.stdout.includes('devcontainer features info ')); + }); + describe('Command run-user-commands', () => { describe('with valid config', () => { let containerId: string | null = null; diff --git a/src/test/container-features/containerFeaturesOCI.test.ts b/src/test/container-features/containerFeaturesOCI.test.ts index 9281a7498..982606cc0 100644 --- a/src/test/container-features/containerFeaturesOCI.test.ts +++ b/src/test/container-features/containerFeaturesOCI.test.ts @@ -1,9 +1,26 @@ import { assert } from 'chai'; import { getRef, getManifest, getBlob, getCollectionRef } from '../../spec-configuration/containerCollectionsOCI'; +import { createTestCommonParams } from '../testUtils'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); +describe('registry scheme', () => { + const cases = [ + { identifier: 'localhost/owner/features/test', expected: 'http' }, + { identifier: 'localhost:5000/owner/features/test', expected: 'http' }, + { identifier: 'LOCALHOST:5000/owner/features/test', expected: 'http' }, + { identifier: '127.0.0.1:5000/owner/features/test', expected: 'https' }, + { identifier: 'registry.example:5000/owner/features/test', expected: 'https' }, + ]; + + for (const { identifier, expected } of cases) { + it(`uses '${expected}' for '${identifier}'`, () => { + assert.equal(getRef(output, identifier)?.scheme, expected); + }); + } +}); + describe('getCollectionRef()', async function () { this.timeout('240s'); @@ -280,7 +297,7 @@ describe('Test OCI Pull', async function () { if (!featureRef) { assert.fail('featureRef should not be undefined'); } - const manifest = await getManifest({ output, env: process.env }, 'https://ghcr.io/v2/codspace/features/ruby/manifests/1.0.13', featureRef); + const manifest = await getManifest(createTestCommonParams(output), 'https://ghcr.io/v2/codspace/features/ruby/manifests/1.0.13', featureRef); assert.isNotNull(manifest); assert.exists(manifest); @@ -306,7 +323,7 @@ describe('Test OCI Pull', async function () { if (!featureRef) { assert.fail('featureRef should not be undefined'); } - const blobResult = await getBlob({ output, env: process.env }, 'https://ghcr.io/v2/codspace/features/ruby/blobs/sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb', '/tmp', '/tmp/featureTest', featureRef, 'sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb'); + const blobResult = await getBlob(createTestCommonParams(output), 'https://ghcr.io/v2/codspace/features/ruby/blobs/sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb', '/tmp', '/tmp/featureTest', featureRef, 'sha256:8f59630bd1ba6d9e78b485233a0280530b3d0a44338f472206090412ffbd3efb'); assert.isDefined(blobResult); assert.isArray(blobResult?.files); }); diff --git a/src/test/container-features/containerFeaturesOCIPush.test.ts b/src/test/container-features/containerFeaturesOCIPush.test.ts index b6672ffc9..8a4b6bf40 100644 --- a/src/test/container-features/containerFeaturesOCIPush.test.ts +++ b/src/test/container-features/containerFeaturesOCIPush.test.ts @@ -3,7 +3,7 @@ import { DEVCONTAINER_TAR_LAYER_MEDIATYPE, getRef } from '../../spec-configurati import { fetchOCIFeatureManifestIfExistsFromUserIdentifier } from '../../spec-configuration/containerFeaturesOCI'; import { calculateDataLayer, checkIfBlobExists, calculateManifestAndContentDigest } from '../../spec-configuration/containerCollectionsOCIPush'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; -import { ExecResult, shellExec } from '../testUtils'; +import { createTestCommonParams, ExecResult, shellExec } from '../testUtils'; import * as path from 'path'; import * as fs from 'fs'; import { readLocalFile, writeLocalFile } from '../../spec-utils/pfs'; @@ -352,7 +352,7 @@ describe('Test OCI Push Helper Functions', function () { }); it('Can fetch an artifact from a digest reference', async () => { - const manifest = await fetchOCIFeatureManifestIfExistsFromUserIdentifier({ output, env: process.env }, 'ghcr.io/codspace/non-empty-config-layer/color', 'sha256:dd328c25cc7382aaf4e9ee10104425d9a2561b47fe238407f6c0f77b3f8409fc'); + const manifest = await fetchOCIFeatureManifestIfExistsFromUserIdentifier(createTestCommonParams(output), 'ghcr.io/codspace/non-empty-config-layer/color', 'sha256:dd328c25cc7382aaf4e9ee10104425d9a2561b47fe238407f6c0f77b3f8409fc'); assert.strictEqual(manifest?.manifestObj.layers[0].annotations['org.opencontainers.image.title'], 'devcontainer-feature-color.tgz'); }); @@ -363,13 +363,14 @@ describe('Test OCI Push Helper Functions', function () { } - const tarLayerBlobExists = await checkIfBlobExists({ output, env: process.env }, ociFeatureRef, 'sha256:0bb92d2da46d760c599d0a41ed88d52521209408b529761417090b62ee16dfd1'); + const params = createTestCommonParams(output); + const tarLayerBlobExists = await checkIfBlobExists(params, ociFeatureRef, 'sha256:0bb92d2da46d760c599d0a41ed88d52521209408b529761417090b62ee16dfd1'); assert.isTrue(tarLayerBlobExists); - const configLayerBlobExists = await checkIfBlobExists({ output, env: process.env }, ociFeatureRef, 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'); + const configLayerBlobExists = await checkIfBlobExists(params, ociFeatureRef, 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'); assert.isTrue(configLayerBlobExists); - const randomStringDoesNotExist = await checkIfBlobExists({ output, env: process.env }, ociFeatureRef, 'sha256:41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3'); + const randomStringDoesNotExist = await checkIfBlobExists(params, ociFeatureRef, 'sha256:41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3'); assert.isFalse(randomStringDoesNotExist); }); }); \ No newline at end of file diff --git a/src/test/container-features/containerFeaturesOrder.test.ts b/src/test/container-features/containerFeaturesOrder.test.ts index 8426a0946..e4d6d8d84 100644 --- a/src/test/container-features/containerFeaturesOrder.test.ts +++ b/src/test/container-features/containerFeaturesOrder.test.ts @@ -10,15 +10,15 @@ import { DevContainerConfig, DevContainerFeature } from '../../spec-configuratio import { CommonParams } from '../../spec-configuration/containerCollectionsOCI'; import { LogLevel, createPlainLog, makeLog } from '../../spec-utils/log'; import { isLocalFile, readLocalFile } from '../../spec-utils/pfs'; +import { createTestCommonParams } from '../testUtils'; // const pkg = require('../../../package.json'); export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Info)); async function setupInstallOrderTest(testWorkspaceFolder: string) { const params: CommonParams = { - env: process.env, - output, - cachedAuthHeader: {} + ...createTestCommonParams(output), + cachedAuthHeader: {}, }; const configPath = `${testWorkspaceFolder}/.devcontainer/devcontainer.json`; @@ -674,7 +674,8 @@ describe('Feature Dependencies', function () { } }); - assert.deepStrictEqual(actual.length, 3); + // The deprecated terraform shorthand resolves to terraform:1, which has github-cli as a hard dependency. + assert.deepStrictEqual(actual.length, 4); assert.deepStrictEqual(actual, [ { @@ -687,6 +688,12 @@ describe('Feature Dependencies', function () { greeting: 'howdy' } }, + { + id: 'ghcr.io/devcontainers/features/github-cli', + options: { + version: 'latest' + } + }, { id: 'ghcr.io/devcontainers/features/terraform', options: 'latest' diff --git a/src/test/container-features/e2e.test.ts b/src/test/container-features/e2e.test.ts index 7976b0c95..eb1b8791e 100644 --- a/src/test/container-features/e2e.test.ts +++ b/src/test/container-features/e2e.test.ts @@ -88,7 +88,8 @@ describe('Dev Container Features E2E (remote)', function () { const response = JSON.parse(res.stdout); console.log(res.stderr); - assert.strictEqual(response.featuresConfiguration?.featureSets.length, 3); + // The deprecated terraform shorthand resolves to terraform:1, which has github-cli as a hard dependency. + assert.strictEqual(response.featuresConfiguration?.featureSets.length, 4); const dind = response?.featuresConfiguration.featureSets.find((f: FeatureSet) => f?.features[0]?.id === 'docker-in-docker'); assert.exists(dind); diff --git a/src/test/container-features/featureHelpers.test.ts b/src/test/container-features/featureHelpers.test.ts index 3e08c0648..cbc6a92a0 100644 --- a/src/test/container-features/featureHelpers.test.ts +++ b/src/test/container-features/featureHelpers.test.ts @@ -7,10 +7,11 @@ import { getSafeId, findContainerUsers } from '../../spec-node/containerFeatures import { ImageMetadataEntry } from '../../spec-node/imageMetadata'; import { SubstitutedConfig } from '../../spec-node/utils'; import { createPlainLog, LogLevel, makeLog, nullLog } from '../../spec-utils/log'; +import { createTestCommonParams } from '../testUtils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); -const params = { output, env: process.env }; +const params = createTestCommonParams(output); describe('getIdSafe should return safe environment variable name', function () { @@ -205,6 +206,7 @@ describe('validate processFeatureIdentifier', async function () { assert.exists(featureSet); const expectedFeatureRef: OCIRef = { + scheme: 'https', id: 'ruby', owner: 'codspace', namespace: 'codspace/features', @@ -241,6 +243,7 @@ describe('validate processFeatureIdentifier', async function () { assert.exists(featureSet); const expectedFeatureRef: OCIRef = { + scheme: 'https', id: 'ruby', owner: 'devcontainers', namespace: 'devcontainers/features', @@ -277,6 +280,7 @@ describe('validate processFeatureIdentifier', async function () { assert.exists(featureSet); const expectedFeatureRef: OCIRef = { + scheme: 'https', id: 'ruby', owner: 'codspace', namespace: 'codspace/features', @@ -630,6 +634,7 @@ chmod +x ./install.sh userFeatureId: 'ghcr.io/my-org/my-repo/test:1', userFeatureIdWithoutVersion: 'ghcr.io/my-org/my-repo/test', featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'my-org', namespace: 'my-org/my-repo', @@ -714,6 +719,7 @@ chmod +x ./install.sh userFeatureId: 'ghcr.io/my-org/my-repo/test:1', userFeatureIdWithoutVersion: 'ghcr.io/my-org/my-repo/test', featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'my-org', namespace: 'my-org/my-repo', @@ -801,6 +807,7 @@ chmod +x ./install.sh userFeatureId: 'ghcr.io/my-org/my-repo/test:1', userFeatureIdWithoutVersion: 'ghcr.io/my-org/my-repo/test', featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'my-org', namespace: 'my-org/my-repo', diff --git a/src/test/container-features/featuresCLICommands.test.ts b/src/test/container-features/featuresCLICommands.test.ts index 2dd2bd0e8..74bc375c8 100644 --- a/src/test/container-features/featuresCLICommands.test.ts +++ b/src/test/container-features/featuresCLICommands.test.ts @@ -3,7 +3,7 @@ import path from 'path'; import { existsSync } from 'fs'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; import { isLocalFile, readLocalFile } from '../../spec-utils/pfs'; -import { ExecResult, shellExec } from '../testUtils'; +import { createTestCommonParams, ExecResult, shellExec } from '../testUtils'; import { getSemanticTags } from '../../spec-node/collectionCommonUtils/publishCommandImpl'; import { getRef, getPublishedTags, getVersionsStrictSorted } from '../../spec-configuration/containerCollectionsOCI'; import { generateFeaturesDocumentation } from '../../spec-node/collectionCommonUtils/generateDocsCommandImpl'; @@ -661,13 +661,14 @@ describe('test function getSermanticVersions', () => { }); describe('test functions getVersionsStrictSorted and getPublishedTags', async () => { + const params = createTestCommonParams(output); it('should list published versions', async () => { const resource = 'ghcr.io/devcontainers/features/node'; const featureRef = getRef(output, resource); if (!featureRef) { assert.fail('featureRef should not be undefined'); } - const publishedTags = await getPublishedTags({ output, env: process.env }, featureRef) ?? []; + const publishedTags = await getPublishedTags(params, featureRef) ?? []; assert.includeMembers(publishedTags, ['1', '1.0', '1.0.0', 'latest']); }); @@ -678,7 +679,7 @@ describe('test functions getVersionsStrictSorted and getPublishedTags', async () if (!ref) { assert.fail('ref should not be undefined'); } - const versionsList = await getVersionsStrictSorted({ output, env: process.env }, ref) ?? []; + const versionsList = await getVersionsStrictSorted(params, ref) ?? []; console.log(versionsList); const expectedVersions = [ '0.0.0', @@ -722,7 +723,7 @@ describe('test functions getVersionsStrictSorted and getPublishedTags', async () assert.deepStrictEqual(versionsList, expectedVersions); - const publishedTags = await getPublishedTags({ output, env: process.env }, ref) ?? []; + const publishedTags = await getPublishedTags(params, ref) ?? []; const expectedTags = [ 'latest', '0', diff --git a/src/test/container-features/generateFeaturesConfig.test.ts b/src/test/container-features/generateFeaturesConfig.test.ts index 915c3e2da..349930a25 100644 --- a/src/test/container-features/generateFeaturesConfig.test.ts +++ b/src/test/container-features/generateFeaturesConfig.test.ts @@ -1,27 +1,27 @@ import { assert } from 'chai'; -import { generateFeaturesConfig, getFeatureLayers, FeatureSet } from '../../spec-configuration/containerFeaturesConfiguration'; +import { generateFeaturesConfig, getFeatureLayers, getContainerFeaturesBaseDockerFile, FeatureSet } from '../../spec-configuration/containerFeaturesConfiguration'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; import * as path from 'path'; import * as process from 'process'; import * as os from 'os'; import * as crypto from 'crypto'; -import { mkdirpLocal } from '../../spec-utils/pfs'; +import { mkdirpLocal, readLocalFile } from '../../spec-utils/pfs'; import { DevContainerConfig } from '../../spec-configuration/configuration'; import { URI } from 'vscode-uri'; import { getLocalCacheFolder } from '../../spec-node/utils'; -import { shellExec } from '../testUtils'; +import { createTestCommonParams, findFromArgsWithoutDefault, shellExec } from '../testUtils'; import { getEntPasswdShellCommand } from '../../spec-common/commonUtils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); -// Test fetching/generating the devcontainer-features.json config +// Testing fetching/generating the devcontainer-features.json config describe('validate generateFeaturesConfig()', function () { - // Setup + // Setup for tests const env = { 'SOME_KEY': 'SOME_VAL' }; const platform = process.platform; const cacheFolder = path.join(os.tmpdir(), `devcontainercli-test-${crypto.randomUUID()}`); - const params = { extensionPath: '', cwd: '', output, env, cacheFolder, persistedFolder: '', skipFeatureAutoMapping: false, platform, noLockfile: true }; + const params = { ...createTestCommonParams(output, env), extensionPath: '', cwd: '', cacheFolder, persistedFolder: '', skipFeatureAutoMapping: false, platform, noLockfile: true }; it('should correctly return a featuresConfig with v2 local features', async function () { const version = 'unittest'; @@ -135,4 +135,25 @@ RUN chmod -R 0755 /tmp/dev-container-features/hello_1 \\ const javaSettings = java?.features[0]?.customizations?.vscode?.settings; assert.isObject(javaSettings); }); +}); + +describe('validate generated Dockerfiles avoid InvalidDefaultArgInFrom', function () { + + it('feature base Dockerfile declares _DEV_CONTAINERS_BASE_IMAGE with a default before FROM', function () { + const dockerfile = getContainerFeaturesBaseDockerFile('/tmp/build-features'); + assert.match(dockerfile, /ARG _DEV_CONTAINERS_BASE_IMAGE=\S+/, 'ARG should have a default value'); + assert.match(dockerfile, /FROM \$_DEV_CONTAINERS_BASE_IMAGE\b/, 'FROM should reference the ARG directly'); + assert.notMatch(dockerfile, /FROM \$\{_DEV_CONTAINERS_BASE_IMAGE:-/, 'should not rely on ${VAR:-default} shell fallback'); + assert.deepStrictEqual(findFromArgsWithoutDefault(dockerfile), []); + }); + + it('validate that updateUID.Dockerfile declares BASE_IMAGE with a default before FROM', async function () { + const content = (await readLocalFile('scripts/updateUID.Dockerfile')).toString(); + assert.match(content, /ARG BASE_IMAGE=\S+/, 'BASE_IMAGE ARG should have a default value'); + assert.deepStrictEqual( + findFromArgsWithoutDefault(content), + [], + 'updateUID.Dockerfile FROM references an ARG without a default' + ); + }); }); \ No newline at end of file diff --git a/src/test/container-features/generateLockfile.test.ts b/src/test/container-features/generateLockfile.test.ts index 4737a59a4..4919c64e4 100644 --- a/src/test/container-features/generateLockfile.test.ts +++ b/src/test/container-features/generateLockfile.test.ts @@ -22,6 +22,7 @@ function makeOciFeatureSet(userFeatureId: string, version: string, digest: strin manifestDigest: digest, manifest: {} as any, featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'devcontainers', namespace: 'devcontainers/features', diff --git a/src/test/container-templates/containerTemplatesOCI.test.ts b/src/test/container-templates/containerTemplatesOCI.test.ts index 42e73b5b5..52b5efd9f 100644 --- a/src/test/container-templates/containerTemplatesOCI.test.ts +++ b/src/test/container-templates/containerTemplatesOCI.test.ts @@ -5,9 +5,11 @@ import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); import { fetchTemplate, SelectedTemplate } from '../../spec-configuration/containerTemplatesOCI'; import { readLocalFile } from '../../spec-utils/pfs'; +import { createTestCommonParams } from '../testUtils'; describe('fetchTemplate', async function () { this.timeout('120s'); + const params = createTestCommonParams(output); it('template apply docker-from-docker without features and with user options', async () => { @@ -20,7 +22,7 @@ describe('fetchTemplate', async function () { }; const dest = path.relative(process.cwd(), path.join(__dirname, 'tmp1')); - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, dest); + const files = await fetchTemplate(params, selectedTemplate, dest); assert.ok(files); // Should only container 1 file '.devcontainer.json'. The other 3 in this repo should be ignored. assert.strictEqual(files.length, 1); @@ -50,7 +52,7 @@ describe('fetchTemplate', async function () { }; const dest = path.relative(process.cwd(), path.join(__dirname, 'tmp2')); - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, dest); + const files = await fetchTemplate(params, selectedTemplate, dest); assert.ok(files); // Should only container 1 file '.devcontainer.json'. The other 3 in this repo should be ignored. assert.strictEqual(files.length, 1); @@ -80,7 +82,7 @@ describe('fetchTemplate', async function () { }; const dest = path.relative(process.cwd(), path.join(__dirname, 'tmp3')); - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, dest); + const files = await fetchTemplate(params, selectedTemplate, dest); assert.ok(files); // Should only container 1 file '.devcontainer.json'. The other 3 in this repo should be ignored. assert.strictEqual(files.length, 1); @@ -113,7 +115,7 @@ describe('fetchTemplate', async function () { }; const dest = path.relative(process.cwd(), path.join(__dirname, 'tmp4')); - const files = await fetchTemplate({ output, env: process.env }, selectedTemplate, dest); + const files = await fetchTemplate(params, selectedTemplate, dest); assert.ok(files); // Expected: // ./environment.yml, ./.devcontainer/.env, ./.devcontainer/Dockerfile, ./.devcontainer/devcontainer.json, ./.devcontainer/docker-compose.yml, ./.devcontainer/noop.txt, ./.github/dependabot.yml @@ -161,7 +163,7 @@ describe('fetchTemplate', async function () { }; const files = await fetchTemplate( - { output, env: process.env }, + params, selectedTemplate, path.join(os.tmpdir(), 'vsch-test-template-temp', `${Date.now()}`) ); @@ -182,7 +184,7 @@ describe('fetchTemplate', async function () { }; const files = await fetchTemplate( - { output, env: process.env }, + params, selectedTemplate, path.join(os.tmpdir(), 'vsch-test-template-temp', `${Date.now()}`) ); @@ -209,7 +211,7 @@ describe('fetchTemplate', async function () { }; const files = await fetchTemplate( - { output, env: process.env }, + params, selectedTemplate, path.join(os.tmpdir(), 'vsch-test-template-temp', `${Date.now()}`) ); @@ -232,5 +234,3 @@ describe('fetchTemplate', async function () { }); - - diff --git a/src/test/httpOCIRegistry.test.ts b/src/test/httpOCIRegistry.test.ts new file mode 100644 index 000000000..7e28d14b4 --- /dev/null +++ b/src/test/httpOCIRegistry.test.ts @@ -0,0 +1,685 @@ +import { execFile } from 'child_process'; +import * as http from 'http'; +import * as https from 'https'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { AddressInfo } from 'net'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { promisify } from 'util'; + +import { assert } from 'chai'; + +import { OCICollectionRef } from '../spec-configuration/containerCollectionsOCI'; +import { isAllowedTokenServiceRealm, isOCIRegistryOrigin, parseCrossOriginAuthHosts, requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; +import { nullLog } from '../spec-utils/log'; +import { createTestCommonParams } from './testUtils'; + +const execFileAsync = promisify(execFile); +const certificateFolder = join(__dirname, 'fixtures'); +const certificatePath = join(certificateFolder, 'localhost-cert.pem'); +const privateKeyPath = join(certificateFolder, 'localhost-key.pem'); + +async function getLocalhostCertificate() { + try { + return { + cert: await readFile(certificatePath), + key: await readFile(privateKeyPath), + }; + } catch (error) { + if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') { + throw error; + } + } + + await mkdir(certificateFolder, { recursive: true }); + await execFileAsync('openssl', [ + 'req', + '-x509', + '-newkey', 'rsa:2048', + '-nodes', + '-keyout', privateKeyPath, + '-out', certificatePath, + '-days', '3650', + '-subj', '/CN=localhost', + '-addext', 'subjectAltName=DNS:localhost', + ]); + return { + cert: await readFile(certificatePath), + key: await readFile(privateKeyPath), + }; +} + +describe('OCI registry authentication', () => { + describe('isAllowedTokenServiceRealm', () => { + const cases = [ + { realm: 'https://registry.example/token', registryUrl: 'https://registry.example/v2/', expected: true }, + { realm: 'https://REGISTRY.EXAMPLE/token', registryUrl: 'https://registry.example/v2/', expected: true }, + { realm: 'https://registry.example/token', registryUrl: 'https://registry.example:443/v2/', expected: true }, + { realm: 'https://registry.example:8443/token', registryUrl: 'https://registry.example:8443/v2/', expected: true }, + { realm: 'https://registry.example:8443/token', registryUrl: 'https://registry.example/v2/', expected: false }, + { realm: 'http://registry.example/token', registryUrl: 'https://registry.example/v2/', expected: false }, + { realm: 'http://localhost:5000/token', registryUrl: 'https://localhost:5000/v2/', expected: true }, + { realm: 'http://localhost:5001/token', registryUrl: 'https://localhost:5000/v2/', expected: false }, + { realm: 'not-a-url', registryUrl: 'https://registry.example/v2/', expected: false }, + { realm: '/token', registryUrl: 'https://registry.example/v2/', expected: false }, + { realm: 'https://auth.docker.io/token', registryUrl: 'https://registry-1.docker.io/v2/', expected: true }, + { realm: 'https://auth.docker.io/token', registryUrl: 'https://docker.io/v2/', expected: true }, + { realm: 'https://auth.docker.io/token', registryUrl: 'https://attacker.example/v2/', expected: false }, + { realm: 'http://auth.docker.io/token', registryUrl: 'https://registry-1.docker.io/v2/', expected: false }, + { realm: 'https://gitlab.com/jwt/auth', registryUrl: 'https://registry.gitlab.com/v2/', expected: true }, + { realm: 'https://gitlab.com/jwt/auth', registryUrl: 'https://attacker.example/v2/', expected: false }, + { realm: 'https://ghcr.io/token', registryUrl: 'https://ghcr.io/v2/', expected: true }, + { realm: 'https://ghcr.io/token', registryUrl: 'https://containers.example/v2/', expected: false }, + { realm: 'https://registry.azurecr.io/oauth2/token', registryUrl: 'https://registry.azurecr.io/v2/', expected: true }, + { realm: 'https://registry.azurecr.io/oauth2/token', registryUrl: 'https://containers.example/v2/', expected: false }, + { realm: 'https://auth.docker.io.attacker.example/token', registryUrl: 'https://registry-1.docker.io/v2/', expected: false }, + { realm: 'https://auth.docker.io:8443/token', registryUrl: 'https://registry-1.docker.io/v2/', expected: false }, + { realm: 'http://127.0.0.1/token', registryUrl: 'https://attacker.example/v2/', expected: false }, + { realm: 'http://169.254.169.254/token', registryUrl: 'https://attacker.example/v2/', expected: false }, + ]; + + for (const { realm, registryUrl, expected } of cases) { + it(`${expected ? 'allows' : 'rejects'} '${realm}' for '${registryUrl}'`, () => { + assert.equal(isAllowedTokenServiceRealm(realm, registryUrl), expected); + }); + } + + it('allows an explicitly configured registry-to-auth-host mapping', () => { + assert.isTrue(isAllowedTokenServiceRealm( + 'https://auth.example/token', + 'https://registry.example/v2/', + ['registry.example=auth.example'], + )); + }); + }); + + describe('parseCrossOriginAuthHosts', () => { + it('normalizes authorities and preserves ports', () => { + const parsed = parseCrossOriginAuthHosts(['REGISTRY.EXAMPLE:8443=AUTH.EXAMPLE:9443']); + assert.deepEqual([...parsed.get('registry.example:8443')!], ['auth.example:9443']); + }); + + for (const entry of [ + 'auth.example', + '=auth.example', + 'registry.example=', + 'https://registry.example=auth.example', + 'registry.example=https://auth.example', + 'registry.example/path=auth.example', + ]) { + it(`rejects malformed mapping '${entry}'`, () => { + assert.throws(() => parseCrossOriginAuthHosts([entry])); + }); + } + }); + + describe('isOCIRegistryOrigin', () => { + const ociRef: OCICollectionRef = { + scheme: 'https', + registry: 'registry.example', + path: 'test/features', + resource: 'registry.example/test/features', + tag: 'latest', + version: 'latest', + }; + + it('accepts the reference registry origin', () => { + assert.isTrue(isOCIRegistryOrigin(new URL('https://registry.example/v2/'), ociRef)); + }); + + it('rejects a different origin', () => { + assert.isFalse(isOCIRegistryOrigin(new URL('https://uploads.example/v2/'), ociRef)); + }); + + it('rejects a protocol downgrade on the reference authority', () => { + assert.isFalse(isOCIRegistryOrigin(new URL('http://registry.example/v2/'), ociRef)); + }); + + it('accepts both origins of a Docker Hub distribution alias redirect', () => { + const dockerHubRef = { + ...ociRef, + registry: 'docker.io', + }; + assert.isTrue(isOCIRegistryOrigin(new URL('https://docker.io/v2/'), dockerHubRef)); + assert.isTrue(isOCIRegistryOrigin(new URL('https://registry-1.docker.io/v2/'), dockerHubRef)); + }); + }); + + it('does not forward registry credentials to a cross-origin upload URL', async () => { + const registry = 'registry.example'; + const originalAuthorization = 'Bearer original-registry-token'; + const uploadToken = 'upload-token'; + let uploadRequests = 0; + let tokenRequests = 0; + const uploadServer = http.createServer((request, response) => { + if (request.url?.startsWith('/token')) { + tokenRequests++; + assert.equal(request.method, 'GET'); + assert.isUndefined(request.headers.authorization); + response.end(JSON.stringify({ token: uploadToken })); + return; + } + + uploadRequests++; + if (request.headers.authorization === `Bearer ${uploadToken}`) { + response.writeHead(201); + response.end(); + return; + } + assert.isUndefined(request.headers.authorization); + const uploadPort = (uploadServer.address() as AddressInfo).port; + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${uploadPort}/token",service="uploads.example",scope="repository:test:push"`, + }); + response.end(); + }); + const uploadPort = await listen(uploadServer); + const cachedAuthHeader = { [registry]: originalAuthorization }; + const params = { + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|secret` }), + cachedAuthHeader, + ociAuthHardening: true, + }; + const ociRef: OCICollectionRef = { + scheme: 'https', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + try { + const result = await requestEnsureAuthenticated(params, { + type: 'PUT', + url: `http://localhost:${uploadPort}/upload`, + headers: { authorization: originalAuthorization }, + data: Buffer.from('blob'), + }, ociRef); + + assert.equal(result?.statusCode, 201); + assert.equal(uploadRequests, 2); + assert.equal(tokenRequests, 1); + assert.equal(cachedAuthHeader[registry], originalAuthorization); + assert.isTrue(params.ociAuthDiagnostics.registryRedirectWouldPreventCredentialForwarding); + } finally { + await close(uploadServer); + } + }); + + it('does not answer a cross-origin Basic challenge with registry credentials', async () => { + const registry = 'registry.example'; + let uploadRequests = 0; + const uploadServer = http.createServer((request, response) => { + uploadRequests++; + assert.isUndefined(request.headers.authorization); + response.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="uploads.example"', + }); + response.end(); + }); + const uploadPort = await listen(uploadServer); + const params = { + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|secret` }), + ociAuthHardening: true, + }; + const ociRef: OCICollectionRef = { + scheme: 'https', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + try { + const result = await requestEnsureAuthenticated(params, { + type: 'PUT', + url: `http://localhost:${uploadPort}/upload`, + headers: {}, + }, ociRef); + + assert.isUndefined(result); + assert.equal(uploadRequests, 1); + assert.isTrue(params.ociAuthDiagnostics.registryRedirectWouldPreventCredentialForwarding); + } finally { + await close(uploadServer); + } + }); + + it('does not request a rejected bearer token realm', async () => { + let registryRequests = 0; + let tokenRequests = 0; + const tokenServer = http.createServer((_request, response) => { + tokenRequests++; + response.end(JSON.stringify({ token: 'internal-secret' })); + }); + const tokenPort = await listen(tokenServer); + const registryServer = http.createServer((_request, response) => { + registryRequests++; + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${tokenPort}/token",service="attacker.example",scope="repository:test:pull"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + + try { + const registry = `127.0.0.1:${registryPort}`; + const ociRef: OCICollectionRef = { + scheme: 'http', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + const cachedAuthHeader: Record = {}; + const params = createTestCommonParams(nullLog, {}); + + const result = await requestEnsureAuthenticated({ ...params, cachedAuthHeader, ociAuthHardening: true }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.isUndefined(result); + assert.equal(registryRequests, 1); + assert.equal(tokenRequests, 0); + assert.notProperty(cachedAuthHeader, registry); + assert.isTrue(params.ociAuthDiagnostics.authLookupWouldBeBlocked); + } finally { + await Promise.all([close(registryServer), close(tokenServer)]); + } + }); + + it('surfaces shadow diagnostics when hardening is disabled', async () => { + const token = 'registry-token'; + const bearerScheme = 'Bearer'; + let redirectTargetRequests = 0; + const redirectTargetServer = http.createServer((_request, response) => { + redirectTargetRequests++; + response.end(JSON.stringify({ token })); + }); + const redirectTargetPort = await listen(redirectTargetServer); + + let tokenRequests = 0; + const tokenServer = http.createServer((_request, response) => { + tokenRequests++; + response.writeHead(307, { + location: `http://localhost:${redirectTargetPort}/token`, + }); + response.end(); + }); + const tokenPort = await listen(tokenServer); + + let challengeRegistryRequests = 0; + const challengeRegistryServer = http.createServer((_request, response) => { + challengeRegistryRequests++; + response.writeHead(401, { + 'WWW-Authenticate': `${bearerScheme} realm="http://localhost:${tokenPort}/token",service="attacker.example",scope="repository:test:pull"`, + }); + response.end(); + }); + const challengeRegistryPort = await listen(challengeRegistryServer); + + let registryRequests = 0; + const registryServer = http.createServer((request, response) => { + registryRequests++; + response.writeHead(307, { + location: `http://localhost:${challengeRegistryPort}${request.url}`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `127.0.0.1:${registryPort}`; + const logMessages: string[] = []; + const output = { + ...nullLog, + write: (text: string) => logMessages.push(text), + }; + + try { + const ociRef: OCICollectionRef = { + scheme: 'http', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated(createTestCommonParams(output, {}), { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 401); + assert.equal(registryRequests, 2); + assert.equal(challengeRegistryRequests, 2); + assert.equal(tokenRequests, 1); + assert.equal(redirectTargetRequests, 1); + assert.deepEqual(result?.ociAuthDiagnostics, { + authLookupWouldBeBlocked: true, + registryRedirectWouldPreventCredentialForwarding: true, + authServerRedirect: true, + }); + assert.lengthOf(logMessages.filter(message => message.includes('OCI auth diagnostics:')), 3); + } finally { + await Promise.all([close(registryServer), close(challengeRegistryServer), close(tokenServer), close(redirectTargetServer)]); + } + }); + + it('does not cache a token from a redirected authentication challenge under the original registry', async () => { + const token = 'redirect-target-token'; + let challengeServerRequests = 0; + let uploadRequests = 0; + const challengeServer = http.createServer((request, response) => { + challengeServerRequests++; + const challengePort = (challengeServer.address() as AddressInfo).port; + if (request.url?.startsWith('/token')) { + response.end(JSON.stringify({ token })); + return; + } + uploadRequests++; + if (uploadRequests === 2) { + response.writeHead(200); + response.end(); + return; + } + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${challengePort}/token",service="uploads.example",scope="repository:test:push"`, + }); + response.end(); + }); + const challengePort = await listen(challengeServer); + + let registryRequests = 0; + const registryServer = http.createServer((request, response) => { + registryRequests++; + response.writeHead(307, { + location: `http://localhost:${challengePort}${request.url}`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + const cachedAuthHeader: Record = {}; + const params = { + ...createTestCommonParams(nullLog, {}), + cachedAuthHeader, + ociAuthHardening: true, + }; + const ociRef: OCICollectionRef = { + scheme: 'http', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + try { + const result = await requestEnsureAuthenticated(params, { + type: 'PUT', + url: `http://${registry}/upload`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 200); + assert.equal(registryRequests, 2); + assert.equal(challengeServerRequests, 3); + assert.notProperty(cachedAuthHeader, registry); + assert.isTrue(params.ociAuthDiagnostics.registryRedirectWouldPreventCredentialForwarding); + } finally { + await Promise.all([close(registryServer), close(challengeServer)]); + } + }); + + it('ignores cross-origin redirects that do not produce an auth challenge', async () => { + const contentServer = http.createServer((_request, response) => { + response.writeHead(200); + response.end('blob'); + }); + const contentPort = await listen(contentServer); + const registryServer = http.createServer((_request, response) => { + response.writeHead(307, { + location: `http://localhost:${contentPort}/blob`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `127.0.0.1:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + scheme: 'http', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + const params = createTestCommonParams(nullLog, {}); + + const result = await requestEnsureAuthenticated(params, { + type: 'GET', + url: `http://${registry}/v2/test/features/blobs/sha256:test`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 200); + assert.isFalse(params.ociAuthDiagnostics.registryRedirectWouldPreventCredentialForwarding); + } finally { + await Promise.all([close(registryServer), close(contentServer)]); + } + }); + + it('forwards a refresh token to an explicitly configured auth host', async () => { + const token = 'registry-token'; + const refreshToken = 'registry-refresh-token'; + const bearerScheme = 'Bearer'; + let tokenRequests = 0; + const tokenServer = https.createServer(await getLocalhostCertificate(), async (request, response) => { + tokenRequests++; + try { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(chunk as Buffer); + } + const body = new URLSearchParams(Buffer.concat(chunks).toString()); + assert.equal(request.method, 'POST'); + assert.equal(body.get('refresh_token'), refreshToken); + response.end(JSON.stringify({ token })); + } catch (err) { + response.destroy(err as Error); + } + }); + const tokenPort = await listen(tokenServer); + + let registryRequests = 0; + const registryServer = http.createServer((request, response) => { + registryRequests++; + if (request.headers.authorization === `${bearerScheme} ${token}`) { + response.writeHead(200); + response.end(); + return; + } + response.writeHead(401, { + 'WWW-Authenticate': `${bearerScheme} realm="https://localhost:${tokenPort}/token",service="registry.example",scope="repository:test:pull"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + const dockerConfig = await mkdtemp(join(tmpdir(), 'devcontainers-oci-auth-')); + await writeFile(join(dockerConfig, 'config.json'), JSON.stringify({ + auths: { + [registry]: { + auth: '', + identitytoken: refreshToken, + }, + }, + })); + + try { + const { stdout } = await execFileAsync(process.execPath, [ + '-r', + 'ts-node/register', + join(__dirname, 'httpOCIRegistryRefreshTokenClient.ts'), + ], { + cwd: join(__dirname, '..', '..'), + encoding: 'utf8', + env: { + ...process.env, + DOCKER_CONFIG: dockerConfig, + NODE_EXTRA_CA_CERTS: certificatePath, + TEST_REGISTRY: registry, + TEST_TOKEN_PORT: `${tokenPort}`, + TS_NODE_PROJECT: join(__dirname, 'tsconfig.json'), + }, + }); + const result = JSON.parse(stdout); + + assert.equal(result.statusCode, 200); + assert.equal(registryRequests, 2); + assert.equal(tokenRequests, 1); + assert.deepEqual(result.ociAuthDiagnostics, { + authLookupWouldBeBlocked: false, + registryRedirectWouldPreventCredentialForwarding: false, + authServerRedirect: false, + }); + } finally { + await rm(dockerConfig, { recursive: true }); + await Promise.all([close(registryServer), close(tokenServer)]); + } + }); + + it('does not follow redirects from a bearer token realm', async () => { + let redirectTargetRequests = 0; + const redirectTargetServer = http.createServer((_request, response) => { + redirectTargetRequests++; + response.end(JSON.stringify({ token: 'internal-secret' })); + }); + const redirectTargetPort = await listen(redirectTargetServer); + + let registryRequests = 0; + const registryServer = http.createServer((request, response) => { + registryRequests++; + if (request.url?.startsWith('/token')) { + response.writeHead(302, { location: `http://localhost:${redirectTargetPort}/token` }); + response.end(); + return; + } + + const registryPort = (registryServer.address() as AddressInfo).port; + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${registryPort}/token",service="localhost:${registryPort}",scope="repository:test:pull"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + scheme: 'http', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated({ + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }), + ociAuthHardening: true, + }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.isUndefined(result); + assert.equal(registryRequests, 2); + assert.equal(redirectTargetRequests, 0); + } finally { + await Promise.all([close(registryServer), close(redirectTargetServer)]); + } + }); + + it('encodes bearer token service and scope query values', async () => { + const service = 'registry.example&injected=service#fragment'; + const scope = 'repository:test:pull&injected=scope#fragment'; + const token = 'registry-token'; + let registryRequests = 0; + let tokenRequests = 0; + const registryServer = http.createServer((request, response) => { + const registryPort = (registryServer.address() as AddressInfo).port; + if (request.url?.startsWith('/token')) { + tokenRequests++; + const tokenUrl = new URL(request.url, `http://localhost:${registryPort}`); + assert.equal(tokenUrl.searchParams.get('existing'), 'value'); + assert.equal(tokenUrl.searchParams.get('service'), service); + assert.equal(tokenUrl.searchParams.get('scope'), scope); + assert.isFalse(tokenUrl.searchParams.has('injected')); + response.end(JSON.stringify({ token })); + return; + } + + registryRequests++; + if (request.headers.authorization === `Bearer ${token}`) { + response.writeHead(200); + response.end(); + return; + } + + response.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="http://localhost:${registryPort}/token?existing=value#realm-fragment",service="${service}",scope="${scope}"`, + }); + response.end(); + }); + const registryPort = await listen(registryServer); + const registry = `localhost:${registryPort}`; + + try { + const ociRef: OCICollectionRef = { + scheme: 'http', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', + }; + + const result = await requestEnsureAuthenticated({ + ...createTestCommonParams(nullLog, { DEVCONTAINERS_OCI_AUTH: `${registry}|user|token` }), + }, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, + }, ociRef); + + assert.equal(result?.statusCode, 200); + assert.equal(registryRequests, 2); + assert.equal(tokenRequests, 1); + } finally { + await close(registryServer); + } + }); +}); + +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve((server.address() as AddressInfo).port); + }); + }); +} + +function close(server: http.Server): Promise { + return new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); +} \ No newline at end of file diff --git a/src/test/httpOCIRegistryRefreshTokenClient.ts b/src/test/httpOCIRegistryRefreshTokenClient.ts new file mode 100644 index 000000000..8beafaac1 --- /dev/null +++ b/src/test/httpOCIRegistryRefreshTokenClient.ts @@ -0,0 +1,41 @@ +import { OCICollectionRef } from '../spec-configuration/containerCollectionsOCI'; +import { requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; +import { nullLog } from '../spec-utils/log'; +import { createTestCommonParams } from './testUtils'; + +function requiredEnvironmentVariable(name: string) { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing ${name}.`); + } + return value; +} + +const registry = requiredEnvironmentVariable('TEST_REGISTRY'); +const tokenPort = requiredEnvironmentVariable('TEST_TOKEN_PORT'); +const ociRef: OCICollectionRef = { + scheme: 'http', + registry, + path: 'test/features', + resource: `${registry}/test/features`, + tag: 'latest', + version: 'latest', +}; + +requestEnsureAuthenticated({ + ...createTestCommonParams(nullLog, {}), + allowedCrossOriginAuthHosts: [`${registry}=localhost:${tokenPort}`], + ociAuthHardening: true, +}, { + type: 'GET', + url: `http://${registry}/v2/test/features/manifests/latest`, + headers: {}, +}, ociRef).then(result => { + process.stdout.write(JSON.stringify({ + statusCode: result?.statusCode, + ociAuthDiagnostics: result?.ociAuthDiagnostics, + })); +}, error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/test/imageMetadata.test.ts b/src/test/imageMetadata.test.ts index fb78df0f5..b45087d58 100644 --- a/src/test/imageMetadata.test.ts +++ b/src/test/imageMetadata.test.ts @@ -568,6 +568,7 @@ function getFeaturesConfig(features: Feature[]): FeaturesConfig { userFeatureId: `ghcr.io/my-org/my-repo/${feature.id}:1`, userFeatureIdWithoutVersion: `ghcr.io/my-org/my-repo/${feature.id}`, featureRef: { + scheme: 'https', registry: 'ghcr.io', owner: 'my-org', namespace: 'my-org/my-repo', diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 22597dce2..1eaaded55 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -6,10 +6,11 @@ import * as assert from 'assert'; import * as cp from 'child_process'; import { getCLIHost, loadNativeModule, plainExec, plainPtyExec, runCommand, runCommandNoPty } from '../spec-common/commonUtils'; import { SubstituteConfig } from '../spec-node/utils'; -import { LogLevel, createPlainLog, makeLog, nullLog } from '../spec-utils/log'; +import { Log, LogLevel, createPlainLog, makeLog, nullLog } from '../spec-utils/log'; import { dockerComposeCLIConfig } from '../spec-node/dockerCompose'; import { DockerCLIParameters } from '../spec-shutdown/dockerUtils'; -import { mapNodeArchitectureToGOARCH, mapNodeOSToGOOS } from '../spec-configuration/containerCollectionsOCI'; +import { CommonParams, mapNodeArchitectureToGOARCH, mapNodeOSToGOOS } from '../spec-configuration/containerCollectionsOCI'; +import { createOCIAuthDiagnostics } from '../spec-common/ociAuth'; export interface BuildKitOption { text: string; @@ -126,6 +127,36 @@ export async function pathExists(cli: string, workspaceFolder: string, location: return false; } } + +export function findFromArgsWithoutDefault(dockerfile: string): string[] { + const preambleArgsWithDefault = new Set(); + const offenders: string[] = []; + let beforeFirstFrom = true; + + for (const rawLine of dockerfile.split('\n')) { + const line = rawLine.trim(); + + const argWithDefault = /^ARG\s+([A-Za-z0-9_]+)\s*=\s*\S+/.exec(line); + if (argWithDefault) { + if (beforeFirstFrom) { + preambleArgsWithDefault.add(argWithDefault[1]); + } + continue; + } + + const fromMatch = /^FROM(?:\s+--platform=\S+)?\s+\$\{?([A-Za-z0-9_]+)/i.exec(line); + if (fromMatch && !preambleArgsWithDefault.has(fromMatch[1])) { + offenders.push(fromMatch[1]); + } + + if (/^FROM\b/i.test(line)) { + beforeFirstFrom = false; + } + } + + return offenders; +} + export async function commandMarkerTests(cli: string, workspaceFolder: string, expected: { postCreate: boolean; postStart: boolean; postAttach: boolean }, message: string) { const actual = { postCreate: await pathExists(cli, workspaceFolder, '/tmp/postCreateCommand.testmarker'), @@ -147,6 +178,14 @@ export const testSubstitute: SubstituteConfig = value => { export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); +export function createTestCommonParams(output: Log, env: NodeJS.ProcessEnv = process.env): CommonParams { + return { + output, + env, + ociAuthDiagnostics: createOCIAuthDiagnostics(), + }; +} + export async function createCLIParams(hostPath: string) { const cliHost = await getCLIHost(hostPath, loadNativeModule, true); const dockerComposeCLI = dockerComposeCLIConfig({ @@ -159,13 +198,12 @@ export async function createCLIParams(hostPath: string) { arch: mapNodeArchitectureToGOARCH(cliHost.arch), }; const cliParams: DockerCLIParameters = { + ...createTestCommonParams(output, {}), cliHost, dockerCLI: 'docker', dockerComposeCLI, - env: {}, - output, buildPlatformInfo, targetPlatformInfo: buildPlatformInfo, -}; + }; return cliParams; } diff --git a/yarn.lock b/yarn.lock index c70431cf8..903f50a91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9,135 +9,135 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@esbuild/aix-ppc64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" - integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== - -"@esbuild/android-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" - integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== - -"@esbuild/android-arm@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" - integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== - -"@esbuild/android-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" - integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== - -"@esbuild/darwin-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" - integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== - -"@esbuild/darwin-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" - integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== - -"@esbuild/freebsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" - integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== - -"@esbuild/freebsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" - integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== - -"@esbuild/linux-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" - integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== - -"@esbuild/linux-arm@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" - integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== - -"@esbuild/linux-ia32@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" - integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== - -"@esbuild/linux-loong64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" - integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== - -"@esbuild/linux-mips64el@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" - integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== - -"@esbuild/linux-ppc64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" - integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== - -"@esbuild/linux-riscv64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" - integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== - -"@esbuild/linux-s390x@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" - integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== - -"@esbuild/linux-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" - integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== - -"@esbuild/netbsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" - integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== - -"@esbuild/netbsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" - integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== - -"@esbuild/openbsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" - integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== - -"@esbuild/openbsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" - integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== - -"@esbuild/openharmony-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" - integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== - -"@esbuild/sunos-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" - integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== - -"@esbuild/win32-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" - integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== - -"@esbuild/win32-ia32@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" - integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== - -"@esbuild/win32-x64@0.27.3": - version "0.27.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" - integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== +"@esbuild/aix-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" + integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== + +"@esbuild/android-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" + integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== + +"@esbuild/android-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" + integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== + +"@esbuild/android-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" + integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== + +"@esbuild/darwin-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" + integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== + +"@esbuild/darwin-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" + integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== + +"@esbuild/freebsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" + integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== + +"@esbuild/freebsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" + integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== + +"@esbuild/linux-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" + integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== + +"@esbuild/linux-arm@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" + integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== + +"@esbuild/linux-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" + integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== + +"@esbuild/linux-loong64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" + integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== + +"@esbuild/linux-mips64el@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" + integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== + +"@esbuild/linux-ppc64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" + integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== + +"@esbuild/linux-riscv64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" + integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== + +"@esbuild/linux-s390x@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" + integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== + +"@esbuild/linux-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" + integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== + +"@esbuild/netbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" + integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== + +"@esbuild/netbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" + integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== + +"@esbuild/openbsd-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" + integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== + +"@esbuild/openbsd-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" + integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== + +"@esbuild/openharmony-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" + integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== + +"@esbuild/sunos-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" + integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== + +"@esbuild/win32-arm64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" + integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== + +"@esbuild/win32-ia32@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" + integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== + +"@esbuild/win32-x64@0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" + integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" @@ -653,24 +653,24 @@ bl@^5.0.0: readable-stream "^3.4.0" brace-expansion@^1.1.7: - version "1.1.13" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" - integrity "sha1-03h1wB3J7/mI3UnREqV8tntU7+Y= sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==" + version "1.1.18" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.18.tgz#3ce74d89885136be1535341f8c3d4425c29a5cab" + integrity sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.3.tgz#0493338bdd58e319b1039c67cf7ee439892c01d9" - integrity "sha1-BJMzi91Y4xmxA5xnz37kOYksAdk= sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==" + version "2.1.4" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.4.tgz#589dab11c0018d0366be64cd8bf12c8dbecc8326" + integrity sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg== dependencies: balanced-match "^1.0.0" brace-expansion@^5.0.2: - version "5.0.5" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" - integrity "sha1-3MOjcRa3nz4bRtuZTO1dVw6TD9s= sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==" + version "5.0.9" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" + integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== dependencies: balanced-match "^4.0.2" @@ -1117,37 +1117,37 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -esbuild@^0.27.3: - version "0.27.3" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" - integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== +esbuild@^0.28.1: + version "0.28.1" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" + integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== optionalDependencies: - "@esbuild/aix-ppc64" "0.27.3" - "@esbuild/android-arm" "0.27.3" - "@esbuild/android-arm64" "0.27.3" - "@esbuild/android-x64" "0.27.3" - "@esbuild/darwin-arm64" "0.27.3" - "@esbuild/darwin-x64" "0.27.3" - "@esbuild/freebsd-arm64" "0.27.3" - "@esbuild/freebsd-x64" "0.27.3" - "@esbuild/linux-arm" "0.27.3" - "@esbuild/linux-arm64" "0.27.3" - "@esbuild/linux-ia32" "0.27.3" - "@esbuild/linux-loong64" "0.27.3" - "@esbuild/linux-mips64el" "0.27.3" - "@esbuild/linux-ppc64" "0.27.3" - "@esbuild/linux-riscv64" "0.27.3" - "@esbuild/linux-s390x" "0.27.3" - "@esbuild/linux-x64" "0.27.3" - "@esbuild/netbsd-arm64" "0.27.3" - "@esbuild/netbsd-x64" "0.27.3" - "@esbuild/openbsd-arm64" "0.27.3" - "@esbuild/openbsd-x64" "0.27.3" - "@esbuild/openharmony-arm64" "0.27.3" - "@esbuild/sunos-x64" "0.27.3" - "@esbuild/win32-arm64" "0.27.3" - "@esbuild/win32-ia32" "0.27.3" - "@esbuild/win32-x64" "0.27.3" + "@esbuild/aix-ppc64" "0.28.1" + "@esbuild/android-arm" "0.28.1" + "@esbuild/android-arm64" "0.28.1" + "@esbuild/android-x64" "0.28.1" + "@esbuild/darwin-arm64" "0.28.1" + "@esbuild/darwin-x64" "0.28.1" + "@esbuild/freebsd-arm64" "0.28.1" + "@esbuild/freebsd-x64" "0.28.1" + "@esbuild/linux-arm" "0.28.1" + "@esbuild/linux-arm64" "0.28.1" + "@esbuild/linux-ia32" "0.28.1" + "@esbuild/linux-loong64" "0.28.1" + "@esbuild/linux-mips64el" "0.28.1" + "@esbuild/linux-ppc64" "0.28.1" + "@esbuild/linux-riscv64" "0.28.1" + "@esbuild/linux-s390x" "0.28.1" + "@esbuild/linux-x64" "0.28.1" + "@esbuild/netbsd-arm64" "0.28.1" + "@esbuild/netbsd-x64" "0.28.1" + "@esbuild/openbsd-arm64" "0.28.1" + "@esbuild/openbsd-x64" "0.28.1" + "@esbuild/openharmony-arm64" "0.28.1" + "@esbuild/sunos-x64" "0.28.1" + "@esbuild/win32-arm64" "0.28.1" + "@esbuild/win32-ia32" "0.28.1" + "@esbuild/win32-x64" "0.28.1" escalade@^3.1.1: version "3.2.0" @@ -1681,9 +1681,9 @@ internal-slot@^1.1.0: side-channel "^1.1.0" ip-address@^10.0.1: - version "10.2.0" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" - integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== + version "10.5.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.5.0.tgz#fcd6d7dfe9e68416b7cb71afe9bc960b3e38c13b" + integrity sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g== is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: version "3.0.5" @@ -1928,9 +1928,9 @@ jackspeak@^3.1.2: "@pkgjs/parseargs" "^0.11.0" js-yaml@^4.1.0, js-yaml@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" - integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + version "4.3.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848" + integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ== dependencies: argparse "^2.0.1" @@ -2688,9 +2688,9 @@ shebang-regex@^3.0.0: integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== shell-quote@^1.6.1, shell-quote@^1.8.3: - version "1.8.3" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#55e40ef33cf5c689902353a3d8cd1a6725f08b4b" - integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== + version "1.9.0" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.9.0.tgz#e108b1a136586d5964edb3300016d4bedba0fe57" + integrity sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA== side-channel-list@^1.0.0: version "1.0.0" @@ -2988,9 +2988,9 @@ supports-preserve-symlinks-flag@^1.0.0: integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== tar@^7.5.10: - version "7.5.11" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868" - integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== + version "7.5.21" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.21.tgz#b3405af2eb493523ce4379f531e9ebda0601bc59" + integrity sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0"