From 628bf31cfe3d0cd3b84144d47977cdddb2145b47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:47:28 +0000 Subject: [PATCH 1/6] Initial plan From 7ccfadd5d82316804b90c7b325e5dc1fef92ea0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:23:23 +0000 Subject: [PATCH 2/6] Support OTLP references to workflow metadata Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .changeset/otlp-metadata-attributes.md | 5 + .../src/content/docs/reference/frontmatter.md | 3 + docs/src/content/docs/reference/imports.md | 2 + .../content/docs/reference/open-telemetry.mdx | 4 + pkg/parser/import_observability.go | 43 ++++-- pkg/parser/import_observability_test.go | 21 +++ .../compiler_orchestrator_workflow.go | 3 + .../compiler_orchestrator_workflow_test.go | 43 ++++++ pkg/workflow/observability_metadata.go | 89 +++++++++++ pkg/workflow/observability_metadata_test.go | 141 ++++++++++++++++++ 10 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 .changeset/otlp-metadata-attributes.md create mode 100644 pkg/workflow/observability_metadata.go create mode 100644 pkg/workflow/observability_metadata_test.go diff --git a/.changeset/otlp-metadata-attributes.md b/.changeset/otlp-metadata-attributes.md new file mode 100644 index 00000000000..5f73a8adb4e --- /dev/null +++ b/.changeset/otlp-metadata-attributes.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Resolve root workflow metadata references in OTLP custom span and resource attributes, including shared imported configurations. diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index 2f28bb8c773..097e7be4162 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -72,6 +72,9 @@ metadata: Keys must be 1–64 characters; values are string-only, up to 1024 characters. `metadata.docs`, when present, must be an absolute HTTPS URL. The compiler preserves it in generated lock-file metadata without fetching it or changing workflow execution. +To explicitly reuse a metadata value in OTLP custom span or resource attributes, use +a whole-value `${{ github.aw.metadata. }}` reference; it resolves against this +root workflow's metadata at compile time. Metadata is not exported automatically. ### Trigger Events (`on:`) diff --git a/docs/src/content/docs/reference/imports.md b/docs/src/content/docs/reference/imports.md index 193958fa069..4dc3c4da4a8 100644 --- a/docs/src/content/docs/reference/imports.md +++ b/docs/src/content/docs/reference/imports.md @@ -106,6 +106,8 @@ imports: `shared/reporting-otlp.md` combines `shared/reporting.md` and `shared/otlp.md` for telemetry-enabled reporting workflows. +Shared OTLP configuration can use whole-value `${{ github.aw.metadata. }}` references in `observability.otlp.attributes` and `observability.otlp.resource-attributes`. These resolve at compile time against each importing root workflow's `metadata` map, so callers do not need to pass the values through import inputs. Only simple, existing metadata keys can be referenced, and only explicitly selected values are exported. + ## Import Schema (`import-schema`) Use `import-schema` to declare a typed parameter contract. Callers pass values via `with`; the compiler validates them and substitutes them into the shared file's frontmatter and body before processing. diff --git a/docs/src/content/docs/reference/open-telemetry.mdx b/docs/src/content/docs/reference/open-telemetry.mdx index dd57d4413a3..ae84752d871 100644 --- a/docs/src/content/docs/reference/open-telemetry.mdx +++ b/docs/src/content/docs/reference/open-telemetry.mdx @@ -117,10 +117,13 @@ observability: deployment.environment: production langfuse.session.id: "{{ gh-aw.episode.id }}" langfuse.user.id: "{{ github.actor }}" + catalogue.version: "${{ github.aw.metadata.version }}" ``` Values are plain strings. Template expressions such as `{{ gh-aw.episode.id }}` or `{{ github.actor }}` are resolved from attributes already computed for the span. Empty values are omitted. Values of at least 4 characters are masked in runner logs; shorter values are not masked. +Whole-value references in the form `${{ github.aw.metadata. }}` resolve at compile time from the root workflow's top-level `metadata` map. They work in either custom attribute map, including in shared imported OTLP configuration, and support simple metadata keys such as `version`, `category`, and `owner`. The reference must occupy the entire attribute value; embedded references, nested selectors, missing metadata keys, and metadata values containing GitHub Actions expression delimiters are compile-time errors. Metadata remains string-only, and only explicitly referenced values are exported. + ## Custom resource attributes `observability.otlp.resource-attributes` appends additional resource attributes to the standard gh-aw and GitHub resource attribute set: @@ -132,6 +135,7 @@ observability: resource-attributes: service.namespace: platform-automation deployment.environment: ${{ github.ref_name }} + catalogue.category: "${{ github.aw.metadata.category }}" ``` Use static strings or GitHub Actions expressions. Do **not** use `secrets.*` or `vars.*` values in `resource-attributes` because resource attributes are exported to external observability backends and are not treated as secret values. diff --git a/pkg/parser/import_observability.go b/pkg/parser/import_observability.go index 6bdc048eb79..b39164f50ba 100644 --- a/pkg/parser/import_observability.go +++ b/pkg/parser/import_observability.go @@ -46,7 +46,8 @@ func extractOTLPEndpointsFromObsMap(obs map[string]any) []observabilityImportEnd result = append(result, entry) } case map[string]any: - if url, _ := ep["url"].(string); url != "" { + url, ok := ep["url"].(string) + if ok && url != "" { entry := observabilityImportEndpoint{URL: url} if h, hasH := ep["headers"]; hasH { entry.Headers = h @@ -59,8 +60,8 @@ func extractOTLPEndpointsFromObsMap(obs map[string]any) []observabilityImportEnd if !ok { continue } - url, _ := itemMap["url"].(string) - if url == "" { + url, ok := itemMap["url"].(string) + if !ok || url == "" { continue } entry := observabilityImportEndpoint{URL: url} @@ -76,13 +77,15 @@ func extractOTLPEndpointsFromObsMap(obs map[string]any) []observabilityImportEnd // mergeObservabilityConfigs takes a slice of observability config JSON strings (one per // import), extracts all OTLP endpoint entries from each (supporting string, object, and // array forms), deduplicates by URL (first occurrence wins), and returns a single merged -// observability JSON string with all endpoints expressed as an array. Custom OTLP -// attributes are also merged across imports (first occurrence wins per key). +// observability JSON string with all endpoints expressed as an array. Custom OTLP +// span and resource attributes are also merged across imports (first occurrence wins +// per key). // Returns "" when no valid endpoints or attributes are found. func mergeObservabilityConfigs(configs []string) string { seen := make(map[string]struct{}) var allEndpoints []observabilityImportEndpoint mergedAttrs := make(map[string]string) + mergedResourceAttrs := make(map[string]string) var mergedGitHubApp map[string]any for i, cfgJSON := range configs { @@ -100,17 +103,14 @@ func mergeObservabilityConfigs(configs []string) string { allEndpoints = append(allEndpoints, e) } } - for k, v := range extractOTLPAttributesFromObsMap(obs) { - if _, exists := mergedAttrs[k]; !exists { - mergedAttrs[k] = v - } - } + mergeOTLPStringMapFirstWins(mergedAttrs, extractOTLPAttributesFromObsMap(obs)) + mergeOTLPStringMapFirstWins(mergedResourceAttrs, extractOTLPResourceAttributesFromObsMap(obs)) if mergedGitHubApp == nil { mergedGitHubApp = extractOTLPGitHubAppFromObsMap(obs) } } - if len(allEndpoints) == 0 && len(mergedAttrs) == 0 && mergedGitHubApp == nil { + if len(allEndpoints) == 0 && len(mergedAttrs) == 0 && len(mergedResourceAttrs) == 0 && mergedGitHubApp == nil { return "" } @@ -124,6 +124,9 @@ func mergeObservabilityConfigs(configs []string) string { if len(mergedAttrs) > 0 { otlpMap["attributes"] = mergedAttrs } + if len(mergedResourceAttrs) > 0 { + otlpMap["resource-attributes"] = mergedResourceAttrs + } if mergedGitHubApp != nil { otlpMap["github-app"] = mergedGitHubApp } @@ -136,6 +139,14 @@ func mergeObservabilityConfigs(configs []string) string { return string(b) } +func mergeOTLPStringMapFirstWins(destination, source map[string]string) { + for key, value := range source { + if _, exists := destination[key]; !exists { + destination[key] = value + } + } +} + func extractOTLPGitHubAppFromObsMap(obs map[string]any) map[string]any { if obs == nil { return nil @@ -171,6 +182,14 @@ func extractOTLPGitHubAppFromObsMap(obs map[string]any) map[string]any { // import the workflow package (circular-dependency risk), so the helper lives // here as a local copy. Both implementations must stay in sync. func extractOTLPAttributesFromObsMap(obs map[string]any) map[string]string { + return extractOTLPStringMapFromObsMap(obs, "attributes") +} + +func extractOTLPResourceAttributesFromObsMap(obs map[string]any) map[string]string { + return extractOTLPStringMapFromObsMap(obs, "resource-attributes") +} + +func extractOTLPStringMapFromObsMap(obs map[string]any, fieldName string) map[string]string { if obs == nil { return nil } @@ -182,7 +201,7 @@ func extractOTLPAttributesFromObsMap(obs map[string]any) map[string]string { if !ok { return nil } - attrsAny, ok := otlpMap["attributes"] + attrsAny, ok := otlpMap[fieldName] if !ok { return nil } diff --git a/pkg/parser/import_observability_test.go b/pkg/parser/import_observability_test.go index d674520d549..1d854c10c44 100644 --- a/pkg/parser/import_observability_test.go +++ b/pkg/parser/import_observability_test.go @@ -1,9 +1,11 @@ package parser import ( + "encoding/json" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestExtractOTLPAttributesFromObsMap verifies extractOTLPAttributesFromObsMap @@ -102,3 +104,22 @@ func TestExtractOTLPAttributesFromObsMap(t *testing.T) { }) } } + +func TestMergeObservabilityConfigsIncludesResourceAttributes(t *testing.T) { + configs := []string{ + `{"otlp":{"resource-attributes":{"shared.key":"first","first.only":"one"}}}`, + `{"otlp":{"resource-attributes":{"shared.key":"second","second.only":"two"}}}`, + } + + merged := mergeObservabilityConfigs(configs) + require.NotEmpty(t, merged) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(merged), &result)) + otlp := result["otlp"].(map[string]any) + assert.Equal(t, map[string]any{ + "shared.key": "first", + "first.only": "one", + "second.only": "two", + }, otlp["resource-attributes"]) +} diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 527006787f9..02b11b2e499 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -322,6 +322,9 @@ func (c *Compiler) mergeImportedWorkflowConfiguration(ctx *workflowBuildContext) ctx.workflowData.ConcurrencyJobDiscriminator = ctx.engineSetup.importsResult.MergedJobDiscriminator } c.mergeImportedObservability(ctx.workflowData, ctx.engineSetup.importsResult.MergedObservability) + if err := resolveOTLPMetadataReferences(ctx.workflowData); err != nil { + return formatCompilerError(ctx.cleanPath, "error", err.Error(), err) + } if err := c.mergeWorkflowEnv(ctx.frontmatter.Frontmatter, ctx.workflowData, ctx.engineSetup.importsResult); err != nil { return formatCompilerError(ctx.cleanPath, "error", err.Error(), err) } diff --git a/pkg/workflow/compiler_orchestrator_workflow_test.go b/pkg/workflow/compiler_orchestrator_workflow_test.go index c10a20ad4ab..40eda291629 100644 --- a/pkg/workflow/compiler_orchestrator_workflow_test.go +++ b/pkg/workflow/compiler_orchestrator_workflow_test.go @@ -409,6 +409,49 @@ func TestMergeImportedObservability_MergesResourceAttributesWithMainPrecedence(t }, otlp["resource-attributes"]) } +func TestMergeImportedObservabilityResolvesRootMetadataReferences(t *testing.T) { + importedObsJSON, err := json.Marshal(map[string]any{ + "metadata": map[string]any{"version": "import-version"}, + "otlp": map[string]any{ + "attributes": map[string]any{ + "catalogue.version": "${{ github.aw.metadata.version }}", + }, + "resource-attributes": map[string]any{ + "catalogue.category": "${{ github.aw.metadata.category }}", + }, + }, + }) + require.NoError(t, err) + + workflowData := &WorkflowData{ + RawFrontmatter: map[string]any{ + "metadata": map[string]any{ + "version": "root-version", + "category": "automation", + }, + }, + } + + ctx := &workflowBuildContext{ + cleanPath: "workflow.md", + frontmatter: &parser.FrontmatterResult{Frontmatter: workflowData.RawFrontmatter}, + engineSetup: &engineSetupResult{ + importsResult: &parser.ImportsResult{MergedObservability: string(importedObsJSON)}, + }, + workflowData: workflowData, + } + require.NoError(t, NewCompiler().mergeImportedWorkflowConfiguration(ctx)) + + obs := workflowData.RawFrontmatter["observability"].(map[string]any) + otlp := obs["otlp"].(map[string]any) + assert.Equal(t, map[string]any{"catalogue.version": "root-version"}, otlp["attributes"]) + assert.Equal(t, map[string]any{"catalogue.category": "automation"}, otlp["resource-attributes"]) + assert.Equal(t, map[string]string{"catalogue.version": "root-version"}, collectOTLPCustomAttributes(workflowData.RawFrontmatter)) + assert.Equal(t, map[string]string{"catalogue.category": "automation"}, collectOTLPResourceAttributes(workflowData.RawFrontmatter)) + assert.Contains(t, workflowData.Env, `"catalogue.version":"root-version"`) + assert.Contains(t, workflowData.Env, "catalogue.category=automation") +} + func TestBuildMergedEnvSources_MainWorkflowWins(t *testing.T) { mergedEnv := map[string]any{ "MAIN_ONLY": "1", diff --git a/pkg/workflow/observability_metadata.go b/pkg/workflow/observability_metadata.go new file mode 100644 index 00000000000..066875ef083 --- /dev/null +++ b/pkg/workflow/observability_metadata.go @@ -0,0 +1,89 @@ +package workflow + +import ( + "fmt" + "regexp" + "strings" +) + +const otlpMetadataNamespace = "github.aw.metadata" + +var otlpMetadataReferencePattern = regexp.MustCompile(`^\s*\$\{\{\s*github\.aw\.metadata\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}\s*$`) + +// resolveOTLPMetadataReferences replaces whole-value references in custom OTLP +// attribute maps with strings from the root workflow's metadata. +func resolveOTLPMetadataReferences(workflowData *WorkflowData) error { + if workflowData == nil { + return nil + } + obs := extractRawObservabilityMap(workflowData.RawFrontmatter) + if obs == nil { + return nil + } + otlp, ok := obs["otlp"].(map[string]any) + if !ok { + return nil + } + + var metadata map[string]any + if workflowData.RawFrontmatter != nil { + if metadataValue, exists := workflowData.RawFrontmatter["metadata"]; exists { + if metadataMap, isMap := metadataValue.(map[string]any); isMap { + metadata = metadataMap + } + } + } + + for _, field := range []string{"attributes", "resource-attributes"} { + attributes := normalizeOTLPAttributeMap(otlp, field) + if attributes == nil { + continue + } + for name, value := range attributes { + text, ok := value.(string) + if !ok || !strings.Contains(text, otlpMetadataNamespace) { + continue + } + scope := "observability.otlp." + field + "." + name + if !otlpMetadataReferencePattern.MatchString(text) { + return fmt.Errorf("%s must be a whole-value reference in the form ${{ github.aw.metadata. }}", scope) + } + key := strings.TrimSpace(text) + key = strings.TrimSpace(strings.TrimPrefix(key, "${{")) + key = strings.TrimSpace(strings.TrimSuffix(key, "}}")) + key = strings.TrimSpace(strings.TrimPrefix(key, otlpMetadataNamespace+".")) + if metadata == nil { + return fmt.Errorf("%s references metadata key %q, but the root workflow has no metadata map", scope, key) + } + metadataValue, exists := metadata[key] + if !exists { + return fmt.Errorf("%s references missing metadata key %q", scope, key) + } + resolved, ok := metadataValue.(string) + if !ok { + return fmt.Errorf("%s references metadata key %q, which must be a string", scope, key) + } + if strings.Contains(resolved, "${{") || strings.Contains(resolved, "}}") { + return fmt.Errorf("%s cannot resolve metadata key %q because its value contains GitHub Actions expression delimiters", scope, key) + } + attributes[name] = resolved + } + } + return nil +} + +func normalizeOTLPAttributeMap(otlp map[string]any, field string) map[string]any { + switch attributes := otlp[field].(type) { + case map[string]any: + return attributes + case map[string]string: + normalized := make(map[string]any, len(attributes)) + for name, value := range attributes { + normalized[name] = value + } + otlp[field] = normalized + return normalized + default: + return nil + } +} diff --git a/pkg/workflow/observability_metadata_test.go b/pkg/workflow/observability_metadata_test.go new file mode 100644 index 00000000000..760359b2193 --- /dev/null +++ b/pkg/workflow/observability_metadata_test.go @@ -0,0 +1,141 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveOTLPMetadataReferences(t *testing.T) { + tests := []struct { + name string + metadata any + attributes map[string]any + resource map[string]any + want map[string]any + wantResource map[string]any + wantErr string + }{ + { + name: "resolves both attribute maps and preserves unrelated expressions", + metadata: map[string]any{"version": "0.1.8", "category": "automation"}, + attributes: map[string]any{ + "catalogue.version": "${{ github.aw.metadata.version }}", + "catalogue.category": "${{ github.aw.metadata.category }}", + "actor": "${{ github.actor }}", + }, + resource: map[string]any{ + "catalogue.category": "${{ github.aw.metadata.category }}", + }, + want: map[string]any{ + "catalogue.version": "0.1.8", + "catalogue.category": "automation", + "actor": "${{ github.actor }}", + }, + wantResource: map[string]any{"catalogue.category": "automation"}, + }, + { + name: "allows an empty metadata value", + metadata: map[string]any{"owner": ""}, + attributes: map[string]any{ + "catalogue.owner": "${{ github.aw.metadata.owner }}", + }, + want: map[string]any{"catalogue.owner": ""}, + }, + { + name: "requires a root metadata map", + attributes: map[string]any{ + "catalogue.version": "${{ github.aw.metadata.version }}", + }, + wantErr: `observability.otlp.attributes.catalogue.version references metadata key "version", but the root workflow has no metadata map`, + }, + { + name: "requires referenced keys to exist", + metadata: map[string]any{"category": "automation"}, + attributes: map[string]any{ + "catalogue.version": "${{ github.aw.metadata.version }}", + }, + wantErr: `observability.otlp.attributes.catalogue.version references missing metadata key "version"`, + }, + { + name: "requires string metadata values", + metadata: map[string]any{"version": 1}, + attributes: map[string]any{ + "catalogue.version": "${{ github.aw.metadata.version }}", + }, + wantErr: `observability.otlp.attributes.catalogue.version references metadata key "version", which must be a string`, + }, + { + name: "rejects embedded references", + metadata: map[string]any{"version": "1.0"}, + attributes: map[string]any{ + "catalogue.version": "release-${{ github.aw.metadata.version }}", + }, + wantErr: "observability.otlp.attributes.catalogue.version must be a whole-value reference", + }, + { + name: "rejects nested selectors", + metadata: map[string]any{"catalogue": map[string]any{"version": "1.0"}}, + attributes: map[string]any{ + "catalogue.version": "${{ github.aw.metadata.catalogue.version }}", + }, + wantErr: "observability.otlp.attributes.catalogue.version must be a whole-value reference", + }, + { + name: "rejects expression-looking metadata", + metadata: map[string]any{"version": "${{ github.actor }}"}, + attributes: map[string]any{ + "catalogue.version": "${{ github.aw.metadata.version }}", + }, + wantErr: "contains GitHub Actions expression delimiters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + frontmatter := map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{"attributes": tt.attributes}, + }, + } + otlp := frontmatter["observability"].(map[string]any)["otlp"].(map[string]any) + if tt.resource != nil { + otlp["resource-attributes"] = tt.resource + } + if tt.metadata != nil { + frontmatter["metadata"] = tt.metadata + } + workflowData := &WorkflowData{RawFrontmatter: frontmatter} + + err := resolveOTLPMetadataReferences(workflowData) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, otlp["attributes"]) + if tt.wantResource != nil { + assert.Equal(t, tt.wantResource, otlp["resource-attributes"]) + } + }) + } +} + +func TestResolveOTLPMetadataReferencesRejectsUnsafeResourceAttribute(t *testing.T) { + workflowData := &WorkflowData{RawFrontmatter: map[string]any{ + "metadata": map[string]any{"category": "${{ secrets.TOKEN }}"}, + "observability": map[string]any{ + "otlp": map[string]any{ + "resource-attributes": map[string]any{ + "catalogue.category": "${{ github.aw.metadata.category }}", + }, + }, + }, + }} + + err := resolveOTLPMetadataReferences(workflowData) + + require.ErrorContains(t, err, "observability.otlp.resource-attributes.catalogue.category") + require.ErrorContains(t, err, "contains GitHub Actions expression delimiters") +} From 36bcf2ac40d72fafa957e129ca5047af78a82a87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:49:51 +0000 Subject: [PATCH 3/6] docs(adr): add draft ADR-70860 for OTLP metadata references --- ...-metadata-references-in-otlp-attributes.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/adr/70860-resolve-root-metadata-references-in-otlp-attributes.md diff --git a/docs/adr/70860-resolve-root-metadata-references-in-otlp-attributes.md b/docs/adr/70860-resolve-root-metadata-references-in-otlp-attributes.md new file mode 100644 index 00000000000..bb034a7a606 --- /dev/null +++ b/docs/adr/70860-resolve-root-metadata-references-in-otlp-attributes.md @@ -0,0 +1,49 @@ +# ADR-70860: Resolve Root Workflow Metadata References in OTLP Attributes at Compile Time + +**Date**: 2026-10-22 +**Status**: Draft +**Deciders**: pelikhan, Copilot coding agent + +--- + +### Context + +Workflows can declare a top-level `metadata` map, such as `version`, `category`, and `owner`. They can also declare custom OTLP span attributes (`observability.otlp.attributes`) and resource attributes (`observability.otlp.resource-attributes`). These OTLP maps are often defined once in a shared import, for example `shared/otlp.md`, and reused by many root workflows. Before this change, there was no way for those shared OTLP maps to pick up the importing workflow's metadata (issue #70377). Authors had to copy values by hand or pass them through import inputs. Separately, the parser's import merge (`mergeObservabilityConfigs`) kept span `attributes` but silently dropped imported `resource-attributes`. Any solution has to keep metadata from being exported unless the author opts in, and it must not open a path for GitHub Actions expression injection. + +### Decision + +We will support whole-value `${{ github.aw.metadata. }}` references in both OTLP custom attribute maps. The compiler resolves them in `resolveOTLPMetadataReferences`, which runs right after imported observability configuration is merged. Values always come from the **root** workflow's top-level `metadata` map, never from the import's own metadata. Resolution is strict. The reference must be the entire attribute value and the key must be a simple identifier. Compilation fails if the key is missing, if the root workflow has no metadata, if the metadata value is not a string, or if it contains `${{` or `}}`. We will also merge imported `resource-attributes` across imports using the same first-occurrence-wins rule already used for span attributes, with the root workflow still taking precedence. + +### Alternatives Considered + +#### Alternative 1: Pass metadata values through import inputs + +Shared OTLP imports could declare inputs that the root workflow fills with its metadata values. This approach uses existing machinery, which is why it was considered. It was rejected because every root workflow would have to repeat its metadata in an `imports` input block. That duplication is exactly the problem issue #70377 describes. + +#### Alternative 2: Automatically export all metadata as OTLP attributes + +The compiler could copy every `metadata` entry into the OTLP attribute maps without any references. This needs no new syntax. It was rejected because it would export data without anyone asking for it, and metadata may contain values that should not leave the workflow. The documentation added in this PR states that "Metadata is not exported automatically" and that "only explicitly selected values are exported". + +#### Alternative 3: Resolve references at runtime as GitHub Actions expressions + +The references could be passed through for GitHub Actions or the runtime to expand. This was rejected because `github.aw.metadata` is not a real Actions context. Resolving at runtime would also put metadata text into an expression-evaluation path. Resolving at compile time, and rejecting values that contain expression delimiters, keeps the compiled lock file static and closes off expression injection. + +### Consequences + +#### Positive +- Shared OTLP configuration can label telemetry with each importing workflow's own metadata, without duplicating values or passing them through import inputs. +- Strict validation (whole-value only, simple keys, string values, no `${{`/`}}` in values) turns mistakes into compile errors and prevents expression injection through metadata. +- Imported `resource-attributes` are no longer dropped silently. Span and resource attributes now share one merge helper (`mergeOTLPStringMapFirstWins`). + +#### Negative +- The `${{ ... }}` syntax looks like a GitHub Actions expression but is a compiler-only pseudo-context. Authors may wrongly expect other Actions contexts or embedded interpolation to work there. +- Embedded references (for example `"v${{ github.aw.metadata.version }}"`), nested selectors, and non-string metadata are not supported. These limits may need to be relaxed later. +- The parser's attribute extraction is still a local copy of the workflow package's logic, kept separate to avoid a circular import. This PR widens that duplicated code to cover `resource-attributes`, so there is more to keep in sync. + +#### Neutral +- Resolution always uses the root workflow's scope, even when the reference appears in an imported file. A `metadata` map inside the import itself is ignored for this purpose. +- Resolution runs inside `mergeImportedWorkflowConfiguration`, so it depends on running after imported observability is merged and before environment variables are built from the OTLP maps. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 672980ec837a4a7f92c6dfdec1a7c15c040149a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:23:34 +0000 Subject: [PATCH 4/6] Add OTLP secret leak lint rule Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .changeset/otlp-secret-attribute-lint.md | 5 + .../docs/reference/workflow-linters.md | 3 + pkg/workflow/lint/lint.go | 1 + pkg/workflow/lint/lint_test.go | 2 +- pkg/workflow/lint/otlp_secret_leak.go | 127 ++++++++++++++++++ pkg/workflow/lint/otlp_secret_leak_test.go | 116 ++++++++++++++++ .../lint/performance_benchmark_test.go | 4 +- pkg/workflow/lint/rule_contract_test.go | 63 ++++++--- pkg/workflow/lint/secrets.go | 4 + pkg/workflow/taint/taint.go | 1 + pkg/workflow/taint/taint_test.go | 20 +++ pkg/workflow/taint/workflow.go | 6 + specs/compiler-threat-detection-changelog.md | 1 + specs/compiler-threat-detection-spec.md | 12 +- 14 files changed, 343 insertions(+), 22 deletions(-) create mode 100644 .changeset/otlp-secret-attribute-lint.md create mode 100644 pkg/workflow/lint/otlp_secret_leak.go create mode 100644 pkg/workflow/lint/otlp_secret_leak_test.go diff --git a/.changeset/otlp-secret-attribute-lint.md b/.changeset/otlp-secret-attribute-lint.md new file mode 100644 index 00000000000..55854c5dd30 --- /dev/null +++ b/.changeset/otlp-secret-attribute-lint.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add an Actions/AW linter for secret-tainted OTLP span and resource attributes. diff --git a/docs/src/content/docs/reference/workflow-linters.md b/docs/src/content/docs/reference/workflow-linters.md index 4c7facf0c8e..715001f266f 100644 --- a/docs/src/content/docs/reference/workflow-linters.md +++ b/docs/src/content/docs/reference/workflow-linters.md @@ -17,6 +17,7 @@ The compiler threat IDs reference the [Compiler Threat Detection Specification]( | `template-injection` | `CTR-006` | AW, Actions | Known attacker-controlled expressions are not interpolated into executable `run` or `actions/github-script` code. Use environment bindings and quoted shell expansions or `process.env` instead. | | `workflow-run-scope` | `CTR-021` | AW, Actions | `workflow_run` selects workflows and has a positive branch scope, not just wildcard, negated-only, or empty branch filters. | | `secret-leak` | `CTR-017` | AW, Actions | Symbolic secrets do not reach modeled disclosure sinks. AW-only checks report missing generated execution coverage. | +| `otlp-secret-leak` | `CTR-017` | AW, Actions | Secret-tainted expressions are not exported through OTLP span/resource attributes. Actions AST analysis follows modeled environment bindings; AW-only analysis checks direct references. Does not detect hard-coded credentials or arbitrary runtime values. | | `permission-scope` | `CTR-001` | AW, Actions | Explicit permissions avoid blanket `write-all` grants; OIDC does not use invalid `id-token: read`. Scoped writes and `id-token: write` are not automatically violations. | | `version-check` | `CTR-018` | AW | Authored source does not disable `check-for-updates`. | | `conditional-import` | `CTR-020` | AW | Configuration import entries do not contain `if`, even if false or null. Runtime prompt conditionals are unaffected. | @@ -65,6 +66,8 @@ AW checks inspect explicit execution blocks, including `safe-outputs.steps`, cus This is not complete zizmor parity. Creator-verification findings such as [#50015](https://github.com/github/gh-aw/pull/50015) require external provenance data; static ASTs do not establish creator trust. Existing specialized trigger and permission rules remain separate, and zizmor ignore comments are not AST-rule suppressions. +`otlp-secret-leak` reports direct secret references in authored `observability.otlp.attributes` and `resource-attributes`, and follows modeled environment flows to Actions `GH_AW_OTLP_ATTRIBUTES` and `OTEL_RESOURCE_ATTRIBUTES` bindings. Diagnostics identify only the configuration path, never the value. The check is advisory; it does not inspect literal credentials, arbitrary runtime-generated values, or collector trust. + ## Severity and source policy Action pinning, workflow-run branch scope, blanket write permissions, disabled version checking, private-to-public opt-outs, enabled Node install hooks, and bare network wildcard allowances are errors for an AW document with strict mode enabled (the AW default), and warnings for explicit non-strict AW. Native Actions policy-sensitive findings are warnings; AW-only rules skip native Actions without an AW document. Conditional imports, bare label-allowlist wildcards, invalid OIDC read grants, missing or malformed `workflow_run.workflows`, invalid explicit timeouts, and direct template injection are errors. Unconstrained wildcard push targets remain warnings even in strict mode. The linter does not reconstruct CLI/repository strict overrides, load imported configurations, infer missing permissions, or determine whether each repository-write grant is authorized. diff --git a/pkg/workflow/lint/lint.go b/pkg/workflow/lint/lint.go index 8b1638fe144..71b69962e80 100644 --- a/pkg/workflow/lint/lint.go +++ b/pkg/workflow/lint/lint.go @@ -213,6 +213,7 @@ func sortDiagnostics(diagnostics []Diagnostic) { func Default() *Runner { return &Runner{rules: []Rule{ SecretLeakRule(), + OTLPSecretLeakRule(), ActionPinRule(), JobTimeoutRule(), NeedsConsistencyRule(), diff --git a/pkg/workflow/lint/lint_test.go b/pkg/workflow/lint/lint_test.go index 47574f06f1a..4cb0b2ca448 100644 --- a/pkg/workflow/lint/lint_test.go +++ b/pkg/workflow/lint/lint_test.go @@ -20,7 +20,7 @@ func TestRuleRegistrationAndSelection(t *testing.T) { ruleIDs = append(ruleIDs, rule.ID) } assert.ElementsMatch(t, []string{ - "secret-leak", "action-pin", "job-timeout", "needs-consistency", "template-injection", "workflow-run-scope", + "secret-leak", "otlp-secret-leak", "action-pin", "job-timeout", "needs-consistency", "template-injection", "workflow-run-scope", "permission-scope", "version-check", "conditional-import", "private-to-public", "install-scripts", "allowed-labels", "network-scope", "push-target", "tool-scope", "sandbox-config", "image-argument", "checkout-mode", "fork-association", diff --git a/pkg/workflow/lint/otlp_secret_leak.go b/pkg/workflow/lint/otlp_secret_leak.go new file mode 100644 index 00000000000..8c26156e48b --- /dev/null +++ b/pkg/workflow/lint/otlp_secret_leak.go @@ -0,0 +1,127 @@ +package lint + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "strings" + + "github.com/github/gh-aw/pkg/workflow/aw" + "github.com/github/gh-aw/pkg/workflow/taint" +) + +func OTLPSecretLeakRule() Rule { + return Rule{ + ID: "otlp-secret-leak", + ThreatRuleID: "CTR-017", + Description: "Detect secret values exported as OTLP attributes", + Severity: Error, + Targets: ActionsAST | AWAST, + Check: checkOTLPSecretLeaks, + } +} + +func checkOTLPSecretLeaks(ctx context.Context, input Input) ([]Diagnostic, error) { + if err := integrityInput(ctx, input); err != nil { + return nil, err + } + rule := taint.SecretLeakRule() + rule.IsSource = isOTLPSecretReference + rule.IsSink = func(site taint.Site) bool { + return site.Kind == taint.OTLPAttribute + } + + if input.Workflow != nil { + result, err := taint.Analyze(input.Workflow, rule) + diagnostics := make([]Diagnostic, 0, len(result.Findings)) + for _, finding := range result.Findings { + diagnostics = append(diagnostics, Diagnostic{ + Path: finding.Site.Path, + Message: "Secret-tainted value is exported to an OTLP backend; remove the secret from telemetry attributes", + }) + } + return diagnostics, err + } + + if input.Document != nil { + return checkAuthoredOTLPSecretAttributes(ctx, input.Document) + } + return nil, errors.New("OTLP secret-leak lint requires an Actions or AW AST") +} + +func checkAuthoredOTLPSecretAttributes(ctx context.Context, document *aw.Document) ([]Diagnostic, error) { + if document == nil { + return nil, nil + } + observability, ok := document.Frontmatter["observability"].(map[string]any) + if !ok { + return nil, nil + } + otlp, ok := observability["otlp"].(map[string]any) + if !ok { + return nil, nil + } + + var diagnostics []Diagnostic + var failures []error + for _, field := range []string{"attributes", "resource-attributes"} { + attributes, ok := stringValues(otlp[field]) + if !ok { + continue + } + for _, name := range slices.Sorted(maps.Keys(attributes)) { + if err := ctx.Err(); err != nil { + return diagnostics, errors.Join(append(failures, err)...) + } + path := "observability.otlp." + field + "." + name + expressions, err := taint.AnalyzeText(ctx, attributes[name], path, authoredOTLPSecretRule(), nil) + if err != nil { + failures = append(failures, fmt.Errorf("cannot inspect OTLP attribute at %s: invalid Actions expression; correct the value before linting", path)) + continue + } + for _, expression := range expressions { + if len(expression.Sources) == 0 { + continue + } + diagnostics = append(diagnostics, Diagnostic{ + Path: path, + Message: "Secret-tainted value is exported to an OTLP backend; remove the secret from telemetry attributes", + }) + break + } + } + } + return diagnostics, errors.Join(append(failures, ctx.Err())...) +} + +func authoredOTLPSecretRule() taint.Rule { + rule := taint.SecretLeakRule() + rule.IsSource = isOTLPSecretReference + return rule +} + +func isOTLPSecretReference(reference string) bool { + reference = strings.ToLower(reference) + return reference == "secrets" || strings.HasPrefix(reference, "secrets.") || + reference == "github.token" || strings.HasPrefix(reference, "github.token.") +} + +func stringValues(value any) (map[string]string, bool) { + switch attributes := value.(type) { + case map[string]any: + result := make(map[string]string, len(attributes)) + for name, value := range attributes { + text, ok := value.(string) + if ok { + result[name] = text + } + } + return result, true + case map[string]string: + return attributes, true + default: + return nil, false + } +} diff --git a/pkg/workflow/lint/otlp_secret_leak_test.go b/pkg/workflow/lint/otlp_secret_leak_test.go new file mode 100644 index 00000000000..9cb94d5bd59 --- /dev/null +++ b/pkg/workflow/lint/otlp_secret_leak_test.go @@ -0,0 +1,116 @@ +package lint + +import ( + "context" + "testing" + + "github.com/github/gh-aw/pkg/workflow/actions" + "github.com/github/gh-aw/pkg/workflow/aw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOTLPSecretLeakRuleChecksBothAuthoredAttributeMaps(t *testing.T) { + document, err := aw.Parse(`--- +observability: + otlp: + attributes: + account: "${{ secrets.API_TOKEN }}" + resource-attributes: + tenant: "${{ github.token }}" +--- +Public prompt. +`) + require.NoError(t, err) + + runner, err := Default().Select("otlp-secret-leak") + require.NoError(t, err) + diagnostics, err := runner.Run(context.Background(), Input{Document: document}) + require.NoError(t, err) + require.Len(t, diagnostics, 2) + assert.Equal(t, "observability.otlp.attributes.account", diagnostics[0].Path) + assert.Equal(t, "observability.otlp.resource-attributes.tenant", diagnostics[1].Path) + for _, diagnostic := range diagnostics { + assert.Equal(t, "CTR-017", diagnostic.ThreatRuleID) + assert.NotContains(t, diagnostic.Message, "API_TOKEN") + assert.NotContains(t, diagnostic.Message, "github.token") + assert.Contains(t, diagnostic.Message, "OTLP backend") + } +} + +func TestOTLPSecretLeakRuleFollowsEnvironmentBindings(t *testing.T) { + workflow, err := actions.Parse([]byte(` +env: + PRIVATE_VALUE: ${{ secrets.API_TOKEN }} +jobs: + export: + runs-on: ubuntu-latest + env: + GH_AW_OTLP_ATTRIBUTES: '{"account":"${{ env.PRIVATE_VALUE }}"}' + steps: + - env: + OTEL_RESOURCE_ATTRIBUTES: 'tenant=${{ env.PRIVATE_VALUE }}' + run: echo ready +`)) + require.NoError(t, err) + + runner, err := Default().Select("otlp-secret-leak") + require.NoError(t, err) + diagnostics, err := runner.Run(context.Background(), Input{Workflow: workflow}) + require.NoError(t, err) + require.Len(t, diagnostics, 2) + assert.Equal(t, "jobs.export.env.GH_AW_OTLP_ATTRIBUTES", diagnostics[0].Path) + assert.Equal(t, "jobs.export.steps[0].env.OTEL_RESOURCE_ATTRIBUTES", diagnostics[1].Path) + for _, diagnostic := range diagnostics { + assert.NotContains(t, diagnostic.Message, "API_TOKEN") + } +} + +func TestDefaultRulesReportOTLPSecretLeakOnce(t *testing.T) { + workflow, err := actions.Parse([]byte(` +env: + GH_AW_OTLP_ATTRIBUTES: '{"account":"${{ secrets.API_TOKEN }}"}' +jobs: + export: + runs-on: ubuntu-latest + steps: + - run: echo ready +`)) + require.NoError(t, err) + + diagnostics, err := Default().Run(context.Background(), Input{Workflow: workflow}) + require.NoError(t, err) + var otlpFindings, genericOTLPFindings int + for _, diagnostic := range diagnostics { + switch diagnostic.RuleID { + case "otlp-secret-leak": + otlpFindings++ + case "secret-leak": + if diagnostic.Path == "env.GH_AW_OTLP_ATTRIBUTES" { + genericOTLPFindings++ + } + } + } + assert.Equal(t, 1, otlpFindings) + assert.Zero(t, genericOTLPFindings) +} + +func TestOTLPSecretLeakRuleIgnoresQuotedMentionsAndPublicContexts(t *testing.T) { + workflow, err := actions.Parse([]byte(` +env: + GH_AW_OTLP_ATTRIBUTES: '{"note":"${{ ''secrets.API_TOKEN'' }}","repository":"${{ github.repository }}"}' + OTEL_RESOURCE_ATTRIBUTES: 'service.name=${{ vars.SERVICE_NAME }}' +jobs: + export: + runs-on: ubuntu-latest + steps: + - run: echo ready +`)) + require.NoError(t, err) + + runner, err := Default().Select("otlp-secret-leak") + require.NoError(t, err) + diagnostics, err := runner.Run(context.Background(), Input{Workflow: workflow}) + require.NoError(t, err) + assert.Empty(t, diagnostics) +} diff --git a/pkg/workflow/lint/performance_benchmark_test.go b/pkg/workflow/lint/performance_benchmark_test.go index 065b5def5e2..f12df5c3a7e 100644 --- a/pkg/workflow/lint/performance_benchmark_test.go +++ b/pkg/workflow/lint/performance_benchmark_test.go @@ -14,10 +14,10 @@ import ( // Parsing is deliberately outside measurement: every rule consumes existing ASTs. func performanceInput(tb testing.TB, contract ruleContract, target ASTTargets, positive bool, jobs int) Input { tb.Helper() - source := contract.safe + source := contractSource(contract, target, false) prompt := contract.safePrompt if positive { - source = contract.unsafe + source = contractSource(contract, target, true) prompt = contract.unsafePrompt } if target == ActionsAST { diff --git a/pkg/workflow/lint/rule_contract_test.go b/pkg/workflow/lint/rule_contract_test.go index 050aadae75f..e61ad9cfe4a 100644 --- a/pkg/workflow/lint/rule_contract_test.go +++ b/pkg/workflow/lint/rule_contract_test.go @@ -12,18 +12,21 @@ import ( ) type ruleContract struct { - targets ASTTargets - threatRuleID string - unsafe string - safe string - path string - severity Severity - strictPolicy bool - javaScript bool - unsafePrompt string - safePrompt string - unsafeShell string - safeShell string + targets ASTTargets + threatRuleID string + unsafe string + safe string + path string + severity Severity + strictPolicy bool + javaScript bool + unsafePrompt string + safePrompt string + unsafeShell string + safeShell string + actionsUnsafe string + actionsSafe string + actionsPath string } const contractJob = "jobs:\n check:\n runs-on: ubuntu-latest\n" @@ -94,6 +97,18 @@ func builtInRuleContracts() map[string]ruleContract { safe: contractJob + " env:\n TOKEN: '${{ secrets.CONTRACT_SECRET }}'\n steps:\n - shell: bash\n run: test -n \"$TOKEN\"\n", path: "jobs.check.steps[0].run", }, + "otlp-secret-leak": { + threatRuleID: "CTR-017", + targets: ActionsAST | AWAST, + unsafe: "observability:\n otlp:\n attributes: {account: '${{ secrets.CONTRACT_SECRET }}'}\n", + safe: "observability:\n otlp:\n attributes: {account: '${{ github.repository }}'}\n", + path: "observability.otlp.attributes.account", + actionsUnsafe: "env:\n GH_AW_OTLP_ATTRIBUTES: '{\"account\":\"${{ secrets.CONTRACT_SECRET }}\"}'\n" + + contractJob + contractSafeSteps, + actionsSafe: "env:\n GH_AW_OTLP_ATTRIBUTES: '{\"note\":\"${{ ''secrets.CONTRACT_SECRET'' }}\"}'\n" + + contractJob + contractSafeSteps, + actionsPath: "env.GH_AW_OTLP_ATTRIBUTES", + }, "action-pin": { threatRuleID: "CTR-002", targets: ActionsAST | AWAST, strictPolicy: true, @@ -286,8 +301,8 @@ func runRuleContractPair(t *testing.T, id string, contract ruleContract, target name, source, prompt string finding bool }{ - {"positive", contract.unsafe, contract.unsafePrompt, true}, - {"negative", contract.safe, contract.safePrompt, false}, + {"positive", contractSource(contract, target, true), contract.unsafePrompt, true}, + {"negative", contractSource(contract, target, false), contract.safePrompt, false}, } { t.Run(test.name, func(t *testing.T) { input := ruleContractInput(t, test.source, target, nonstrict) @@ -352,8 +367,13 @@ func assertRuleContractDiagnostics(t *testing.T, id string, contract ruleContrac if contract.strictPolicy && (nonstrict || target == ActionsAST) { severity = Warning } - want[contract.path] = severity + path := contract.path + if target == ActionsAST && contract.actionsPath != "" { + path = contract.actionsPath + } + want[path] = severity } + require.Len(t, diagnostics, len(want), "positive cases must detect the violation; negative cases must reject false positives") for _, diagnostic := range diagnostics { severity, expected := want[diagnostic.Path] @@ -369,3 +389,16 @@ func assertRuleContractDiagnostics(t *testing.T, id string, contract ruleContrac } assert.Empty(t, want, "every expected diagnostic must be emitted") } + +func contractSource(contract ruleContract, target ASTTargets, positive bool) string { + if target == ActionsAST && contract.actionsUnsafe != "" { + if positive { + return contract.actionsUnsafe + } + return contract.actionsSafe + } + if positive { + return contract.unsafe + } + return contract.safe +} diff --git a/pkg/workflow/lint/secrets.go b/pkg/workflow/lint/secrets.go index a1fffca405f..99e60384d4b 100644 --- a/pkg/workflow/lint/secrets.go +++ b/pkg/workflow/lint/secrets.go @@ -20,6 +20,10 @@ func SecretLeakRule() Rule { var result taint.Result var err error rule := taint.SecretLeakRule() + isSink := rule.IsSink + rule.IsSink = func(site taint.Site) bool { + return site.Kind != taint.OTLPAttribute && isSink(site) + } if input.JavaScript != nil { options := *input.JavaScript options.Context = ctx diff --git a/pkg/workflow/taint/taint.go b/pkg/workflow/taint/taint.go index 0b013d47db6..6a6a5ac881a 100644 --- a/pkg/workflow/taint/taint.go +++ b/pkg/workflow/taint/taint.go @@ -22,6 +22,7 @@ const ( WorkflowInput Kind = "workflow-input" Artifact Kind = "artifact" Network Kind = "network" + OTLPAttribute Kind = "otlp-attribute" Prompt Kind = "prompt" ) diff --git a/pkg/workflow/taint/taint_test.go b/pkg/workflow/taint/taint_test.go index 237d1ac170e..e27367416bb 100644 --- a/pkg/workflow/taint/taint_test.go +++ b/pkg/workflow/taint/taint_test.go @@ -26,6 +26,26 @@ func checkSteps(t *testing.T, steps string) Result { return result } +func TestCheckSecretLeaksReportsOTLPAttributeValues(t *testing.T) { + workflow, err := actions.Parse([]byte(` +env: + GH_AW_OTLP_ATTRIBUTES: '{"account":"${{ secrets.API_TOKEN }}"}' +jobs: + export: + runs-on: ubuntu-latest + steps: + - run: echo ready +`)) + require.NoError(t, err) + + result, err := CheckSecretLeaks(workflow) + require.NoError(t, err) + require.Len(t, result.Findings, 1) + assert.Equal(t, OTLPAttribute, result.Findings[0].Site.Kind) + assert.Equal(t, "env.GH_AW_OTLP_ATTRIBUTES", result.Findings[0].Site.Path) + assert.NotContains(t, result.Findings[0].String(), "API_TOKEN") +} + func TestSecretLeakSinksAndFilters(t *testing.T) { tests := []struct { name string diff --git a/pkg/workflow/taint/workflow.go b/pkg/workflow/taint/workflow.go index 4b08df5591d..7677d478458 100644 --- a/pkg/workflow/taint/workflow.go +++ b/pkg/workflow/taint/workflow.go @@ -129,6 +129,9 @@ func (a *analyzer) expressions(values map[string]actions.Expression, path string if err != nil { return nil, err } + if name == "GH_AW_OTLP_ATTRIBUTES" || name == "OTEL_RESOURCE_ATTRIBUTES" { + a.emit(Site{Kind: OTLPAttribute, Path: path + "." + name, Input: name}, sources) + } result["env."+strings.ToLower(name)] = sources result["shell."+name] = sources } @@ -321,6 +324,9 @@ func (a *analyzer) step(fields map[string]any, path string, env, persisted, file if err != nil { return err } + if key == "GH_AW_OTLP_ATTRIBUTES" || key == "OTEL_RESOURCE_ATTRIBUTES" { + a.emit(Site{Kind: OTLPAttribute, Path: path + ".env." + key, Input: key}, sources) + } bindings["env."+strings.ToLower(key)] = sources bindings["shell."+key] = sources } diff --git a/specs/compiler-threat-detection-changelog.md b/specs/compiler-threat-detection-changelog.md index 207a6120dde..1b76551514f 100644 --- a/specs/compiler-threat-detection-changelog.md +++ b/specs/compiler-threat-detection-changelog.md @@ -16,6 +16,7 @@ This changelog records the version history and the dated mapping audits for `spe | Version | Change | |---|---| +| 1.0.65 | Added `otlp-secret-leak`, an Actions/AW AST lint subset of CTR-017 that detects secret-tainted OTLP span/resource attributes and modeled Actions environment flows with value-free diagnostics. Documented that the check is advisory and cannot identify hard-coded credentials, arbitrary runtime values, or collector trust. No new threat identifier or lock-file schema change. | | 1.0.64 | Adds the compromised-intermediate-CA threat and maps bootstrap/runtime controls to security architecture §6.8: independently authenticated AWF/Copilot checksum manifests, literal frontmatter manifest-pin overrides bound to explicit versions, compiler-pinned Compose bytes, authenticated apt signing key, HTTPS-only redirects, actual Go release-toolchain verification and bounded runtime inventory. Overrides propagate through matching detection/eval installations; Copilot overrides bypass toolcache activation. Preserves CTR-011 destination authorization as a separate boundary and records broader download enforcement as a candidate. Unreviewed release downloads without a supplied pin fail closed; no new threat identifier or lock-file schema change. | | 1.0.63 | Audit-only clarification of the Copilot SDK runtime boundary: explicit staged-only skill registration with ambient discovery disabled, hard rejection of file and symlink roots, and unchanged permission-denial counting and termination. Separates prompt recovery guidance from authorization and preserves CTR-023 engine-capability checks and CTR-028/030 generated provenance requirements. No new threat identifier or lock-file schema change. | | 1.0.62 | Added authored `pull-request-target-checkout` (CTR-008 subset) and `prompt-expression` (CTR-010 subset) rules, bringing the AST registry to 23 rules. Shared existing base-ref and literal-pair trust predicates with compiler checkout validation and reused the Actions expression visitor for prompt checks. Preserved authored strict/non-strict checkout policy, import uncertainty, exact allowlist matching, value-free prompt diagnostics, and native Actions evidence boundaries. Prompt checks reject multiline/malformed interpolations and blocked property traversal without claiming the full context allowlist. Added registry, CLI, compiler-policy, cancellation, and repository-workflow regressions. No new threat identifier or lock-file schema change. | diff --git a/specs/compiler-threat-detection-spec.md b/specs/compiler-threat-detection-spec.md index 1761d7c0e2c..e14ea072058 100644 --- a/specs/compiler-threat-detection-spec.md +++ b/specs/compiler-threat-detection-spec.md @@ -7,7 +7,7 @@ sidebar: # GitHub Actions Compiler Threat Detection Specification -**Version**: 1.0.64 +**Version**: 1.0.65 **Status**: Candidate Recommendation **Latest Version**: https://github.com/github/gh-aw/blob/main/specs/compiler-threat-detection-spec.md **Editors**: GitHub Next (GitHub, Inc.) @@ -32,6 +32,7 @@ Each version maps to the minimum compatible binary. A version change MUST update | Versions | Minimum gh-aw | Compatibility | |---|---:|---| +| `1.0.65` | Unreleased | Adds the `otlp-secret-leak` Actions/AW AST lint subset of CTR-017 for secret-tainted OTLP span and resource attributes, including Actions environment dataflow and value-free diagnostics. It does not infer literal credentials or arbitrary runtime values. No new threat identifier or lock-file schema change. | | `1.0.64` | Unreleased | Separates compromised-CA transport authentication from CTR-011 destination authorization. Maps trusted release digests, HTTPS-only bootstrap redirects, patched release toolchains and runtime inventory to security architecture §6.8. Adds optional literal `engine.checksums-sha256` and `sandbox.agent.checksums-sha256` overrides bound to explicit versions, including matching detection/eval installations. Unreviewed downloads without a supplied pin fail closed; no new `CTR-*` identifier or lock-file schema change. | | `1.0.63` | Unreleased | Audit-only; distinguishes staged Copilot SDK skill registration and runtime permission-denial enforcement from compiler controls. Preserves CTR-023 engine-capability validation and CTR-028/030 configuration provenance. No new threat identifier, permission grant, or lock-file schema change. | | `1.0.62` | Unreleased | Adds authored AST checks for `pull_request_target` checkout trust (CTR-008 subset) and multiline, malformed, or blocked-property prompt expressions (CTR-010 subset). Shares checkout trust predicates with the compiler and expression parsing with secret-flow analysis. Preserves unresolved-import uncertainty and native Actions evidence boundaries. No new threat identifier or lock-file schema change. | @@ -98,6 +99,8 @@ Runtime shell authorization and denial-budget enforcement are likewise distinct Host-side OTLP exporter configuration is another secret-bearing trust boundary. Passing collector headers and endpoints through AWF's `--env-all` exposes collector credentials to sandboxed processes and can cause an agent CLI to export duplicate telemetry. CTR-017 covers compiler-generated exclusions for the agent, threat-detection, and evals sandboxes. +OTLP span and resource attributes are outbound telemetry sinks: a configured secret expression can disclose its resolved value to the collector even when the exporter credentials remain isolated from the agent sandbox. The Actions/AW linter reports direct secret references in authored OTLP attribute maps and secret-tainted values reaching the generated `GH_AW_OTLP_ATTRIBUTES` or `OTEL_RESOURCE_ATTRIBUTES` environment bindings, including modeled Actions environment flows. This lint subset is not a compiler rejection, cannot identify hard-coded credentials or arbitrary runtime values, and does not establish that an OTLP collector is trusted. + Safe-output body content is untrusted even when supplied to a trusted publisher. A body can contain forged reserved HTML comments, including nested, variant, or unterminated openers, or Markdown anchor markers that imitate execution provenance. Attribution can also be lost when a publisher omits markers from initial WIP bodies or uses HTML comments in PR review comments, where GitHub's API filters them out. CTR-032 requires the compiler to provide the publishing check-run identity and the runtime publisher to remove or neutralize supplied reserved markers before appending trusted execution metadata in a format retained by the destination API; missing or invalid identity fails closed. Repository-selection expressions are runtime data, not an implicit request for repository-list expansion. Inferring multi mode from an expression or an allowlist changes authentication, visibility, path, and secondary-fetch behavior unexpectedly. CTR-033 requires explicit mode classification and generated single-selector validation. The multi-mode preflight and privileged-consumer boundary are specified by checkout behavior §3.1 and safe outputs RCR8–RCR15 and modeled in `specs/workflow-security/RuntimeRepositoryCheckout.tla`; the model does not prove parser, filesystem, or Git correctness. @@ -130,7 +133,7 @@ Each rule has a stable `CTR-*` ID, threat class, trigger, compiler action, diagn - **CTR-014 Supply Chain Attack via Install Scripts**: Warn, or reject in strict mode, when Node install scripts are enabled. - **CTR-015 Allowed Label Glob Scope**: Reject bare `*` safe-output allowed-label patterns. - **CTR-016 Compile-Time Manifest Drift**: Reject new restricted secrets or action references absent from an existing manifest. -- **CTR-017 Secret Leakage via Environment Variables**: Warn, or reject in strict mode, for uncontrolled secret-expression placement. When OTLP is enabled and the effective AWF version supports `--exclude-env`, generated agent, threat-detection, and evals AWF invocations using `--env-all` MUST exclude `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, and `GH_AW_OTLP_ENDPOINTS`. These values MUST remain available to host-side exporters. Both inline and external threat-detection paths and evals execution MUST preserve the exclusions. Workflows without OTLP MUST NOT gain these OTLP-specific exclusions unless independently required by another exclusion policy. +- **CTR-017 Secret Leakage via Environment Variables and Telemetry**: Warn, or reject in strict mode, for uncontrolled secret-expression placement. The Actions/AW linter MUST report secret-tainted values configured as OTLP span or resource attributes, including modeled Actions environment flows, without reproducing the values in diagnostics. This lint check is advisory and does not prove the absence of hard-coded credentials or arbitrary runtime disclosures. When OTLP is enabled and the effective AWF version supports `--exclude-env`, generated agent, threat-detection, and evals AWF invocations using `--env-all` MUST exclude `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, and `GH_AW_OTLP_ENDPOINTS`. These values MUST remain available to host-side exporters. Both inline and external threat-detection paths and evals execution MUST preserve the exclusions. Workflows without OTLP MUST NOT gain these OTLP-specific exclusions unless independently required by another exclusion policy. - **CTR-018 Version Integrity Bypass**: Warn, or reject in strict mode, for `check-for-updates: false`. - **CTR-019 Cache-Memory Integrity Enforcement**: Require cache updates only after successful agent and threat-detection jobs. - **CTR-020 Conditional Import Security**: Reject `imports` entries containing `if`. @@ -235,7 +238,7 @@ Every active rule MUST map to implementation and test coverage. References are p | CTR-014 Supply Chain Attack via Install Scripts | name, install-script, and allowed-label validation | `argument_injection_test.go`, corresponding validation tests | | CTR-015 Allowed Label Glob Scope | name, install-script, and allowed-label validation | `argument_injection_test.go`, corresponding validation tests | | CTR-016 Compile-Time Manifest Drift | safe-update, strict env/update, cache, and expression builder | corresponding enforcement, secrets, update, and cache tests | -| CTR-017 Secret Leakage via Environment Variables | safe-update, strict env/update, cache, and expression builder; `pkg/workflow/awf_command_builder.go`, `threat_detection_external.go`, `evals_steps.go`; AST subset: `pkg/workflow/lint/secrets.go`, `pkg/workflow/taint/` | corresponding enforcement, secrets, update, and cache tests; `TestOTLPEnvExcludedFromAWFSandboxes` in `awf_env_test.go`, `TestEvalsOTLPEnvExcludedFromSandbox` in `evals_steps_test.go`, `TestCompiledOTLPEnvExcludedFromAgentDetectionAndEvals` in `threat_detection_isolation_test.go`; `pkg/workflow/taint/taint_test.go` | +| CTR-017 Secret Leakage via Environment Variables and Telemetry | safe-update, strict env/update, cache, and expression builder; `pkg/workflow/awf_command_builder.go`, `threat_detection_external.go`, `evals_steps.go`; AST subsets: `pkg/workflow/lint/secrets.go`, `otlp_secret_leak.go`, `pkg/workflow/taint/` | corresponding enforcement, secrets, update, cache, and OTLP leak tests; `TestOTLPEnvExcludedFromAWFSandboxes` in `awf_env_test.go`, `TestEvalsOTLPEnvExcludedFromSandbox` in `evals_steps_test.go`, `TestCompiledOTLPEnvExcludedFromAgentDetectionAndEvals` in `threat_detection_isolation_test.go`; `pkg/workflow/taint/taint_test.go`, `pkg/workflow/lint/otlp_secret_leak_test.go` | | CTR-018 Version Integrity Bypass | safe-update, strict env/update, cache, and expression builder | corresponding enforcement, secrets, update, and cache tests | | CTR-019 Cache-Memory Integrity Enforcement | safe-update, strict env/update, cache, and expression builder | corresponding enforcement, secrets, update, and cache tests | | CTR-020 Conditional Import Security | `pkg/parser/import_bfs.go` | `pkg/parser/import_bfs_test.go` | @@ -296,6 +299,7 @@ Rules with a known compiler threat mapping MUST declare its `CTR-*` ID in `Rule. | CTR-014 | `install-scripts` | Authored Node runtime configuration does not explicitly enable dependency install hooks. Does not infer imported runtime configuration or arbitrary package-install commands. | `pkg/workflow/lint/runtime_policy.go`, `policy_conversions_test.go`, `source_policy_test.go` | | CTR-015 | `allowed-labels` | Authored issue, discussion, discussion-update, and pull-request creation label allowlists do not contain a bare wildcard. Required-label names remain literal; narrower allowed-label patterns are accepted. | `pkg/workflow/lint/output_policy.go`, `policy_conversions_test.go` | | CTR-017 | `secret-leak` | Symbolic secrets do not reach modeled disclosure sinks through expression, environment, output, shell, file, or explicitly configured JavaScript flows. Coverage warnings identify unmodeled paths; JavaScript analysis requires an explicit native compiler and source configuration. | `pkg/workflow/lint/secrets.go`, `pkg/workflow/taint/`, `pkg/workflow/taint/taint_test.go`, `pkg/workflow/taint/javascript_test.go` | +| CTR-017 | `otlp-secret-leak` | Secret-tainted values do not reach authored OTLP span/resource attribute maps or Actions environment bindings named `GH_AW_OTLP_ATTRIBUTES` and `OTEL_RESOURCE_ATTRIBUTES`. Actions AST analysis follows modeled environment bindings; AW-only analysis checks direct secret references. Diagnostics are value-free. Hard-coded credentials, arbitrary runtime values, and unresolved external inputs are not inferred, and the linter does not prove collector trust or compiler rejection. | `pkg/workflow/lint/otlp_secret_leak.go`, `otlp_secret_leak_test.go`; `pkg/workflow/taint/workflow.go` | | CTR-018 | `version-check` | An authored AW document does not explicitly disable version integrity checking. | `pkg/workflow/lint/source_policy.go`, `source_policy_test.go` | | CTR-020 | `conditional-import` | Authored AW configuration import entries do not contain `if`; runtime prompt conditionals are unaffected. | `pkg/workflow/lint/source_policy.go`, `source_policy_test.go` | | CTR-021 | `workflow-run-scope` | `workflow_run` selects workflows and has branch scope, with strict/non-strict severity policy. | `pkg/workflow/lint/workflow_run_scope.go`, `workflow_run_scope_test.go` | @@ -340,7 +344,7 @@ Each active rule MUST have at least one deterministic test that covers its prima | **T-CTR-014** | CTR-014 Supply Chain Attack via Install Scripts | Warn, or reject in strict mode, when Node install scripts are enabled | Warn, or reject in strict mode, when Node install scripts are enabled. | `CTR-014` | | **T-CTR-015** | CTR-015 Allowed Label Glob Scope | Reject bare `*` safe-output allowed-label patterns | Reject bare `*` safe-output allowed-label patterns. | `CTR-015` | | **T-CTR-016** | CTR-016 Compile-Time Manifest Drift | Reject new restricted secrets or action references absent from an existing manifest | Reject new restricted secrets or action references absent from an existing manifest. | `CTR-016` | -| **T-CTR-017** | CTR-017 Secret Leakage via Environment Variables | Uncontrolled secret-expression placement, or OTLP-enabled agent/threat-detection/evals AWF generation with `--exclude-env` support | Warn, or reject in strict mode, for uncontrolled secret-expression placement; exclude all three host-side OTLP variables from agent, inline/external detection, and evals sandboxes while retaining them for host exporters; preserve behavior without OTLP. | `CTR-017` | +| **T-CTR-017** | CTR-017 Secret Leakage via Environment Variables and Telemetry | Uncontrolled secret-expression placement, secret-tainted values in OTLP attributes, or OTLP-enabled agent/threat-detection/evals AWF generation with `--exclude-env` support | Warn, or reject in strict mode, for uncontrolled secret-expression placement; report secret-tainted OTLP span/resource attributes with value-free diagnostics; exclude all three host-side OTLP variables from agent, inline/external detection, and evals sandboxes while retaining them for host exporters; preserve behavior without OTLP. | `CTR-017` | | **T-CTR-018** | CTR-018 Version Integrity Bypass | Warn, or reject in strict mode, for `check-for-updates: false` | Warn, or reject in strict mode, for `check-for-updates: false`. | `CTR-018` | | **T-CTR-019** | CTR-019 Cache-Memory Integrity Enforcement | Require cache updates only after successful agent and threat-detection jobs | Require cache updates only after successful agent and threat-detection jobs. | `CTR-019` | | **T-CTR-020** | CTR-020 Conditional Import Security | Reject `imports` entries containing `if` | Reject `imports` entries containing `if`. | `CTR-020` | From 8ca0d9e96167f1f6ef445516a440a1d09575dac9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:24:44 +0000 Subject: [PATCH 5/6] Apply remaining changes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/skills/agentic-workflows/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index b3246aa7452..e1f7efefa6f 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -40,6 +40,7 @@ If GitHub API, `gh api`, or GitHub MCP downloads return HTTP 403 (including a to - `.github/aw/deployment-status.md` - `.github/aw/designer-mappings.md` - `.github/aw/designer.md` +- `.github/aw/documentation-workflows.md` - `.github/aw/drive-memory.md` - `.github/aw/enclaves.md` - `.github/aw/evals.md` From 7176f094d45fc1ca0edb005dac68d91ddc81ca0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:26:18 +0000 Subject: [PATCH 6/6] Fix OTLP metadata reference detection Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/observability_metadata.go | 139 ++++++++++++++++---- pkg/workflow/observability_metadata_test.go | 79 +++++++++++ schemas/logs.schema.json | 60 +++++++++ 3 files changed, 256 insertions(+), 22 deletions(-) diff --git a/pkg/workflow/observability_metadata.go b/pkg/workflow/observability_metadata.go index 066875ef083..eb64b9b80ee 100644 --- a/pkg/workflow/observability_metadata.go +++ b/pkg/workflow/observability_metadata.go @@ -3,12 +3,12 @@ package workflow import ( "fmt" "regexp" + "sort" "strings" ) -const otlpMetadataNamespace = "github.aw.metadata" - var otlpMetadataReferencePattern = regexp.MustCompile(`^\s*\$\{\{\s*github\.aw\.metadata\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}\s*$`) +var otlpMetadataAccessPattern = regexp.MustCompile(`^github\s*\.\s*aw\s*(?:\.\s*metadata\b|\[\s*'metadata'\s*\]|\[\s*"metadata"\s*\])`) // resolveOTLPMetadataReferences replaces whole-value references in custom OTLP // attribute maps with strings from the root workflow's metadata. @@ -39,37 +39,132 @@ func resolveOTLPMetadataReferences(workflowData *WorkflowData) error { if attributes == nil { continue } - for name, value := range attributes { + names := make([]string, 0, len(attributes)) + for name := range attributes { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + value := attributes[name] text, ok := value.(string) - if !ok || !strings.Contains(text, otlpMetadataNamespace) { + if !ok || !containsOTLPMetadataAccess(text) { continue } scope := "observability.otlp." + field + "." + name - if !otlpMetadataReferencePattern.MatchString(text) { - return fmt.Errorf("%s must be a whole-value reference in the form ${{ github.aw.metadata. }}", scope) + resolved, err := resolveOTLPMetadataValue(text, metadata, scope) + if err != nil { + return err + } + attributes[name] = resolved + } + } + return nil +} + +func resolveOTLPMetadataValue(text string, metadata map[string]any, scope string) (string, error) { + match := otlpMetadataReferencePattern.FindStringSubmatch(text) + if len(match) < 2 { + return "", fmt.Errorf("%s must be a whole-value reference in the form ${{ github.aw.metadata. }}", scope) + } + key := "" + for index, capture := range match { + if index == 1 { + key = capture + break + } + } + if metadata == nil { + return "", fmt.Errorf("%s references metadata key %q, but the root workflow has no metadata map", scope, key) + } + metadataValue, exists := metadata[key] + if !exists { + return "", fmt.Errorf("%s references missing metadata key %q", scope, key) + } + resolved, ok := metadataValue.(string) + if !ok { + return "", fmt.Errorf("%s references metadata key %q, which must be a string", scope, key) + } + if strings.Contains(resolved, "${{") || strings.Contains(resolved, "}}") { + return "", fmt.Errorf("%s cannot resolve metadata key %q because its value contains GitHub Actions expression delimiters", scope, key) + } + return resolved, nil +} + +func containsOTLPMetadataAccess(value string) bool { + searchFrom := 0 + for { + open := strings.Index(value[searchFrom:], "${{") + if open == -1 { + return false + } + expressionStart := searchFrom + open + len("${{") + closed := false + for index := expressionStart; index < len(value); { + current, ok := otlpStringByteAt(value, index) + if !ok { + break } - key := strings.TrimSpace(text) - key = strings.TrimSpace(strings.TrimPrefix(key, "${{")) - key = strings.TrimSpace(strings.TrimSuffix(key, "}}")) - key = strings.TrimSpace(strings.TrimPrefix(key, otlpMetadataNamespace+".")) - if metadata == nil { - return fmt.Errorf("%s references metadata key %q, but the root workflow has no metadata map", scope, key) + switch current { + case '\'', '"': + index = skipOTLPExpressionString(value, index) + continue + case '}': + next, ok := otlpStringByteAt(value, index+1) + if ok && next == '}' { + searchFrom = index + 2 + closed = true + } } - metadataValue, exists := metadata[key] - if !exists { - return fmt.Errorf("%s references missing metadata key %q", scope, key) + if closed { + break } - resolved, ok := metadataValue.(string) - if !ok { - return fmt.Errorf("%s references metadata key %q, which must be a string", scope, key) + previous, hasPrevious := otlpStringByteAt(value, index-1) + if current == 'g' && (!hasPrevious || index == expressionStart || !isOTLPIdentifierCharacter(previous)) && otlpMetadataAccessPattern.MatchString(value[index:]) { + return true } - if strings.Contains(resolved, "${{") || strings.Contains(resolved, "}}") { - return fmt.Errorf("%s cannot resolve metadata key %q because its value contains GitHub Actions expression delimiters", scope, key) + index++ + } + if !closed { + return false + } + } +} + +func skipOTLPExpressionString(value string, start int) int { + quote, ok := otlpStringByteAt(value, start) + if !ok { + return len(value) + } + for index := start + 1; index < len(value); index++ { + current, ok := otlpStringByteAt(value, index) + if !ok { + return len(value) + } + if current == '\\' { + index++ + continue + } + if current == quote { + next, ok := otlpStringByteAt(value, index+1) + if ok && next == quote { + index++ + continue } - attributes[name] = resolved + return index + 1 } } - return nil + return len(value) +} + +func otlpStringByteAt(value string, index int) (byte, bool) { + if index < 0 || index >= len(value) { + return 0, false + } + return value[index], true +} + +func isOTLPIdentifierCharacter(char byte) bool { + return char == '_' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' } func normalizeOTLPAttributeMap(otlp map[string]any, field string) map[string]any { diff --git a/pkg/workflow/observability_metadata_test.go b/pkg/workflow/observability_metadata_test.go index 760359b2193..f7d6dd5237f 100644 --- a/pkg/workflow/observability_metadata_test.go +++ b/pkg/workflow/observability_metadata_test.go @@ -82,6 +82,62 @@ func TestResolveOTLPMetadataReferences(t *testing.T) { }, wantErr: "observability.otlp.attributes.catalogue.version must be a whole-value reference", }, + { + name: "preserves literal and quoted namespace mentions", + metadata: map[string]any{"version": "1.0"}, + attributes: map[string]any{ + "literal": "github.aw.metadata.version", + "quoted": "${{ 'github.aw.metadata.version' }}", + }, + resource: map[string]any{ + "literal": "github.aw.metadata.version", + "quoted": `${{ "github.aw.metadata.version" }}`, + }, + want: map[string]any{ + "literal": "github.aw.metadata.version", + "quoted": "${{ 'github.aw.metadata.version' }}", + }, + wantResource: map[string]any{ + "literal": "github.aw.metadata.version", + "quoted": `${{ "github.aw.metadata.version" }}`, + }, + }, + { + name: "rejects bracket-selected metadata in attributes", + metadata: map[string]any{"version": "1.0"}, + attributes: map[string]any{ + "catalogue.version": "${{ github.aw['metadata'].version }}", + }, + wantErr: "observability.otlp.attributes.catalogue.version must be a whole-value reference", + }, + { + name: "rejects bracket-selected metadata in resource attributes", + metadata: map[string]any{"version": "1.0"}, + resource: map[string]any{ + "catalogue.version": `${{ github.aw["metadata"].version }}`, + }, + wantErr: "observability.otlp.resource-attributes.catalogue.version must be a whole-value reference", + }, + { + name: "rejects nested metadata access in expressions", + metadata: map[string]any{"version": "1.0"}, + attributes: map[string]any{ + "catalogue.version": "${{ contains(github.aw.metadata.version, 'x') }}", + }, + resource: map[string]any{ + "catalogue.version": "${{ contains(github.aw.metadata.version, 'x') }}", + }, + wantErr: "observability.otlp.attributes.catalogue.version must be a whole-value reference", + }, + { + name: "reports invalid attributes deterministically", + metadata: map[string]any{}, + attributes: map[string]any{ + "zeta": "${{ github.aw['metadata'].version }}", + "alpha": "${{ github.aw.metadata.missing }}", + }, + wantErr: `observability.otlp.attributes.alpha references missing metadata key "missing"`, + }, { name: "rejects expression-looking metadata", metadata: map[string]any{"version": "${{ github.actor }}"}, @@ -122,6 +178,29 @@ func TestResolveOTLPMetadataReferences(t *testing.T) { } } +func TestResolveOTLPMetadataReferencesNormalizesMergedStringMaps(t *testing.T) { + workflowData := &WorkflowData{RawFrontmatter: map[string]any{ + "metadata": map[string]any{"version": "1.0", "category": "automation"}, + "observability": map[string]any{ + "otlp": map[string]any{ + "attributes": map[string]string{ + "catalogue.version": "${{ github.aw.metadata.version }}", + }, + "resource-attributes": map[string]string{ + "catalogue.category": "${{ github.aw.metadata.category }}", + }, + }, + }, + }} + + err := resolveOTLPMetadataReferences(workflowData) + + require.NoError(t, err) + otlp := workflowData.RawFrontmatter["observability"].(map[string]any)["otlp"].(map[string]any) + assert.Equal(t, map[string]any{"catalogue.version": "1.0"}, otlp["attributes"]) + assert.Equal(t, map[string]any{"catalogue.category": "automation"}, otlp["resource-attributes"]) +} + func TestResolveOTLPMetadataReferencesRejectsUnsafeResourceAttribute(t *testing.T) { workflowData := &WorkflowData{RawFrontmatter: map[string]any{ "metadata": map[string]any{"category": "${{ secrets.TOKEN }}"}, diff --git a/schemas/logs.schema.json b/schemas/logs.schema.json index 2e048b3e312..40e271e767c 100644 --- a/schemas/logs.schema.json +++ b/schemas/logs.schema.json @@ -77,6 +77,21 @@ "audit_path": { "type": "string" }, + "branch": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "attempt": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, "cluster_ids": { "type": [ "null", @@ -273,6 +288,21 @@ }, "audit_path": { "type": "string" + }, + "branch": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "attempt": { + "type": "string" + }, + "updated_at": { + "type": "string" } }, "required": [ @@ -6313,6 +6343,21 @@ "audit_path": { "type": "string" }, + "branch": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "attempt": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, "cluster_ids": { "type": [ "null", @@ -6509,6 +6554,21 @@ }, "audit_path": { "type": "string" + }, + "branch": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "attempt": { + "type": "string" + }, + "updated_at": { + "type": "string" } }, "required": [