Skip to content

Commit be60e8f

Browse files
fix: safely backfill legacy signed npm releases (#166)
1 parent eeac73b commit be60e8f

3 files changed

Lines changed: 197 additions & 5 deletions

File tree

.github/workflows/node-github-release.yml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,6 @@ jobs:
225225
--registry=https://registry.npmjs.org/
226226
)"
227227
228-
printf '%s\n' "$metadata" |
229-
node sdk/typescript/scripts/release-automation.mjs \
230-
verify-publication "$archive" "$RELEASE_VERSION" "$RELEASE_SHA"
231-
232228
consumer="$(mktemp -d)"
233229
trap 'rm -rf "$consumer"' EXIT
234230
npm install \
@@ -257,6 +253,7 @@ jobs:
257253
"$GITHUB_REPOSITORY" \
258254
"$RELEASE_RUN_ID" 2>/dev/null
259255
)"; then
256+
verified_run_id="$RELEASE_RUN_ID"
260257
printf '%s\n' "$verified_provenance"
261258
echo "Verified npm provenance for the resolved release run."
262259
else
@@ -305,10 +302,21 @@ jobs:
305302
exit 1
306303
fi
307304
305+
verified_run_id="$original_run_id"
308306
printf '%s\n' "$verified_provenance"
309307
echo "Verified protected recovery provenance from its original signing run."
310308
fi
311309
310+
printf '%s\n' "$metadata" |
311+
CODEX_SECURITY_VERIFIED_PROVENANCE="$verified_provenance" \
312+
node sdk/typescript/scripts/release-automation.mjs \
313+
verify-github-publication \
314+
"$archive" \
315+
"$RELEASE_VERSION" \
316+
"$RELEASE_SHA" \
317+
"$GITHUB_REPOSITORY" \
318+
"$verified_run_id"
319+
312320
printf 'archive=%s\n' "$archive" >> "$GITHUB_OUTPUT"
313321
314322
- name: Resolve published release history

sdk/typescript/scripts/release-automation.mjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,36 @@ export function verifyPublishedRelease(metadata, archive, expected) {
437437
};
438438
}
439439

