diff --git a/.github/actions/check-shai-hulud/action.yml b/.github/actions/check-shai-hulud/action.yml new file mode 100644 index 0000000000..821d3324c6 --- /dev/null +++ b/.github/actions/check-shai-hulud/action.yml @@ -0,0 +1,64 @@ +--- +name: check-shai-hulud +description: | + Scans a directory for the known shai-hulud npm supply-chain attack + payloads using SHA-1 and SHA-256 hashes published by Semgrep and Wiz. + + Use after `npm ci` (or any flow that restores npm dependencies into + node_modules/) and BEFORE exposing any registry credentials, so a + poisoned dependency cannot run with publish privileges. See + elastic/docs-eng-team#518 for the threat model. + +inputs: + path: + description: Directory to scan recursively. Defaults to current dir. + required: false + default: '.' + +runs: + using: composite + steps: + - name: Check shai-hulud attack hashes + shell: bash + env: + SCAN_PATH: ${{ inputs.path }} + # language=bash + run: | + set -euo pipefail + + if [ ! -d "$SCAN_PATH" ]; then + echo "::error::shai-hulud scan path does not exist: ${SCAN_PATH}" + exit 1 + fi + + # shai-hulud v1 + # https://semgrep.dev/blog/2025/security-advisory-npm-packages-using-secret-scanning-tools-to-steal-credentials/ + SHAI_HULUD_V1_SHA256="46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09" + # shai-hulud v2 + # https://www.wiz.io/blog/shai-hulud-2-0-ongoing-supply-chain-attack#malware-hashes-65 + SHAI_HULUD_V2_SHA1="d1829b4708126dcc7bea7437c04d1f10eacd4a16" + + # Fail-open `|| true` on the find/grep pipeline so that an empty + # match doesn't trip `set -e`. The explicit non-empty test below + # is what decides pass/fail. + v1_matches=$( + find "$SCAN_PATH" -type f -name "*.js" -exec sha256sum {} \; \ + | grep "$SHAI_HULUD_V1_SHA256" || true + ) + if [ -n "$v1_matches" ]; then + echo "::error::shai-hulud v1 (vulnerable serialize-javascript) detected:" + echo "$v1_matches" | awk '{print $2}' + exit 1 + fi + + v2_matches=$( + find "$SCAN_PATH" -type f -name "*.js" -exec sha1sum {} \; \ + | grep "$SHAI_HULUD_V2_SHA1" || true + ) + if [ -n "$v2_matches" ]; then + echo "::error::shai-hulud v2 (vulnerable serialize-javascript) detected:" + echo "$v2_matches" | awk '{print $2}' + exit 1 + fi + + echo "shai-hulud check passed (scanned ${SCAN_PATH})" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db20400825..c60d4dbc05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,25 +83,12 @@ jobs: - name: Install dependencies run: npm ci - + - name: Check shai-hulud attack - run: | - # shai hulud v1 - # https://semgrep.dev/blog/2025/security-advisory-npm-packages-using-secret-scanning-tools-to-steal-credentials/ - if find . -type f -name "*.js" -exec sha256sum {} \; | grep -q "46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09"; then - echo "Vulnerable version of serialize-javascript found in:" - find . -type f -name "*.js" -exec sha256sum {} \; | grep "46faab8ab153fae6e80e7cca38eab363075bb524edd79e42269217a083628f09" | awk '{print $2}' - exit 1 - fi - # shai hulud v2 - # https://www.wiz.io/blog/shai-hulud-2-0-ongoing-supply-chain-attack#malware-hashes-65 - if find . -type f -name "*.js" -exec sha1sum {} \; | grep -q "d1829b4708126dcc7bea7437c04d1f10eacd4a16"; then - echo "Vulnerable version of serialize-javascript found in:" - find . -type f -name "*.js" -exec sha1sum {} \; | grep "d1829b4708126dcc7bea7437c04d1f10eacd4a16" | awk '{print $2}' - exit 1 - fi - - + uses: ./.github/actions/check-shai-hulud + with: + path: src/Elastic.Documentation.Site + - name: Lint run: npm run lint diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 39fe134ec2..320de076b4 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -47,6 +47,12 @@ jobs: build: runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # Required for `actions/attest-build-provenance` + id-token: write + attestations: write outputs: full-version: ${{ steps.bootstrap.outputs.full-version }} major-version: ${{ steps.bootstrap.outputs.major-version }} @@ -57,7 +63,17 @@ jobs: - name: Bootstrap Action Workspace id: bootstrap uses: ./.github/actions/bootstrap - + + # Restore npm dependencies explicitly for supply-chain checks + - name: Restore npm dependencies + working-directory: src/Elastic.Documentation.Site + run: npm ci + + - name: Check shai-hulud supply-chain hashes + uses: ./.github/actions/check-shai-hulud + with: + path: src/Elastic.Documentation.Site + - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: @@ -65,6 +81,48 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Publish Containers run: ./build.sh publishcontainers + + # Resolve the manifest digest of each pushed image so we can + # mint a signed attestation against the immutable digest, not + # the mutable :edge tag. Consumers verify with: + # gh attestation verify oci://ghcr.io/elastic/docs-builder@ \ + # -R elastic/docs-builder + - name: Resolve pushed image digests + id: digests + # language=bash + run: | + set -euo pipefail + for image in docs-builder docs-builder-mcp docs-builder-api; do + ref="ghcr.io/elastic/${image}:edge" + digest=$(docker buildx imagetools inspect "$ref" 2>/dev/null \ + | awk '/^Digest:/ {print $2; exit}') + if [ -z "$digest" ]; then + echo "::error::Could not resolve manifest digest for ${ref}" + exit 1 + fi + echo "${image}=${digest}" >> "$GITHUB_OUTPUT" + echo "Resolved ${ref} -> ${digest}" + done + + - name: Attest docs-builder image + uses: actions/attest-build-provenance@v4 + with: + subject-name: ghcr.io/elastic/docs-builder + subject-digest: ${{ steps.digests.outputs.docs-builder }} + push-to-registry: true + + - name: Attest docs-builder-mcp image + uses: actions/attest-build-provenance@v4 + with: + subject-name: ghcr.io/elastic/docs-builder-mcp + subject-digest: ${{ steps.digests.outputs.docs-builder-mcp }} + push-to-registry: true + + - name: Attest docs-builder-api image + uses: actions/attest-build-provenance@v4 + with: + subject-name: ghcr.io/elastic/docs-builder-api + subject-digest: ${{ steps.digests.outputs.docs-builder-api }} + push-to-registry: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1774461603..1dd0794c7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,12 @@ jobs: needs: - release-drafter runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # Required for `actions/attest-build-provenance` + id-token: write + attestations: write outputs: full-version: ${{ steps.bootstrap.outputs.full-version }} major-version: ${{ steps.bootstrap.outputs.major-version }} @@ -58,7 +64,16 @@ jobs: - name: Bootstrap Action Workspace id: bootstrap uses: ./.github/actions/bootstrap - + + - name: Restore npm dependencies + working-directory: src/Elastic.Documentation.Site + run: npm ci + + - name: Check shai-hulud supply-chain hashes + uses: ./.github/actions/check-shai-hulud + with: + path: src/Elastic.Documentation.Site + - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: @@ -68,6 +83,53 @@ jobs: - name: Publish Containers run: ./build.sh publishcontainers + + # Resolve the manifest digest of each pushed image so we can + # mint a signed attestation against the immutable digest. The + # release version tag (e.g., :v1.4.0) is the canonical reference + # but we attest against the digest because tags can be moved. + # Consumers verify with: + # gh attestation verify oci://ghcr.io/elastic/docs-builder@ \ + # -R elastic/docs-builder + - name: Resolve pushed image digests + id: digests + env: + TAG_NAME: ${{ needs.release-drafter.outputs.tag_name }} + # language=bash + run: | + set -euo pipefail + for image in docs-builder docs-builder-mcp docs-builder-api; do + ref="ghcr.io/elastic/${image}:${TAG_NAME}" + digest=$(docker buildx imagetools inspect "$ref" 2>/dev/null \ + | awk '/^Digest:/ {print $2; exit}') + if [ -z "$digest" ]; then + echo "::error::Could not resolve manifest digest for ${ref}" + exit 1 + fi + echo "${image}=${digest}" >> "$GITHUB_OUTPUT" + echo "Resolved ${ref} -> ${digest}" + done + + - name: Attest docs-builder image + uses: actions/attest-build-provenance@v4 + with: + subject-name: ghcr.io/elastic/docs-builder + subject-digest: ${{ steps.digests.outputs.docs-builder }} + push-to-registry: true + + - name: Attest docs-builder-mcp image + uses: actions/attest-build-provenance@v4 + with: + subject-name: ghcr.io/elastic/docs-builder-mcp + subject-digest: ${{ steps.digests.outputs.docs-builder-mcp }} + push-to-registry: true + + - name: Attest docs-builder-api image + uses: actions/attest-build-provenance@v4 + with: + subject-name: ghcr.io/elastic/docs-builder-api + subject-digest: ${{ steps.digests.outputs.docs-builder-api }} + push-to-registry: true build-link-index-lambda: needs: diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs index 0f57cb0032..012fee7fd8 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -8,6 +8,7 @@ using Actions.Core.Services; using Elastic.Changelog.Creation; using Elastic.Changelog.GitHub; +using Elastic.Changelog.Utilities; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; @@ -89,18 +90,22 @@ public async Task EvaluateArtifact(IDiagnosticsCollector collector, Evalua var shouldCommentSuccess = statusParsed && metadataStatus == PrEvaluationResult.Success && !metadata.CanCommit; var shouldCommentFailure = statusParsed && metadataStatus == PrEvaluationResult.NoLabel; + // All artifact-derived outputs flow through OutputSanitizer to strip + // control characters and enforce per-field length caps. metadata.json + // is produced upstream from PR-author input, so each text field is + // treated as untrusted at this boundary. See elastic/docs-eng-team#491. await coreService.SetOutputAsync("pr-number", metadata.PrNumber.ToString(CultureInfo.InvariantCulture)); - await coreService.SetOutputAsync("head-ref", metadata.HeadRef); - await coreService.SetOutputAsync("head-sha", metadata.HeadSha); - await coreService.SetOutputAsync("status", metadata.Status); + await coreService.SetOutputAsync("head-ref", OutputSanitizer.SanitizeForOutput(metadata.HeadRef, OutputSanitizer.PathMaxLength)); + await coreService.SetOutputAsync("head-sha", OutputSanitizer.SanitizeForOutput(metadata.HeadSha, OutputSanitizer.PathMaxLength)); + await coreService.SetOutputAsync("status", OutputSanitizer.SanitizeForOutput(metadata.Status, OutputSanitizer.TypeMaxLength)); await coreService.SetOutputAsync("is-fork", metadata.IsFork ? "true" : "false"); - await coreService.SetOutputAsync("head-repo", metadata.HeadRepo ?? string.Empty); - await coreService.SetOutputAsync("config-file", metadata.ConfigFile ?? string.Empty); - await coreService.SetOutputAsync("changelog-dir", metadata.ChangelogDir ?? string.Empty); - await coreService.SetOutputAsync("changelog-filename", metadata.ChangelogFilename ?? string.Empty); - await coreService.SetOutputAsync("label-table", metadata.LabelTable ?? string.Empty); - await coreService.SetOutputAsync("product-label-table", metadata.ProductLabelTable ?? string.Empty); - await coreService.SetOutputAsync("skip-labels", metadata.SkipLabels ?? string.Empty); + await coreService.SetOutputAsync("head-repo", OutputSanitizer.SanitizeForOutput(metadata.HeadRepo, OutputSanitizer.PathMaxLength)); + await coreService.SetOutputAsync("config-file", OutputSanitizer.SanitizeForOutput(metadata.ConfigFile, OutputSanitizer.PathMaxLength)); + await coreService.SetOutputAsync("changelog-dir", OutputSanitizer.SanitizeForOutput(metadata.ChangelogDir, OutputSanitizer.PathMaxLength)); + await coreService.SetOutputAsync("changelog-filename", OutputSanitizer.SanitizeForOutput(metadata.ChangelogFilename, OutputSanitizer.PathMaxLength)); + await coreService.SetOutputAsync("label-table", OutputSanitizer.SanitizeForOutput(metadata.LabelTable, OutputSanitizer.LabelTableMaxLength)); + await coreService.SetOutputAsync("product-label-table", OutputSanitizer.SanitizeForOutput(metadata.ProductLabelTable, OutputSanitizer.LabelTableMaxLength)); + await coreService.SetOutputAsync("skip-labels", OutputSanitizer.SanitizeForOutput(metadata.SkipLabels, OutputSanitizer.LabelsMaxLength)); await coreService.SetOutputAsync("should-commit", shouldCommit ? "true" : "false"); await coreService.SetOutputAsync("should-comment-success", shouldCommentSuccess ? "true" : "false"); await coreService.SetOutputAsync("should-comment-failure", shouldCommentFailure ? "true" : "false"); diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index a6edeb8368..680b491b11 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -8,6 +8,7 @@ using Elastic.Changelog.Configuration; using Elastic.Changelog.Creation; using Elastic.Changelog.GitHub; +using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Diagnostics; @@ -173,24 +174,27 @@ private async Task SetOutputs( await coreService.SetOutputAsync("status", statusString); await coreService.SetOutputAsync("should-generate", shouldGenerate ? "true" : "false"); + // All PR-derived outputs flow through OutputSanitizer to strip + // control characters and enforce per-field length caps before they + // cross the GITHUB_OUTPUT boundary. See elastic/docs-eng-team#491. if (resolvedTitle != null) - await coreService.SetOutputAsync("title", resolvedTitle); + await coreService.SetOutputAsync("title", OutputSanitizer.SanitizeForOutput(resolvedTitle, OutputSanitizer.TitleMaxLength)); if (resolvedDescription != null) - await coreService.SetOutputAsync("description", resolvedDescription); + await coreService.SetOutputAsync("description", OutputSanitizer.SanitizeForOutput(resolvedDescription, OutputSanitizer.DescriptionMaxLength)); if (resolvedType != null) - await coreService.SetOutputAsync("type", resolvedType); + await coreService.SetOutputAsync("type", OutputSanitizer.SanitizeForOutput(resolvedType, OutputSanitizer.TypeMaxLength)); if (resolvedProducts != null) - await coreService.SetOutputAsync("products", resolvedProducts); + await coreService.SetOutputAsync("products", OutputSanitizer.SanitizeForOutput(resolvedProducts, OutputSanitizer.LabelsMaxLength)); if (labelTable != null) - await coreService.SetOutputAsync("label-table", labelTable); + await coreService.SetOutputAsync("label-table", OutputSanitizer.SanitizeForOutput(labelTable, OutputSanitizer.LabelTableMaxLength)); if (productLabelTable != null) - await coreService.SetOutputAsync("product-label-table", productLabelTable); + await coreService.SetOutputAsync("product-label-table", OutputSanitizer.SanitizeForOutput(productLabelTable, OutputSanitizer.LabelTableMaxLength)); if (changelogDir != null) - await coreService.SetOutputAsync("changelog-dir", changelogDir); + await coreService.SetOutputAsync("changelog-dir", OutputSanitizer.SanitizeForOutput(changelogDir, OutputSanitizer.PathMaxLength)); if (existingFilename != null) - await coreService.SetOutputAsync("existing-changelog-filename", existingFilename); + await coreService.SetOutputAsync("existing-changelog-filename", OutputSanitizer.SanitizeForOutput(existingFilename, OutputSanitizer.PathMaxLength)); if (skipLabels != null) - await coreService.SetOutputAsync("skip-labels", skipLabels); + await coreService.SetOutputAsync("skip-labels", OutputSanitizer.SanitizeForOutput(skipLabels, OutputSanitizer.LabelsMaxLength)); return true; } diff --git a/src/services/Elastic.Changelog/Utilities/OutputSanitizer.cs b/src/services/Elastic.Changelog/Utilities/OutputSanitizer.cs new file mode 100644 index 0000000000..face95f42f --- /dev/null +++ b/src/services/Elastic.Changelog/Utilities/OutputSanitizer.cs @@ -0,0 +1,79 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text; + +namespace Elastic.Changelog.Utilities; + +/// +/// Defense-in-depth sanitizer for values written to GitHub Actions step +/// outputs (GITHUB_OUTPUT) when those values originate from +/// attacker-controlled PR metadata (title, body, labels, etc.). +/// +/// +/// +/// Actions.Core already protects the output framing layer with +/// random delimiters, so this sanitizer is *belt-and-braces*: it strips +/// control characters that could appear in error messages or be parsed +/// downstream, and caps length per field so a malicious PR cannot blow +/// past runner env-var budgets or downstream string buffers. +/// +/// +/// See elastic/docs-eng-team#491 +/// for the security review that motivates these caps. +/// +/// +public static class OutputSanitizer +{ + /// Cap for PR title outputs (matches the action-layer sanitizer). + public const int TitleMaxLength = 200; + + /// Cap for extracted release-note descriptions. + public const int DescriptionMaxLength = 4 * 1024; + + /// Cap for the changelog type identifier. + public const int TypeMaxLength = 128; + + /// Cap for comma-separated product or label lists. + public const int LabelsMaxLength = 4 * 1024; + + /// Cap for rendered Markdown label tables. + public const int LabelTableMaxLength = 8 * 1024; + + /// Cap for filesystem paths derived from repo layout. + public const int PathMaxLength = 1024; + + /// + /// Strips null bytes and C0/DEL control characters (preserving + /// \n and \t) and truncates the result to + /// characters. Returns + /// for or empty input. + /// + public static string SanitizeForOutput(string? value, int maxLength) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + if (maxLength <= 0) + return string.Empty; + + var capacity = Math.Min(value.Length, maxLength); + var builder = new StringBuilder(capacity); + + foreach (var c in value) + { + if (builder.Length >= maxLength) + break; + + // Strip C0 control characters (U+0000..U+001F) except \n and \t, + // plus DEL (U+007F). These are the characters most likely to + // confuse downstream shell, YAML, Markdown, and log parsers. + if (c is (< (char)0x20 and not '\n' and not '\t') or (char)0x7F) + continue; + + _ = builder.Append(c); + } + + return builder.ToString(); + } +} diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 2d87ef40b7..c0c5f9f30f 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -2,6 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Buffers; using System.ComponentModel.DataAnnotations; using System.IO.Abstractions; using System.Linq; @@ -1362,7 +1363,7 @@ public async Task EvaluatePr( IGitHubPrService prService = new GitHubPrService(logFactory); var service = new ChangelogPrEvaluationService(logFactory, configurationContext, prService, githubActionsService); - var prBody = environmentVariables.GetEnvironmentVariable("PR_BODY"); + var prBody = await ReadPrBodyFromEnvironmentAsync(ctx); var args = new EvaluatePrArguments { @@ -1518,6 +1519,45 @@ private static List ExpandCommaSeparated(string[]? values) return result; } + // PR_BODY can hit GitHub's 65,536-char limit and exceed runner env-var + // budgets when passed inline. PR_BODY_FILE lets callers stage the body + // in a file under RUNNER_TEMP and pass the path instead, which keeps + // the body off the env block entirely. Cap reads at 256 KiB to bound + // memory if a caller hands us a hostile path. + private const int MaxPrBodyFileBytes = 256 * 1024; + + private async Task ReadPrBodyFromEnvironmentAsync(CancellationToken ct) + { + var prBodyFile = environmentVariables.GetEnvironmentVariable("PR_BODY_FILE"); + if (string.IsNullOrWhiteSpace(prBodyFile)) + return environmentVariables.GetEnvironmentVariable("PR_BODY"); + + var info = _fileSystem.FileInfo.New(prBodyFile); + if (!info.Exists) + { + collector.EmitWarning(string.Empty, $"PR_BODY_FILE points to a missing file: {prBodyFile}"); + return null; + } + + if (info.Length <= MaxPrBodyFileBytes) + return await _fileSystem.File.ReadAllTextAsync(prBodyFile, ct); + + collector.EmitHint(string.Empty, $"PR_BODY_FILE exceeds {MaxPrBodyFileBytes} bytes ({info.Length}); truncating."); + + var buffer = ArrayPool.Shared.Rent(MaxPrBodyFileBytes); + try + { + await using var stream = info.OpenRead(); + var slice = buffer.AsMemory(0, MaxPrBodyFileBytes); + await stream.ReadExactlyAsync(slice, ct); + return Encoding.UTF8.GetString(slice.Span); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + private static string GetPathForConfig(string repoPath, string targetPath) { var relativePath = Path.GetRelativePath(repoPath, targetPath); diff --git a/tests/Elastic.Changelog.Tests/Utilities/OutputSanitizerTests.cs b/tests/Elastic.Changelog.Tests/Utilities/OutputSanitizerTests.cs new file mode 100644 index 0000000000..9add211c21 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Utilities/OutputSanitizerTests.cs @@ -0,0 +1,113 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using AwesomeAssertions; +using Elastic.Changelog.Utilities; + +namespace Elastic.Changelog.Tests.Utilities; + +public class OutputSanitizerTests +{ + [Fact] + public void NullInput_ReturnsEmpty() => + OutputSanitizer.SanitizeForOutput(null, 100).Should().Be(string.Empty); + + [Fact] + public void EmptyInput_ReturnsEmpty() => + OutputSanitizer.SanitizeForOutput(string.Empty, 100).Should().Be(string.Empty); + + [Fact] + public void ZeroMaxLength_ReturnsEmpty() => + OutputSanitizer.SanitizeForOutput("anything", 0).Should().Be(string.Empty); + + [Fact] + public void NegativeMaxLength_ReturnsEmpty() => + OutputSanitizer.SanitizeForOutput("anything", -1).Should().Be(string.Empty); + + [Fact] + public void PlainAscii_PassesThrough() => + OutputSanitizer.SanitizeForOutput("Add new search API", 100) + .Should().Be("Add new search API"); + + [Fact] + public void PreservesNewlinesAndTabs() + { + var input = "line1\nline2\twith tab\nline3"; + OutputSanitizer.SanitizeForOutput(input, 100).Should().Be(input); + } + + [Fact] + public void StripsNullBytes() => + OutputSanitizer.SanitizeForOutput("hello\0world", 100).Should().Be("helloworld"); + + [Fact] + public void StripsCarriageReturn() => + OutputSanitizer.SanitizeForOutput("line1\r\nline2", 100).Should().Be("line1\nline2"); + + [Theory] + [InlineData('\u0001')] + [InlineData('\u0007')] + [InlineData('\u001b')] + [InlineData('\u001f')] + [InlineData('\u007f')] + public void StripsC0AndDelControlCharacters(char control) + { + var input = $"safe{control}value"; + OutputSanitizer.SanitizeForOutput(input, 100).Should().Be("safevalue"); + } + + [Fact] + public void TruncatesAtMaxLength() + { + var input = new string('a', 250); + OutputSanitizer.SanitizeForOutput(input, 200).Should().HaveLength(200); + } + + [Fact] + public void TruncatesBeforeStrippedCharsAreCounted() + { + // Stripped characters do not count toward the cap; the result + // should contain exactly maxLength surviving characters. + var input = "\0\0abc\0def\0ghi"; + OutputSanitizer.SanitizeForOutput(input, 5).Should().Be("abcde"); + } + + [Fact] + public void TruncationIsCharacterBasedNotByteBased() + { + // Emoji are surrogate pairs (2 chars each in C#); the cap counts chars, + // not graphemes — that's the same behavior as String.Substring. + var input = "🚀🚀🚀🚀🚀"; + var result = OutputSanitizer.SanitizeForOutput(input, 4); + result.Should().HaveLength(4); + } + + [Fact] + public void GitHubOutputDelimiterMimic_HasControlCharsStripped() + { + // A hostile PR title that tries to inject a fake GITHUB_OUTPUT line. + // The C0 stripping leaves the visible text intact; the random-delimiter + // framing in Actions.Core handles the rest. + var input = "evil\u0000fake-output< folded marker")] + [InlineData("Title with newline\nthen colon: injected: true")] + [InlineData("title:\nmalicious: true")] + [InlineData("\\u202E right-to-left override")] + public void SerializeEntry_AdversarialTitle_RoundTripsWithoutInjection(string adversarialTitle) + { + var entry = new ChangelogEntry + { + Title = adversarialTitle, + Type = ChangelogEntryType.Feature, + Products = + [ + new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } + ] + }; + + var yaml = ReleaseNotesSerialization.SerializeEntry(entry); + var roundTrip = ReleaseNotesSerialization.DeserializeEntry(yaml); + + roundTrip.Title.Should().Be(adversarialTitle, + "adversarial titles must round-trip exactly without leaking into surrounding YAML structure"); + roundTrip.Type.Should().Be(ChangelogEntryType.Feature, + "adversarial title must not change unrelated fields"); + } + + [Fact] + public void SerializeEntry_DescriptionWithYamlBlockMarkers_RoundTrips() + { + var entry = new ChangelogEntry + { + Title = "Plain title", + Description = "First line\n---\nfake: document\n...\nclosing marker", + Type = ChangelogEntryType.Feature, + Products = + [ + new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } + ] + }; + + var yaml = ReleaseNotesSerialization.SerializeEntry(entry); + var roundTrip = ReleaseNotesSerialization.DeserializeEntry(yaml); + + roundTrip.Description.Should().Be("First line\n---\nfake: document\n...\nclosing marker"); + roundTrip.Title.Should().Be("Plain title"); + } + + [Fact] + public void SerializeEntry_InjectedFieldInTitle_DoesNotPolluteOtherFields() + { + // A hostile title that tries to make the deserializer believe extra + // fields exist at the entry level. + var entry = new ChangelogEntry + { + Title = "Legit\nimpact: attacker-set\naction: rm -rf /", + Type = ChangelogEntryType.Feature, + Products = + [ + new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } + ] + }; + + var yaml = ReleaseNotesSerialization.SerializeEntry(entry); + var roundTrip = ReleaseNotesSerialization.DeserializeEntry(yaml); + + roundTrip.Title.Should().Be("Legit\nimpact: attacker-set\naction: rm -rf /"); + roundTrip.Impact.Should().BeNull(); + roundTrip.Action.Should().BeNull(); + } }