From 126ca1eca6983a301b4fefabff68a21af97f57ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Fri, 10 Apr 2026 15:20:07 +0100 Subject: [PATCH] fix(parse): always emit permissions: {} in workflow YAML output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no permissions block is present in HCL, the workflow now emits permissions: {} (deny all) rather than omitting the key. An empty permissions {} block already emitted {} after the previous fix, but the global ": {}\n" → ":\n" post-processor in marshalWorkflowYAML was silently erasing it. Fixed with a sentinel-swap that protects permissions: {} through the strip. All 14 affected golden fixtures updated. Explicit unit tests added (TestParsePermissionsDefault) covering: no block, empty block, and scoped permissions. --- ...placement-and-empty-permissions-default.md | 228 ++++++++++++++++++ provider/github/github_test.go | 71 ++++++ provider/github/parse_workflow.go | 4 + .../workflow_parse_expression.golden.yaml | 1 + .../workflow_parse_job_order.golden.yaml | 1 + .../workflow_parse_order.golden.yaml | 1 + .../job_strategy_include_exclude.golden.yaml | 1 + .../valid/reusable_job_minimal.golden.yaml | 1 + .../workflow_dispatch_inputs.golden.yaml | 1 + .../valid/workflow_run_expression.golden.yaml | 1 + .../expression_fields.roundtrip.golden.yaml | 1 + .../on_shorthand_string.roundtrip.golden.yaml | 1 + ...reusable_job_minimal.roundtrip.golden.yaml | 1 + ...flow_dispatch_inputs.roundtrip.golden.yaml | 1 + .../workflows/basic_workflow.golden.yaml | 1 + .../workflows/container_services.golden.yaml | 1 + .../workflows/reusable_job.golden.yaml | 1 + .../workflows/workflow_call.golden.yaml | 1 + provider/github/workflow_yaml.go | 13 +- 19 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 docs/solutions/logic-errors/github-pin-comment-placement-and-empty-permissions-default.md diff --git a/docs/solutions/logic-errors/github-pin-comment-placement-and-empty-permissions-default.md b/docs/solutions/logic-errors/github-pin-comment-placement-and-empty-permissions-default.md new file mode 100644 index 0000000..bf1c187 --- /dev/null +++ b/docs/solutions/logic-errors/github-pin-comment-placement-and-empty-permissions-default.md @@ -0,0 +1,228 @@ +--- +title: "github pin inline comment format and workflow permissions least-privilege default" +date: 2026-04-10 +category: logic-errors +tags: [cinzel, hcl, github-actions, yaml, permissions, security, pin, upgrade, sentinel-swap] +symptoms: + - "cinzel github pin adds comment above uses block instead of inline on version line" + - "comment style uses // instead of # on version line after pinning" + - "empty permissions {} HCL block emits permissions: read-all in YAML output" + - "no permissions block in HCL produces no permissions key in YAML output at all" + - "security scanner (zizmor/Scorecard) warns: overly broad permissions: uses read-all permissions" + - "permissions: {} stripped to permissions: (null) by global empty-map post-processor" +affected_components: + - "internal/pin/pin.go" + - "internal/pin/upgrade.go" + - "provider/github/parse_workflow.go" + - "provider/github/workflow_yaml.go" +problem_type: logic-errors +related: + - docs/solutions/patterns/assist-pin-upgrade-feature-implementation.md + - docs/solutions/logic-errors/preserve-hcl-job-order-in-yaml-output.md +--- + +# github pin inline comment format and empty permissions least-privilege default + +Two independent logic errors in the `github pin`/`upgrade` commands and the workflow permissions parser, both producing semantically wrong output from valid input. + +## Symptoms + +**Pin comment placement:** +After running `cinzel github pin`, the generated HCL had a `//` comment placed *above* the `uses {}` block instead of inline on the `version` line: + +```hcl +// actions/checkout v4 ← wrong: detached, wrong style +uses { + action = "actions/checkout" + version = "abc123sha..." +} +``` + +**Empty permissions escalation:** +A security scanner flagged generated workflow YAML files: + +``` +Warning: commitlint.yml:10: overly broad permissions: uses read-all permissions +Warning: security.yml:11: overly broad permissions: uses read-all permissions +Error: Process completed with exit code 13. +``` + +This occurred even when the source HCL had an empty `permissions {}` block — i.e. the author explicitly declared no permissions, yet the output granted broad read access. + +**Missing permissions key:** +Workflows with no `permissions` block at all produced YAML with no `permissions` key — causing GitHub Actions to inherit the repo's default token permissions (often `contents: write` or broader) rather than denying all access. + +## Root Cause + +### Pin comment placement + +`PinFile` and `UpgradeFile` called `upsertUsesComment()` to inject a freestanding `// action tag` comment above the `uses {}` block. Two problems: + +1. **Wrong position** — the comment was detached from the field it documented; editors and diff tools could separate it from the `version` line. +2. **Wrong style** — `//` is valid HCL but `#` is the conventional inline comment style, matching the GitHub Actions YAML convention (`uses: actions/checkout@sha # v4`). + +### Empty permissions → read-all + +In `parse_workflow.go`, the code treated an empty body map as a cue to apply the `read-all` shorthand: + +```go +if len(child) == 0 { + out["permissions"] = "read-all" // ← inverted semantics +} else { + out["permissions"] = child +} +``` + +An empty `permissions {}` block means *deny all* — the most restrictive state. The code did the opposite, expanding it to the broadest read shorthand. + +### No permissions block → missing key + +`parseWorkflowConfig` only set `out["permissions"]` inside the `cfg.PermBlocks` loop. With no block present, the key was never set, so the YAML had no `permissions` field at all — silently inheriting the repo's token permissions. + +### `permissions: {}` erased by global post-processor + +`marshalWorkflowYAML` in `workflow_yaml.go` stripped all empty maps as a formatting pass: + +```go +out := bytes.ReplaceAll(buf.Bytes(), []byte(": {}\n"), []byte(":\n")) +``` + +This turned `permissions: {}\n` into `permissions:\n` — which YAML decodes as `null`, not an empty map. A naive targeted fix (`ReplaceAll("permissions:\n", "permissions: {}\n")`) would break non-empty permissions blocks, since `permissions:\n contents: read` also starts with `permissions:\n`. Go's `regexp` package has no negative lookahead, so the distinction cannot be expressed as a regex. + +## Solution + +### Problem 1 — inline `#` comment on version line + +**`internal/pin/pin.go`** and **`internal/pin/upgrade.go`** (same change in both): + +```go +// Before +oldLine := fmt.Sprintf(`version = %q`, ref.Version) +newLine := fmt.Sprintf(`version = %q`, sha) +updated = strings.Replace(updated, oldLine, newLine, 1) +updated = upsertUsesComment(updated, ref.Action, ref.Version) // removed + +// After +oldLine := fmt.Sprintf(`version = %q`, ref.Version) +newLine := fmt.Sprintf(`version = %q # %s`, sha, ref.Version) +updated = strings.Replace(updated, oldLine, newLine, 1) +``` + +The `upsertUsesComment` function was deleted entirely. Output now: + +```hcl +uses { + action = "actions/checkout" + version = "abc123sha..." # v4 +} +``` + +### Problem 2 — empty permissions → `{}` + +**`provider/github/parse_workflow.go`** (two locations: workflow-level and job-level): + +```go +// Before +if len(child) == 0 { + out["permissions"] = "read-all" +} else { + out["permissions"] = child +} + +// After +out["permissions"] = child +``` + +Empty permissions now round-trips faithfully: + +```hcl +# HCL input +permissions {} +``` + +```yaml +# YAML output +permissions: {} # all scopes denied — correct least-privilege +``` + +### Problem 3 — no permissions block → always emit `permissions: {}` + +**`provider/github/parse_workflow.go`** — add a default after the PermBlocks loop in `parseWorkflowConfig`: + +```go +for _, block := range cfg.PermBlocks { + child, err := parseBodyMap(block.Body, hv, "permissions") + if err != nil { + return nil, err + } + out["permissions"] = child +} + +if _, ok := out["permissions"]; !ok { + out["permissions"] = map[string]any{} +} +``` + +### Problem 4 — sentinel-swap to survive the global empty-map strip + +**`provider/github/workflow_yaml.go`** — protect `permissions: {}` before the global replacement, restore after: + +```go +var permissionsEmptySentinel = []byte("\x00PERM_EMPTY\x00\n") + +// Inside marshalWorkflowYAML: +raw := buf.Bytes() +raw = bytes.ReplaceAll(raw, []byte("permissions: {}\n"), permissionsEmptySentinel) +raw = bytes.ReplaceAll(raw, []byte(": {}\n"), []byte(":\n")) +out := bytes.ReplaceAll(raw, permissionsEmptySentinel, []byte("permissions: {}\n")) +``` + +The sentinel (`\x00PERM_EMPTY\x00`) is a byte sequence that cannot appear in valid YAML, making false matches impossible. The three passes: protect → strip other empty maps → restore. + +## Prevention + +### Never treat empty as "grant everything" + +`read-all` and `write-all` are expansion shorthands that broaden access. Only emit them when the source data explicitly contains that string — never as a fallback for an empty or zero-value input. The invariant: if the user wrote fewer scopes, the output must have fewer or equal scopes, never more. + +### Global post-processors are fragile for security-sensitive fields + +String replacements on serialized YAML have no structural awareness — they cannot distinguish `permissions: {}` (an explicit deny-all) from `cache: {}` (a harmless empty map). Any post-processor that strips or rewrites output must explicitly protect fields with security semantics, or be redesigned to operate at the node level. + +When a global strip must be kept, use the **sentinel-swap pattern**: protect the target value with a byte sequence that cannot appear in valid YAML, run the strip, restore the sentinel. Prefer this over regex when Go's RE2 engine lacks the lookahead needed to express the distinction. + +### Round-trip tests catch escalation bugs + +A one-way golden test can pass even when permissions are silently escalated. A round-trip test (HCL → YAML → HCL) will fail if the output is semantically different from the input. Add explicit unit tests — not just golden file updates — for security-sensitive fields: + +``` +permissions {} → permissions: {} (not read-all, not omitted) +no permissions → permissions: {} (not omitted) +permissions { contents = "read" } → permissions:\n contents: read (scopes preserved) +``` + +Golden file bulk-updates during refactors can silently regress these invariants without a reviewer noticing. Dedicated unit tests like `TestParsePermissionsDefault` catch them immediately. + +### Test boundary conditions for every enum-like field + +- empty block +- single scope (`contents: read`) +- all scopes `read` +- all scopes `write` +- mixed scopes + +### GitHub Actions least-privilege best practices + +- Prefer explicit per-scope grants over shorthands (`contents: read` not `read-all`). +- `read-all` grants read on every scope including sensitive ones (id-token, secrets metadata). +- Security scanners (zizmor, Scorecard, StepSecurity) flag `read-all`/`write-all` as findings — generated workflows using these shorthands will fail CI security checks. +- The safest default for generated workflows is `permissions: {}` at the top level with explicit grants added per-job. + +### Inline `#` comment is more robust than a block comment + +The `# vtag` inline pattern is preferred over a freestanding comment above the block because: + +1. **Mirrors YAML convention** — tools like Dependabot and `pin-github-action` use `# vX.Y.Z` inline. +2. **Survives reformatting** — a comment on the same line cannot be accidentally detached during editing. +3. **Tooling-friendly** — upgrade tools locate the version by scanning the SHA line, no lookahead needed. +4. **Visually scannable** — SHA and tag are visible together without scrolling. diff --git a/provider/github/github_test.go b/provider/github/github_test.go index 3e7ede6..eeb1e61 100644 --- a/provider/github/github_test.go +++ b/provider/github/github_test.go @@ -1174,3 +1174,74 @@ workflow "ci" { } }) } + +func TestParsePermissionsDefault(t *testing.T) { + minimalWorkflow := func(permissionsBlock string) string { + return `step "test" { run = "echo hi" } + +job "build" { + runs_on { runners = "ubuntu-latest" } + steps = [step.test] +} + +workflow "ci" { + filename = "ci" + on "push" {} + jobs = [job.build] + ` + permissionsBlock + ` +}` + } + + parseAndReadYAML := func(t *testing.T, hcl string) string { + t.Helper() + + tmpDir := t.TempDir() + inputFile := filepath.Join(tmpDir, "workflow.hcl") + outputDir := filepath.Join(tmpDir, "out") + + if err := os.WriteFile(inputFile, []byte(hcl), 0o644); err != nil { + t.Fatal(err) + } + + if err := New().Parse(provider.ProviderOps{File: inputFile, OutputDirectory: outputDir}); err != nil { + t.Fatal(err) + } + + out, err := os.ReadFile(filepath.Join(outputDir, "ci.yaml")) + if err != nil { + t.Fatal(err) + } + + return string(out) + } + + t.Run("no permissions block emits permissions: {}", func(t *testing.T) { + yaml := parseAndReadYAML(t, minimalWorkflow("")) + + if !strings.Contains(yaml, "permissions: {}") { + t.Errorf("expected permissions: {} in output\ngot:\n%s", yaml) + } + }) + + t.Run("empty permissions block emits permissions: {}", func(t *testing.T) { + yaml := parseAndReadYAML(t, minimalWorkflow("permissions {}")) + + if !strings.Contains(yaml, "permissions: {}") { + t.Errorf("expected permissions: {} in output\ngot:\n%s", yaml) + } + }) + + t.Run("explicit scopes are preserved and not replaced with empty", func(t *testing.T) { + yaml := parseAndReadYAML(t, minimalWorkflow(`permissions { + contents = "read" + }`)) + + if strings.Contains(yaml, "permissions: {}") { + t.Errorf("permissions: {} should not appear when scopes are set\ngot:\n%s", yaml) + } + + if !strings.Contains(yaml, "contents: read") { + t.Errorf("expected contents: read in output\ngot:\n%s", yaml) + } + }) +} diff --git a/provider/github/parse_workflow.go b/provider/github/parse_workflow.go index 8b391fa..6923f97 100644 --- a/provider/github/parse_workflow.go +++ b/provider/github/parse_workflow.go @@ -374,6 +374,10 @@ func parseWorkflowConfig(cfg hclWorkflowBlock, hv *hclparser.HCLVars) (map[strin out["permissions"] = child } + if _, ok := out["permissions"]; !ok { + out["permissions"] = map[string]any{} + } + for _, block := range cfg.Defaults { child, err := parseBodyMap(block.Body, hv, "defaults") if err != nil { diff --git a/provider/github/testdata/fixtures/formatting/workflow_parse_expression.golden.yaml b/provider/github/testdata/fixtures/formatting/workflow_parse_expression.golden.yaml index d0344e6..eb24ac7 100644 --- a/provider/github/testdata/fixtures/formatting/workflow_parse_expression.golden.yaml +++ b/provider/github/testdata/fixtures/formatting/workflow_parse_expression.golden.yaml @@ -3,6 +3,7 @@ run-name: "${{ github.workflow }} #${{ github.run_number }}" on: push: +permissions: {} jobs: build: runs-on: ubuntu-latest diff --git a/provider/github/testdata/fixtures/formatting/workflow_parse_job_order.golden.yaml b/provider/github/testdata/fixtures/formatting/workflow_parse_job_order.golden.yaml index 6b4f4ac..17ceae3 100644 --- a/provider/github/testdata/fixtures/formatting/workflow_parse_job_order.golden.yaml +++ b/provider/github/testdata/fixtures/formatting/workflow_parse_job_order.golden.yaml @@ -3,6 +3,7 @@ name: CI on: push: +permissions: {} jobs: test: runs-on: ubuntu-latest diff --git a/provider/github/testdata/fixtures/formatting/workflow_parse_order.golden.yaml b/provider/github/testdata/fixtures/formatting/workflow_parse_order.golden.yaml index b007da6..6f17324 100644 --- a/provider/github/testdata/fixtures/formatting/workflow_parse_order.golden.yaml +++ b/provider/github/testdata/fixtures/formatting/workflow_parse_order.golden.yaml @@ -4,6 +4,7 @@ name: Build run-name: "Build #1" on: push: +permissions: {} jobs: build: runs-on: ubuntu-latest diff --git a/provider/github/testdata/fixtures/matrix/parse/valid/job_strategy_include_exclude.golden.yaml b/provider/github/testdata/fixtures/matrix/parse/valid/job_strategy_include_exclude.golden.yaml index 26eacb8..2d11f33 100644 --- a/provider/github/testdata/fixtures/matrix/parse/valid/job_strategy_include_exclude.golden.yaml +++ b/provider/github/testdata/fixtures/matrix/parse/valid/job_strategy_include_exclude.golden.yaml @@ -1,5 +1,6 @@ on: push: +permissions: {} jobs: build: runs-on: ubuntu-latest diff --git a/provider/github/testdata/fixtures/matrix/parse/valid/reusable_job_minimal.golden.yaml b/provider/github/testdata/fixtures/matrix/parse/valid/reusable_job_minimal.golden.yaml index ddea993..1a223a2 100644 --- a/provider/github/testdata/fixtures/matrix/parse/valid/reusable_job_minimal.golden.yaml +++ b/provider/github/testdata/fixtures/matrix/parse/valid/reusable_job_minimal.golden.yaml @@ -1,5 +1,6 @@ on: push: +permissions: {} jobs: release: secrets: inherit diff --git a/provider/github/testdata/fixtures/matrix/parse/valid/workflow_dispatch_inputs.golden.yaml b/provider/github/testdata/fixtures/matrix/parse/valid/workflow_dispatch_inputs.golden.yaml index 81b4d71..55b3f30 100644 --- a/provider/github/testdata/fixtures/matrix/parse/valid/workflow_dispatch_inputs.golden.yaml +++ b/provider/github/testdata/fixtures/matrix/parse/valid/workflow_dispatch_inputs.golden.yaml @@ -4,6 +4,7 @@ on: target: required: true type: string +permissions: {} jobs: build: runs-on: ubuntu-latest diff --git a/provider/github/testdata/fixtures/matrix/parse/valid/workflow_run_expression.golden.yaml b/provider/github/testdata/fixtures/matrix/parse/valid/workflow_run_expression.golden.yaml index 83a7319..f6ed856 100644 --- a/provider/github/testdata/fixtures/matrix/parse/valid/workflow_run_expression.golden.yaml +++ b/provider/github/testdata/fixtures/matrix/parse/valid/workflow_run_expression.golden.yaml @@ -5,6 +5,7 @@ on: - completed workflows: - Build +permissions: {} jobs: build: if: ${{ github.ref == 'refs/heads/main' }} diff --git a/provider/github/testdata/fixtures/matrix/unparse/valid/expression_fields.roundtrip.golden.yaml b/provider/github/testdata/fixtures/matrix/unparse/valid/expression_fields.roundtrip.golden.yaml index 52e7adf..99d924d 100644 --- a/provider/github/testdata/fixtures/matrix/unparse/valid/expression_fields.roundtrip.golden.yaml +++ b/provider/github/testdata/fixtures/matrix/unparse/valid/expression_fields.roundtrip.golden.yaml @@ -1,6 +1,7 @@ run-name: "${{ github.workflow }} #${{ github.run_number }}" on: push: +permissions: {} jobs: release: if: ${{ github.ref == 'refs/heads/main' }} diff --git a/provider/github/testdata/fixtures/matrix/unparse/valid/on_shorthand_string.roundtrip.golden.yaml b/provider/github/testdata/fixtures/matrix/unparse/valid/on_shorthand_string.roundtrip.golden.yaml index f590afa..c500fe2 100644 --- a/provider/github/testdata/fixtures/matrix/unparse/valid/on_shorthand_string.roundtrip.golden.yaml +++ b/provider/github/testdata/fixtures/matrix/unparse/valid/on_shorthand_string.roundtrip.golden.yaml @@ -1,5 +1,6 @@ on: push: +permissions: {} jobs: build: runs-on: ubuntu-latest diff --git a/provider/github/testdata/fixtures/matrix/unparse/valid/reusable_job_minimal.roundtrip.golden.yaml b/provider/github/testdata/fixtures/matrix/unparse/valid/reusable_job_minimal.roundtrip.golden.yaml index dbb83d5..e80d41d 100644 --- a/provider/github/testdata/fixtures/matrix/unparse/valid/reusable_job_minimal.roundtrip.golden.yaml +++ b/provider/github/testdata/fixtures/matrix/unparse/valid/reusable_job_minimal.roundtrip.golden.yaml @@ -1,5 +1,6 @@ on: push: +permissions: {} jobs: call_release: secrets: inherit diff --git a/provider/github/testdata/fixtures/matrix/unparse/valid/workflow_dispatch_inputs.roundtrip.golden.yaml b/provider/github/testdata/fixtures/matrix/unparse/valid/workflow_dispatch_inputs.roundtrip.golden.yaml index 81b4d71..55b3f30 100644 --- a/provider/github/testdata/fixtures/matrix/unparse/valid/workflow_dispatch_inputs.roundtrip.golden.yaml +++ b/provider/github/testdata/fixtures/matrix/unparse/valid/workflow_dispatch_inputs.roundtrip.golden.yaml @@ -4,6 +4,7 @@ on: target: required: true type: string +permissions: {} jobs: build: runs-on: ubuntu-latest diff --git a/provider/github/testdata/fixtures/workflows/basic_workflow.golden.yaml b/provider/github/testdata/fixtures/workflows/basic_workflow.golden.yaml index 6c66a53..9e64d56 100644 --- a/provider/github/testdata/fixtures/workflows/basic_workflow.golden.yaml +++ b/provider/github/testdata/fixtures/workflows/basic_workflow.golden.yaml @@ -1,3 +1,4 @@ +permissions: {} jobs: build: runs-on: ubuntu-latest diff --git a/provider/github/testdata/fixtures/workflows/container_services.golden.yaml b/provider/github/testdata/fixtures/workflows/container_services.golden.yaml index d97e535..1c4c04e 100644 --- a/provider/github/testdata/fixtures/workflows/container_services.golden.yaml +++ b/provider/github/testdata/fixtures/workflows/container_services.golden.yaml @@ -1,5 +1,6 @@ on: push: +permissions: {} jobs: integration: container: diff --git a/provider/github/testdata/fixtures/workflows/reusable_job.golden.yaml b/provider/github/testdata/fixtures/workflows/reusable_job.golden.yaml index b8af383..9920e7d 100644 --- a/provider/github/testdata/fixtures/workflows/reusable_job.golden.yaml +++ b/provider/github/testdata/fixtures/workflows/reusable_job.golden.yaml @@ -1,3 +1,4 @@ +permissions: {} jobs: release: secrets: inherit diff --git a/provider/github/testdata/fixtures/workflows/workflow_call.golden.yaml b/provider/github/testdata/fixtures/workflows/workflow_call.golden.yaml index b45d158..24451e7 100644 --- a/provider/github/testdata/fixtures/workflows/workflow_call.golden.yaml +++ b/provider/github/testdata/fixtures/workflows/workflow_call.golden.yaml @@ -1,3 +1,4 @@ +permissions: {} jobs: build: runs-on: ubuntu-latest diff --git a/provider/github/workflow_yaml.go b/provider/github/workflow_yaml.go index aaad789..9f32eb8 100644 --- a/provider/github/workflow_yaml.go +++ b/provider/github/workflow_yaml.go @@ -46,7 +46,14 @@ func marshalWorkflowYAML(workflow map[string]any) ([]byte, error) { return nil, err } - out := bytes.ReplaceAll(buf.Bytes(), []byte(": {}\n"), []byte(":\n")) + // Strip empty maps (e.g. on: {} → on:) but preserve permissions: {} so it + // stays explicit — an absent or null permissions field causes GitHub Actions + // to inherit the default token permissions rather than denying all access. + // Sentinel-swap: protect permissions: {} before the global strip, restore after. + raw := buf.Bytes() + raw = bytes.ReplaceAll(raw, []byte("permissions: {}\n"), permissionsEmptySentinel) + raw = bytes.ReplaceAll(raw, []byte(": {}\n"), []byte(":\n")) + out := bytes.ReplaceAll(raw, permissionsEmptySentinel, []byte("permissions: {}\n")) return unescapeYAMLUnicode(out), nil } @@ -72,6 +79,10 @@ func unescapeYAMLUnicode(src []byte) []byte { var reYAMLUnicodeEscape = regexp.MustCompile(`\\U[0-9A-Fa-f]{8}|\\u[0-9A-Fa-f]{4}`) +// permissionsEmptySentinel is a placeholder used to protect "permissions: {}" +// from the global empty-map strip in marshalWorkflowYAML. +var permissionsEmptySentinel = []byte("\x00PERM_EMPTY\x00\n") + func workflowMapNode(workflow map[string]any) (*yamlv3.Node, error) { node := &yamlv3.Node{Kind: yamlv3.MappingNode}