Skip to content

Commit 32e77eb

Browse files
aledbfclaude
andcommitted
feat(oci): support OCI auth hardening from reference v0.89.0
Bump the pinned reference CLI to v0.89.0 and implement the observable surface it added (PR devcontainers/cli#1278): - `--oci-auth-hardening` and `--allow-cross-origin-auth-host` as global flags, with the same validation the oracle applies: the allow list requires hardening, and each entry is a '<registry-host>=<auth-host>' pair of bare authorities. - `ociAuthDiagnostics` in the `up`, `build` and `read-configuration` output, reporting what hardening would change. - Bearer realms pinned to the registry authority or a trusted auth host (including the built-in Docker Hub and GitLab mappings), and token endpoints refused a redirect, when hardening is on. - `scheme` on the feature ref in `read-configuration` output, and the generated feature Dockerfiles defaulting the base-image ARG to `scratch` rather than `placeholder`. The policy is built once per invocation and carried on the command context, so every OCI client of a command shares its settings and feeds the same diagnostics. `exec` parses its own flags and therefore applies the validation itself; `features test` re-invokes this binary and forwards the flags to the `up` it spawns. The hardening is enforced in a transport above oras-go, which already refuses to forward credentials to a cross-origin challenge. That makes hardening-off stricter here than upstream; the divergence is documented and the diagnostics still report what hardening would change. TestOracleFlagCoverage only inspected per-command options, so the two new global flags went unnoticed; it now checks the oracle's global options as well, and TestFlagInventoryParity pins them to the root command. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e0f21b5 commit 32e77eb

31 files changed

Lines changed: 1295 additions & 54 deletions

‎.github/workflows/go-cli.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ jobs:
109109
- uses: actions/upload-artifact@v7
110110
if: always()
111111
with:
112-
name: parity-contract-network-v0.88.0
112+
name: parity-contract-network-v0.89.0
113113
path: artifacts/
114114
if-no-files-found: error
115115
- uses: actions/upload-artifact@v7
@@ -222,7 +222,7 @@ jobs:
222222
- uses: actions/upload-artifact@v7
223223
if: always() && steps.plan.outputs.run == 'true'
224224
with:
225-
name: parity-runtime-v0.88.0-shard-${{ matrix.shard }}
225+
name: parity-runtime-v0.89.0-shard-${{ matrix.shard }}
226226
path: artifacts/
227227
if-no-files-found: error
228228
# daily only: covdata slice for the cross-lane merge (distinct name per shard).

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ GitHub Releases are not supported targets. “Compatible” means compatibility
121121
this scope; it does not mean that every platform or historical upstream behavior is
122122
implemented.
123123

124-
A pinned official TypeScript CLI (`reference/`, currently v0.88.0) is the behavioral
124+
A pinned official TypeScript CLI (`reference/`, currently v0.89.0) is the behavioral
125125
oracle. Roughly 200 cases run commands through both CLIs and compare exit status,
126126
normalized output, and relevant container or registry state. See the
127127
[parity matrix](docs/parity/parity-matrix.yaml) and

‎docs/DIVERGENCES.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Divergences, decisions & accepted limitations
22

33
This CLI is validated for behavioral parity with the reference TypeScript
4-
`@devcontainers/cli` (pinned at **v0.88.0**, see [`parity/`](parity/)). Where it
4+
`@devcontainers/cli` (pinned at **v0.89.0**, see [`parity/`](parity/)). Where it
55
deliberately differs, the difference is recorded here — this is the durable record of
66
*intentional* departures from the oracle, not a backlog. User-facing additions are
77
documented in [`go-only-features.md`](go-only-features.md).
@@ -32,6 +32,16 @@ touches a compared surface, reflected in the parity matrix.
3232
- **`config.build.cacheFrom`** is honored (wired to `--cache-from` after the flag's
3333
values) — matching `singleContainer.ts`. Upstream defines the field; this is a parity
3434
fix, noted here because it was previously a dead field.
35+
- **OCI auth hardening is enforced through `oras-go`, which is stricter by default.**
36+
`--oci-auth-hardening` and `--allow-cross-origin-auth-host` behave as documented
37+
upstream (bearer realms pinned to the registry authority or a trusted auth host,
38+
token endpoints may not redirect), and `ociAuthDiagnostics` reports the same three
39+
flags in `up`/`build`/`read-configuration`. The difference is what happens
40+
**without** the flag: the reference CLI still forwards registry credentials to a
41+
challenge that arrives from another origin, while `oras-go` never does
42+
(GHSA-vh4v-2xq2-g5cg). Hardening off is therefore already safe here; the
43+
diagnostics still report what hardening *would* change, so the flag remains a
44+
faithful compatibility probe.
3545
- **`BUILDKIT_INLINE_CACHE=1`** is omitted when `--cache-to` is an inline exporter
3646
(`/type\s*=\s*inline/i`), matching TS `isBuildxCacheToInline` — a parity fix over the
3747
earlier unconditional build-arg.

‎docs/parity/cli-flags-inventory.yaml‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ parser_configuration:
2121
strict: true
2222
demand_command: true
2323

24+
# ─────────────────────────────────────────────────────────────────────────────
25+
# Global flags (yargs `.option(..., { global: true })`: accepted by every command)
26+
# ─────────────────────────────────────────────────────────────────────────────
27+
global_flags:
28+
oci-auth-hardening:
29+
type: boolean
30+
default: false
31+
description: "Restrict OCI bearer authentication realms, registry credential forwarding, and token redirects."
32+
allow-cross-origin-auth-host:
33+
type: string
34+
array: true
35+
description: "Allow an OCI registry to use a cross-origin HTTPS authentication host. Format: <registry-host>=<auth-host>. May be repeated."
36+
notes: "Requires --oci-auth-hardening; each entry must be '<registry-host>=<auth-host>' of bare authorities."
37+
2438
# ─────────────────────────────────────────────────────────────────────────────
2539
# Global validations (shared by multiple commands)
2640
# ─────────────────────────────────────────────────────────────────────────────
@@ -1140,17 +1154,17 @@ commands:
11401154
# =============================================================================
11411155
json_output_envelopes:
11421156
success_with_container: # up
1143-
fields: [outcome, containerId, remoteUser, remoteWorkspaceFolder, composeProjectName, configuration, mergedConfiguration]
1157+
fields: [outcome, containerId, remoteUser, remoteWorkspaceFolder, composeProjectName, configuration, mergedConfiguration, ociAuthDiagnostics]
11441158
success_setup: # set-up
11451159
fields: [outcome, configuration, mergedConfiguration]
11461160
success_build: # build
1147-
fields: [outcome, imageName]
1161+
fields: [outcome, imageName, ociAuthDiagnostics]
11481162
success_run_user_commands: # run-user-commands
11491163
fields: [outcome, result]
11501164
error: # all commands with outcome envelope
11511165
fields: [outcome, message, description, containerId, disallowedFeatureId, didStopContainer, learnMoreUrl]
11521166
read_configuration: # read-configuration (NO outcome envelope)
1153-
fields: [configuration, workspace, featuresConfiguration, mergedConfiguration]
1167+
fields: [configuration, workspace, featuresConfiguration, mergedConfiguration, ociAuthDiagnostics]
11541168

11551169
# =============================================================================
11561170
# ENVIRONMENT VARIABLES READ

‎docs/parity/parity-matrix.yaml‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ meta:
99
scope:
1010
languages: [ts, go]
1111
commands: [features-info, read-configuration, exec, run-user-commands, set-up, up, build]
12+
global_flags: [oci-auth-hardening, allow-cross-origin-auth-host]
1213
lanes:
1314
contract:
1415
description: "Parsing, required args, enums, error formats, output shape"
@@ -491,6 +492,61 @@ initial_cases:
491492
class: format-validation
492493
current_status: match
493494

495+
- id: read-configuration.oci-auth-hardening-accepted
496+
lane: contract
497+
command: read-configuration
498+
priority: p0
499+
docker_required: false
500+
ts_cmd: "--oci-auth-hardening read-configuration --workspace-folder src/test/configs/image"
501+
asserts: [exit_code, stdout_normalized]
502+
class: global-flags
503+
current_status: match
504+
notes: "0.89 global flag: accepted before the command name and the output carries the ociAuthDiagnostics envelope field (all false when no registry is contacted)."
505+
506+
- id: read-configuration.cross-origin-auth-host-requires-hardening
507+
lane: contract
508+
command: read-configuration
509+
priority: p0
510+
docker_required: false
511+
ts_cmd: "--allow-cross-origin-auth-host registry.example=auth.example read-configuration --workspace-folder src/test/configs/image"
512+
asserts: [exit_code, stderr_normalized]
513+
class: global-flags
514+
current_status: match
515+
notes: "0.89 yargs .check(): --allow-cross-origin-auth-host requires --oci-auth-hardening."
516+
517+
- id: read-configuration.cross-origin-auth-host-invalid-pair
518+
lane: contract
519+
command: read-configuration
520+
priority: p1
521+
docker_required: false
522+
ts_cmd: "--oci-auth-hardening --allow-cross-origin-auth-host bad read-configuration --workspace-folder src/test/configs/image"
523+
asserts: [exit_code, stderr_normalized]
524+
class: global-flags
525+
current_status: match
526+
notes: "0.89: each entry must be '<registry-host>=<auth-host>'."
527+
528+
- id: read-configuration.cross-origin-auth-host-invalid-authority
529+
lane: contract
530+
command: read-configuration
531+
priority: p1
532+
docker_required: false
533+
ts_cmd: "--oci-auth-hardening --allow-cross-origin-auth-host a/b=c read-configuration --workspace-folder src/test/configs/image"
534+
asserts: [exit_code, stderr_normalized]
535+
class: global-flags
536+
current_status: match
537+
notes: "0.89: both sides of the mapping must be bare authorities."
538+
539+
- id: read-configuration.global-options-consume-one-argument
540+
lane: contract
541+
command: read-configuration
542+
priority: p1
543+
docker_required: false
544+
ts_cmd: "--oci-auth-hardening --allow-cross-origin-auth-host registry.example=auth.example read-configuration --workspace-folder src/test/configs/image"
545+
asserts: [exit_code, stdout_normalized]
546+
class: global-flags
547+
current_status: match
548+
notes: "Mirrors the oracle's own 'Global options consume exactly one argument' test (src/test/cli.test.ts), asserted on read-configuration because yargs and cobra render --help differently: the repeatable flag must consume exactly one value and leave the subcommand intact."
549+
494550
- id: read-configuration.terminal-columns-implies-rows
495551
lane: contract
496552
command: read-configuration

‎internal/cli/build.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error {
163163
Format: opts.logFormat,
164164
Writer: os.Stderr,
165165
})
166+
// Route the OCI auth diagnostic lines at this command's logger.
167+
ociAuthPolicy(ctx).SetLogger(logger)
166168