440+
export function verifyGitHubPublishedRelease(
441+
metadata,
442+
archive,
443+
expected,
444+
provenance,
445+
) {
446+
const version = releaseVersion(metadata);
447+
const sha512 = createHash("sha512").update(archive).digest("hex");
448+
if (
449+
provenance?.version !== version ||
450+
provenance.gitHead !== expected.gitHead ||
451+
provenance.repository !== expected.repository ||
452+
provenance.runId !== String(expected.runId) ||
453+
provenance.sha512 !== sha512
454+
) {
455+
throw new Error(
456+
"Verified signed npm provenance must match the GitHub release.",
457+
);
458+
}
459+
460+
if (metadata.gitHead === undefined) {
461+
if (version !== "0.1.0" && version !== "0.1.1") {
462+
throw new Error("Only npm releases 0.1.0 and 0.1.1 may omit gitHead.");
463+
}
464+
metadata = { ...metadata, gitHead: provenance.gitHead };
465+
}
466+
467+
return verifyPublishedRelease(metadata, archive, expected);
468+
}
469+
440470
export function verifySignatureAudit(report, archive, expected) {
441471
if (
442472
!Array.isArray(report?.invalid) ||
@@ -793,6 +823,27 @@ function main() {
793823
return;
794824
}
795825

826+
if (command === "verify-github-publication" && process.argv.length === 8) {
827+
const metadata = JSON.parse(readFileSync(0, "utf8"));
828+
const archive = readFileSync(process.argv[3]);
829+
const provenance = JSON.parse(
830+
process.env.CODEX_SECURITY_VERIFIED_PROVENANCE ?? "null",
831+
);
832+
const verified = verifyGitHubPublishedRelease(
833+
metadata,
834+
archive,
835+
{
836+
version: process.argv[4],
837+
gitHead: process.argv[5],
838+
repository: process.argv[6],
839+
runId: process.argv[7],
840+
},
841+
provenance,
842+
);
843+
console.log(JSON.stringify(verified));
844+
return;
845+
}
846+
796847
if (command === "verify-provenance" && process.argv.length === 8) {
797848
const report = JSON.parse(readFileSync(0, "utf8"));
798849
const archive = readFileSync(process.argv[3]);
@@ -848,6 +899,9 @@ function main() {
848899
"release-history <tag>, " +
849900
"verify-publication <archive> <version> <git-head> " +
850901
"(package metadata JSON from stdin), " +
902+
"verify-github-publication <archive> <version> <git-head> " +
903+
"<repository> <run-id> (package metadata JSON from stdin and " +
904+
"verified provenance from CODEX_SECURITY_VERIFIED_PROVENANCE), " +
851905
"verify-provenance <archive> <version> <git-head> <repository> <run-id> " +
852906
"(signature audit JSON from stdin), " +
853907
"verify-recovered-provenance <archive> <version> <git-head> " +

sdk/typescript/tests-ts/release-automation.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,28 @@ type ReleaseAutomation = {
5454
integrity: string;
5555
sha256: string;
5656
};
57+
verifyGitHubPublishedRelease: (
58+
metadata: ReleaseMetadata,
59+
archive: Uint8Array,
60+
expected: {
61+
version: string;
62+
gitHead: string;
63+
repository: string;
64+
runId: string;
65+
},
66+
provenance: {
67+
version: string;
68+
gitHead: string;
69+
repository: string;
70+
runId: string;
71+
sha512: string;
72+
},
73+
) => {
74+
version: string;
75+
gitHead: string;
76+
integrity: string;
77+
sha256: string;
78+
};
5779
verifySignatureAudit: (
5880
report: ReleaseMetadata,
5981
archive: Uint8Array,
@@ -108,6 +130,7 @@ const {
108130
requirePublishedReleaseIncrease,
109131
releaseHistory,
110132
verifyPublishedRelease,
133+
verifyGitHubPublishedRelease,
111134
verifySignatureAudit,
112135
verifyRecoveredSignatureAudit,
113136
verifyGitHubRelease,
@@ -744,6 +767,111 @@ describe("published npm release verification", () => {
744767
});
745768
});
746769

770+
describe("GitHub release publication verification", () => {
771+
const expected = {
772+
version: "0.1.2",
773+
gitHead: releaseCommit,
774+
repository: releaseRepository,
775+
runId: releaseRun,
776+
};
777+
const provenance = {
778+
...expected,
779+
sha512,
780+
};
781+
782+
test("retains strict source and artifact verification for current releases", () => {
783+
expect(
784+
verifyGitHubPublishedRelease(
785+
publishedMetadata(),
786+
archive,
787+
expected,
788+
provenance,
789+
),
790+
).toEqual({
791+
version: "0.1.2",
792+
gitHead: releaseCommit,
793+
integrity,
794+
sha256: digest.slice("sha256:".length),
795+
});
796+
});
797+
798+
test.each(["0.1.0", "0.1.1"])(
799+
"recovers the missing gitHead only from verified provenance for %s",
800+
(version) => {
801+
const metadata: ReleaseMetadata = { ...publishedMetadata(), version };
802+
delete metadata["gitHead"];
803+
804+
expect(
805+
verifyGitHubPublishedRelease(
806+
metadata,
807+
archive,
808+
{ ...expected, version },
809+
{ ...provenance, version },
810+
),
811+
).toEqual({
812+
version,
813+
gitHead: releaseCommit,
814+
integrity,
815+
sha256: digest.slice("sha256:".length),
816+
});
817+
818+
expect(() =>
819+
verifyPublishedRelease(metadata, archive, { ...expected, version }),
820+
).toThrow("npm package gitHead must match release commit");
821+
},
822+
);
823+
824+
test("rejects a missing gitHead on all later npm releases", () => {
825+
const metadata: ReleaseMetadata = { ...publishedMetadata() };
826+
delete metadata["gitHead"];
827+
828+
expect(() =>
829+
verifyGitHubPublishedRelease(metadata, archive, expected, provenance),
830+
).toThrow("Only npm releases 0.1.0 and 0.1.1 may omit gitHead.");
831+
});
832+
833+
test("rejects a mismatched historical npm gitHead", () => {
834+
expect(() =>
835+
verifyGitHubPublishedRelease(
836+
{
837+
...publishedMetadata(),
838+
version: "0.1.0",
839+
gitHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
840+
},
841+
archive,
842+
{ ...expected, version: "0.1.0" },
843+
{ ...provenance, version: "0.1.0" },
844+
),
845+
).toThrow("npm package gitHead must match release commit");
846+
});
847+
848+
test.each([
849+
{ version: "0.1.1" },
850+
{ gitHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
851+
{ repository: "different/codex-security" },
852+
{ runId: "30481596228" },
853+
{ sha512: "0".repeat(128) },
854+
])("rejects signed provenance that does not match %j", (mismatch) => {
855+
expect(() =>
856+
verifyGitHubPublishedRelease(publishedMetadata(), archive, expected, {
857+
...provenance,
858+
...mismatch,
859+
}),
860+
).toThrow("Verified signed npm provenance must match the GitHub release.");
861+
});
862+
863+
test("rejects missing verified provenance", () => {
864+
expect(() =>
865+
verifyGitHubPublishedRelease(
866+
publishedMetadata(),
867+
archive,
868+
expected,
869+
undefined as unknown as typeof provenance,
870+
),
871+
).toThrow("Verified signed npm provenance must match the GitHub release.");
872+
});
873+
});
874+
747875
describe("cryptographically verified npm provenance", () => {
748876
test("binds the verified bundle to the exact archive, source, and run", () => {
749877
expect(
@@ -2129,7 +2257,9 @@ describe("GitHub release workflow safeguards", () => {
21292257
' if [[ "${1:-}" == "sdk/typescript/scripts/release-automation.mjs" ]]; then',
21302258
" cat >/dev/null",
21312259
' case "$2" in',
2132-
" verify-publication)",
2260+
" verify-github-publication)",
2261+
' if [[ -z "${CODEX_SECURITY_VERIFIED_PROVENANCE:-}" ||',
2262+
' "$6" != "$GITHUB_REPOSITORY" ]]; then return 69; fi',
21332263
" printf '%s\\n' 'verified published artifact'",
21342264
" ;;",
21352265
" verify-provenance)",

0 commit comments

Comments
 (0)