From def7679228b0f6061f337a3d374711ebefc7be39 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 26 Apr 2026 19:23:01 +0200 Subject: [PATCH 1/2] ci: pin actions by hash and add signed release provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin TrigenSoftware/simple-release-action@v1 by commit hash in release.yml (resolves 3 Scorecard PinnedDependenciesID alerts) - Add SLSA build provenance attestation via actions/attest-build-provenance@v4 in release-binaries.yml - Upload .sigstore.json bundles as release assets alongside binaries (addresses Scorecard Signed-Releases check, 0 → 8) - Add fuzz tests for ADR and project-config schema parsing layers (parseFrontmatter, parseAdr, AdrFrontmatterSchema, DomainNameSchema, DomainPrefixSchema, ProjectConfigSchema) --- .github/workflows/release-binaries.yml | 26 ++- .github/workflows/release.yml | 6 +- tests/formats/adr-fuzz.test.ts | Bin 0 -> 8266 bytes tests/formats/project-config-fuzz.test.ts | 224 ++++++++++++++++++++++ 4 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 tests/formats/adr-fuzz.test.ts create mode 100644 tests/formats/project-config-fuzz.test.ts diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 41b84ddd..82e5d5ee 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -20,6 +20,8 @@ jobs: timeout-minutes: 15 permissions: contents: write + id-token: write + attestations: write strategy: fail-fast: false matrix: @@ -78,13 +80,32 @@ jobs: Remove-Item "archgate.exe" (Get-FileHash "${{ matrix.artifact }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.artifact }}.zip" | Out-File -Encoding ascii "${{ matrix.artifact }}.zip.sha256" + - name: Attest build provenance (Unix) + if: runner.os != 'Windows' + id: attest-unix + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4 + with: + subject-path: ${{ matrix.artifact }}.tar.gz + + - name: Attest build provenance (Windows) + if: runner.os == 'Windows' + id: attest-win + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4 + with: + subject-path: ${{ matrix.artifact }}.zip + - name: Upload release asset (Unix) if: runner.os != 'Windows' env: GH_TOKEN: ${{ github.token }} run: | TAG="${{ github.event.release.tag_name || inputs.tag }}" - gh release upload "$TAG" "${{ matrix.artifact }}.tar.gz" "${{ matrix.artifact }}.tar.gz.sha256" --clobber + cp "${{ steps.attest-unix.outputs.bundle-path }}" "${{ matrix.artifact }}.tar.gz.sigstore.json" + gh release upload "$TAG" \ + "${{ matrix.artifact }}.tar.gz" \ + "${{ matrix.artifact }}.tar.gz.sha256" \ + "${{ matrix.artifact }}.tar.gz.sigstore.json" \ + --clobber - name: Upload release asset (Windows) if: runner.os == 'Windows' @@ -93,4 +114,5 @@ jobs: GH_TOKEN: ${{ github.token }} run: | $tag = "${{ github.event.release.tag_name || inputs.tag }}" - gh release upload $tag "${{ matrix.artifact }}.zip" "${{ matrix.artifact }}.zip.sha256" --clobber + Copy-Item "${{ steps.attest-win.outputs.bundle-path }}" "${{ matrix.artifact }}.zip.sigstore.json" + gh release upload $tag "${{ matrix.artifact }}.zip" "${{ matrix.artifact }}.zip.sha256" "${{ matrix.artifact }}.zip.sigstore.json" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7165062f..8fdca477 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: run: bun install --frozen-lockfile - name: Context check id: check - uses: TrigenSoftware/simple-release-action@v1 + uses: TrigenSoftware/simple-release-action@a796225481a62b5b46b37d4481ef7fb114a6d399 # v1 with: workflow: check github-token: ${{ steps.generate_token.outputs.token }} @@ -85,7 +85,7 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - name: Create or update pull request - uses: TrigenSoftware/simple-release-action@v1 + uses: TrigenSoftware/simple-release-action@a796225481a62b5b46b37d4481ef7fb114a6d399 # v1 with: workflow: pull-request github-token: ${{ github.token }} @@ -151,7 +151,7 @@ jobs: - name: Ensure main is up-to-date before release run: git pull --rebase origin main - name: Release - uses: TrigenSoftware/simple-release-action@v1 + uses: TrigenSoftware/simple-release-action@a796225481a62b5b46b37d4481ef7fb114a6d399 # v1 with: workflow: release github-token: ${{ steps.generate_token.outputs.token }} diff --git a/tests/formats/adr-fuzz.test.ts b/tests/formats/adr-fuzz.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..26a2d5b0b4c57060ecaefec0cdca34a3f4f3b7d7 GIT binary patch literal 8266 zcmdT}OII7o6`o0U{EABtV@p^F0l^*%g9ih)72DVW&m;~aR;#;&+HQ4g`T>?@<;*!* zW}8KJS!X%PGC3y;@BJhE2l5y4-Ky#*0{mbWX&aF0aqq4B{OYzd82Uj(W3okP1x{Dg zNW2;fE2>c>!iXl+3;Y2Y-PmjLfU!0+=&^8=6w7fm~04vUMlaCr}tyC*ymn2{fZ^!~@cP~Y_Ig}`@R zVN>LjdF(qjnYQhCM`S`5$J=5kJX?6yh$@?VTlW_ljqBCRmTSiHJ?MCIf2-A^bu!GZ zWsBZX-+A%U9eDomhaimNho(0>j#J9$(yyS&9+giFW%a? z-EOzNcF>L}-TG|v(ZlCY_gY&Ip7D0rpzSs3;E=`>+MqY|6@5+L(kxw}dAdytRHF|4 zG2Q*$ZfooD_73lS`}em$z5VywzrFkE-9O&_^Zj4{)Q-nXcdvibn7se-&;R|`f4*Qd z_hQeA9N(h==I{qCFRBd8S55NbL01IT61n3X9%6gZ3`UL&i|J{mz-rT=TB^#;D7+#>lZmAb;i!h)C}26m}nn?YcX z>KrT;3=$#=dyb-hlmF6$1Az!~u5Toq4>EFEmb%(fJ;dT_{e|y%6~mZ%rfq* zOXSe{`Z_hz?*x}Q$H$GFLYA*fB`|A#n&<7yB48m;V&z6kBCks(r>z0Sh`}%#<&y`+vla1hC<4n2MSlLy=GKFq-R*4Ei4*lH9GaF0^Xmp9g3H5**~-{+ zEZ;6rQ(=@XITpc)T;Ds&lQNBZfOKU>m8C|bnjS#87af^F*F4HoRTDZaR@IKfoGT|D zl(R=NlzM1Ix#i;gJl%;M7s)dTny7C^5Yc}blIy$_w7I>l!>@LiW)i@`xn>TM(CmncfLCa|0 z*p)thf!r$L!0@yVIru4Na#rmsRI43y%4D^y-lZf{6?7uyVX48ZBx=t&Pt?_-`OP4J z{d2ibi=Cyw%>3A@vrCfgJ4VSU%P@-3ku-FEEl4dzL(QEVgRd?EH8QG`!=fb>PoT9Z zFiBAWO#msXxF~B#zSLO5pIiBiUd70@Ysv8nd`nU;tyU+Q#kDP1^qdsRl3m{(fmy-v zmXU~Z8KonE#N1djRF$pm;ZSW)PPM(cG1HlJWCceEUmpBhVA%E)*fso_@LTKY&fezJ z*5loMsA09yKo&~Sbj6TJIEQ|z7r%aO)HD>}BFwP5(y@J{DdZ%w0yFFrK+)VdBRtXS zB?f3Z(}tZqmFcF>C=?72rl=zc0?=?hCuF5y%#}u?Rt8A`3OTG;)sBqPN|qT}eX58i z#okNLKk*W8<4A`ko&a_+%oahwXxt2hzHtb94O_TWXkaci0j=;7m93cs>E#tstw;VH z(E@}vJxe_9Rd$$UCW;KP34Tv7mMGd_=#StB4s>1Wj-Y+Q2sq2)D@zsl%erq`xYUFN zW2Gg*8^$?|MPzU?Z#Q(Q#&?~OzxeVOzf1>VWiI&~R~jo%I;vh{gHBff$FNQ;(oF^s zGZW`Z(`YPZ57W&wHr7QXm>=vuxVKS2*TTYo`K{-j_pff;BbcR5mT zInDw$Qg*XmX-`;wVBb=uy_Vd-HsB)TiM#`G zdc-AcPd40v1suD2Q~H_G zO_RCQI9$Cz8)>VG4a-u?_EFkd5pzFI8gF8A#+E+de7%XnrNSkNfnvZjMD|Pozb5*XF F{{y7QUo8Lt literal 0 HcmV?d00001 diff --git a/tests/formats/project-config-fuzz.test.ts b/tests/formats/project-config-fuzz.test.ts new file mode 100644 index 00000000..ba04339d --- /dev/null +++ b/tests/formats/project-config-fuzz.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from "bun:test"; + +import { + DomainNameSchema, + DomainPrefixSchema, + ProjectConfigSchema, +} from "../../src/formats/project-config"; + +// --------------------------------------------------------------------------- +// Generators — hand-rolled to avoid adding a devDependency (ARCH-006) +// --------------------------------------------------------------------------- + +const ASCII = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const SPECIAL = "!@#$%^&*()_+-=[]{}|;':\",./<>?\\\n\r\t "; + +function randomInt(max: number): number { + return Math.floor(Math.random() * max); +} + +function randomString(maxLen: number, charset = ASCII + SPECIAL): string { + const len = randomInt(maxLen); + return Array.from( + { length: len }, + () => charset[randomInt(charset.length)] + ).join(""); +} + +// --------------------------------------------------------------------------- +// DomainNameSchema fuzz +// --------------------------------------------------------------------------- + +const ITERATIONS = 500; + +describe("DomainNameSchema fuzz", () => { + test(`validates ${ITERATIONS} random strings without crashing`, () => { + for (let i = 0; i < ITERATIONS; i++) { + const input = randomString(50); + const result = DomainNameSchema.safeParse(input); + expect(result).toHaveProperty("success"); + } + }); + + test("boundary cases for length constraints (min 2, max 32)", () => { + const cases = [ + "", // too short + "a", // 1 char — too short + "ab", // 2 chars — minimum + "a".repeat(32), // 32 chars — maximum + "a".repeat(33), // 33 chars — over limit + "a".repeat(1000), + ]; + for (const c of cases) { + const result = DomainNameSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); + + test("regex boundary cases for kebab-case", () => { + const cases = [ + "backend", // valid + "ml-ops", // valid + "a1", // valid — letter then digit + "1abc", // invalid — starts with digit + "-abc", // invalid — starts with hyphen + "abc-", // valid — ends with hyphen (regex allows it) + "ABC", // invalid — uppercase + "aB", // invalid — mixed case + "ab cd", // invalid — space + "ab_cd", // invalid — underscore + "ab.cd", // invalid — dot + "ab--cd", // valid — double hyphen + "a-b-c-d-e-f", // valid — many hyphens + "ñ", // invalid — non-ASCII + "a\n", // invalid — newline + ]; + for (const c of cases) { + const result = DomainNameSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); + + test("handles non-string types", () => { + const cases = [ + null, + undefined, + 0, + false, + true, + [], + {}, + NaN, + Infinity, + Symbol("test"), + () => {}, + ]; + for (const c of cases) { + const result = DomainNameSchema.safeParse(c); + expect(result).toHaveProperty("success"); + expect(result.success).toBe(false); + } + }); +}); + +// --------------------------------------------------------------------------- +// DomainPrefixSchema fuzz +// --------------------------------------------------------------------------- + +describe("DomainPrefixSchema fuzz", () => { + test(`validates ${ITERATIONS} random strings without crashing`, () => { + for (let i = 0; i < ITERATIONS; i++) { + const input = randomString(20); + const result = DomainPrefixSchema.safeParse(input); + expect(result).toHaveProperty("success"); + } + }); + + test("boundary cases for length constraints (min 2, max 10)", () => { + const cases = [ + "", + "A", // 1 char — too short + "AB", // 2 chars — minimum + "A".repeat(10), // 10 chars — maximum + "A".repeat(11), // 11 chars — over limit + "A".repeat(1000), + ]; + for (const c of cases) { + const result = DomainPrefixSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); + + test("regex boundary cases for uppercase pattern", () => { + const cases = [ + "GEN", // valid + "MLOPS", // valid + "ML_OPS", // valid — underscore allowed + "A1", // valid — letter then digit + "1ABC", // invalid — starts with digit + "_ABC", // invalid — starts with underscore + "abc", // invalid — lowercase + "Ab", // invalid — mixed case + "AB CD", // invalid — space + "AB-CD", // invalid — hyphen + ]; + for (const c of cases) { + const result = DomainPrefixSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); +}); + +// --------------------------------------------------------------------------- +// ProjectConfigSchema fuzz +// --------------------------------------------------------------------------- + +describe("ProjectConfigSchema fuzz", () => { + test(`validates ${ITERATIONS} random config objects without crashing`, () => { + for (let i = 0; i < ITERATIONS; i++) { + const domainCount = randomInt(10); + const domains: Record = {}; + + for (let j = 0; j < domainCount; j++) { + const key = + Math.random() > 0.5 + ? randomString(15, "abcdefghijklmnopqrstuvwxyz0123456789-") + : randomString(15); + const value = + Math.random() > 0.5 + ? randomString(8, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_") + : randomString(8); + domains[key] = value; + } + + const config: Record = { domains }; + + // Randomly add extra top-level keys + if (Math.random() > 0.7) { + config[randomString(10)] = randomString(20); + } + + const result = ProjectConfigSchema.safeParse(config); + expect(result).toHaveProperty("success"); + } + }); + + test("handles extreme domain counts", () => { + // Many domains + const domains: Record = {}; + for (let i = 0; i < 100; i++) { + domains[`domain${String.fromCodePoint(97 + (i % 26))}${i}`] = `D${i}`; + } + const result = ProjectConfigSchema.safeParse({ domains }); + expect(result).toHaveProperty("success"); + }); + + test("handles wrong shapes for domains field", () => { + const cases = [ + { domains: null }, + { domains: "not-an-object" }, + { domains: 42 }, + { domains: [] }, + { domains: true }, + { domains: { valid: 123 } }, // value is number, not string + { domains: { valid: null } }, // value is null + { domains: { valid: undefined } }, // value is undefined + { domains: { "": "" } }, // empty keys/values + ]; + for (const c of cases) { + const result = ProjectConfigSchema.safeParse(c); + expect(result).toHaveProperty("success"); + } + }); + + test("defaults correctly for missing or empty input", () => { + const cases = [undefined, {}, { domains: {} }]; + for (const c of cases) { + const result = ProjectConfigSchema.safeParse(c); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.domains).toEqual({}); + } + } + }); +}); From f336052c48ef64436c2172854ce05d833c52b61b Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 26 Apr 2026 19:27:35 +0200 Subject: [PATCH 2/2] ci: add one-off workflow to backfill release signatures Adds a workflow_dispatch workflow that iterates over all existing releases, downloads each binary artifact, signs it with cosign (keyless OIDC via Sigstore), and uploads the .sigstore.json bundle as a release asset. Skips artifacts that already have a signature. This backfills the Scorecard Signed-Releases check for releases published before the attestation step was added to release-binaries.yml. Run once after merge, safe to re-run (idempotent). --- .github/workflows/backfill-attestations.yml | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/backfill-attestations.yml diff --git a/.github/workflows/backfill-attestations.yml b/.github/workflows/backfill-attestations.yml new file mode 100644 index 00000000..0f637948 --- /dev/null +++ b/.github/workflows/backfill-attestations.yml @@ -0,0 +1,59 @@ +name: Backfill Release Signatures + +on: + workflow_dispatch: + +permissions: {} + +jobs: + sign: + name: Sign existing release artifacts + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + id-token: write + steps: + - name: Install cosign + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + + - name: Sign release artifacts + env: + GH_TOKEN: ${{ github.token }} + run: | + WORKDIR=$(mktemp -d) + REPO="${{ github.repository }}" + signed=0 + skipped=0 + + for tag in $(gh release list --repo "$REPO" --limit 30 --json tagName --jq '.[].tagName'); do + echo "=== $tag ===" + existing=$(gh release view "$tag" --repo "$REPO" --json assets --jq '.assets[].name') + + for asset in $(echo "$existing" | grep -E '\.(tar\.gz|zip)$'); do + bundle="${asset}.sigstore.json" + + # Skip if already signed + if echo "$existing" | grep -qF "$bundle"; then + echo " $asset — already signed, skipping" + skipped=$((skipped + 1)) + continue + fi + + echo " Signing $asset..." + gh release download "$tag" --repo "$REPO" --pattern "$asset" --dir "$WORKDIR" --clobber + + cosign sign-blob --yes \ + --bundle "$WORKDIR/$bundle" \ + "$WORKDIR/$asset" + + gh release upload "$tag" --repo "$REPO" "$WORKDIR/$bundle" --clobber + + rm -f "$WORKDIR/$asset" "$WORKDIR/$bundle" + signed=$((signed + 1)) + done + done + + rm -rf "$WORKDIR" + echo "" + echo "Done: $signed artifacts signed, $skipped already signed"