167169
// Load config
168170
loadResult, err := config.LoadDevContainerConfig(workspaceFolder, configPath, "")
@@ -243,8 +245,9 @@ func runBuild(ctx context.Context, out Output, opts *buildOpts) error {
243245
}
244246

245247
return writeSuccessJSON(out, map[string]interface{}{
246-
"outcome": "success",
247-
"imageName": imageNameResult,
248+
"outcome": "success",
249+
"imageName": imageNameResult,
250+
"ociAuthDiagnostics": ociAuthDiagnostics(ctx),
248251
})
249252
}
250253

‎internal/cli/collection_commands.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ func realFeaturesTestCmd() *cobra.Command {
238238
preserve,
239239
quiet,
240240
permitRandomization,
241+
ociAuthGlobalArgs(cmd),
241242
)
242243
if exitCode != 0 {
243244
return &coreerrors.ExitCodeError{Code: exitCode}
@@ -438,7 +439,7 @@ func publishCollection(ctx context.Context, targetFolder, registry, namespace, c
438439
Format: "text",
439440
Writer: os.Stderr,
440441
})
441-
reg := oci.NewClient(logger, osEnvMap())
442+
reg := newOCIClient(ctx, logger)
442443
return publishCollectionWith(ctx, OSOutput(), reg, targetFolder, registry, namespace, collectionType, logLevelStr)
443444
}
444445

‎internal/cli/exec.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ func newExecCmd() *cobra.Command {
6262
if err := cmd.ParseFlags(flagArgs); err != nil {
6363
return err
6464
}
65+
// DisableFlagParsing skips the root's PersistentPreRunE validation, so
66+
// the global OCI auth flags are validated here, once parsed.
67+
if err := applyOCIAuthPolicy(cmd); err != nil {
68+
return err
69+
}
6570

6671
opts.workspaceFolder, _ = cmd.Flags().GetString("workspace-folder")
6772
opts.configPath, _ = cmd.Flags().GetString("config")
@@ -433,6 +438,7 @@ func splitExecArgs(args []string) (flags []string, cmd []string) {
433438
"--default-user-env-probe": true, "--user-data-folder": true,
434439
"--terminal-columns": true, "--terminal-rows": true,
435440
"--log-file": true, "--terminal-log-file": true,
441+
"--" + flagAllowCrossOriginAuthHos: true,
436442
}
437443

438444
i := 0

‎internal/cli/feature_install.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ type fetchFeatureResult struct {
7979

8080
// fetchFeatureSets fetches features and returns them in install order. reg is the
8181
// registry seam; pass nil for the default OCI client.
82-
func fetchFeatureSets(logger log.Logger, reg oci.Registry, featuresCfg map[string]interface{}, featuresBasePath string, skipAutoMapping bool, lockfile *features.Lockfile) (*fetchFeatureResult, error) {
83-
return fetchFeatureSetsWithOrder(logger, reg, featuresCfg, featuresBasePath, skipAutoMapping, lockfile, nil)
82+
func fetchFeatureSets(ctx context.Context, logger log.Logger, reg oci.Registry, featuresCfg map[string]interface{}, featuresBasePath string, skipAutoMapping bool, lockfile *features.Lockfile) (*fetchFeatureResult, error) {
83+
return fetchFeatureSetsWithOrder(ctx, logger, reg, featuresCfg, featuresBasePath, skipAutoMapping, lockfile, nil)
8484
}
8585

8686
// fetchFeatureSetsWithOrder resolves the feature dependency graph through the
@@ -89,7 +89,7 @@ func fetchFeatureSets(logger log.Logger, reg oci.Registry, featuresCfg map[strin
8989
// order. Each returned Set's content is staged under the returned TmpDir
9090
// at _dev_container_feature_<installOrderIndex>, matching the generated
9191
// Dockerfile's COPY paths.
92-
func fetchFeatureSetsWithOrder(logger log.Logger, reg oci.Registry, featuresCfg map[string]interface{}, featuresBasePath string, skipAutoMapping bool, lockfile *features.Lockfile, overrideOrder []string) (*fetchFeatureResult, error) {
92+
func fetchFeatureSetsWithOrder(ctx context.Context, logger log.Logger, reg oci.Registry, featuresCfg map[string]interface{}, featuresBasePath string, skipAutoMapping bool, lockfile *features.Lockfile, overrideOrder []string) (*fetchFeatureResult, error) {
9393
if len(featuresCfg) == 0 {
9494
return nil, nil
9595
}
@@ -104,7 +104,7 @@ func fetchFeatureSetsWithOrder(logger log.Logger, reg oci.Registry, featuresCfg
104104

105105
ociClient := reg
106106
if ociClient == nil {
107-
ociClient = oci.NewClient(logger, osEnvMap())
107+
ociClient = newOCIClient(ctx, logger)
108108
}
109109

110110
tmpDir, err := os.MkdirTemp("", "devcontainer-features-")
@@ -396,6 +396,7 @@ func processInstallFeature(
396396
"owner": strings.SplitN(ref.Namespace, "/", 2)[0],
397397
"path": ref.Namespace + "/" + ref.ID,
398398
"registry": ref.Registry,
399+
"scheme": ref.Scheme(),
399400
"resource": ref.Resource,
400401
"tag": ref.Tag, "version": ref.Tag,
401402
},
@@ -529,7 +530,7 @@ func extendImageWithFeatures(
529530
if fbOpts != nil {
530531
overrideOrder = fbOpts.OverrideFeatureInstallOrder
531532
}
532-
result, err := fetchFeatureSetsWithOrder(logger, nil, featuresCfg, featuresBasePath, skipAutoMap, lockfile, overrideOrder)
533+
result, err := fetchFeatureSetsWithOrder(ctx, logger, nil, featuresCfg, featuresBasePath, skipAutoMap, lockfile, overrideOrder)
533534
if err != nil {
534535
return nil, err
535536
}

‎internal/cli/feature_install_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ func TestFetchFeatureSets(t *testing.T) {
134134
t.Run(tt.name, func(t *testing.T) {
135135
baseDir := t.TempDir()
136136
tt.setup(t, baseDir)
137-
result, err := fetchFeatureSets(log.Null, nil, tt.entries, baseDir, false, nil)
137+
result, err := fetchFeatureSets(t.Context(), log.Null, nil, tt.entries, baseDir, false, nil)
138138
if result != nil && result.TmpDir != "" {
139139
defer os.RemoveAll(result.TmpDir)
140140
}

0 commit comments

Comments
 (0)