Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspellignore
Original file line number Diff line number Diff line change
Expand Up @@ -1345,3 +1345,4 @@ syft
repoints
korthout
uncheckpointed
anchore
56 changes: 56 additions & 0 deletions .github/scripts/release-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,34 @@ function findAsset(assets, name) {
return matches[0];
}

/** @param {Buffer} data @param {string} name */
function verifySpdxDocument(data, name) {
let document;
try {
document = JSON.parse(data.toString("utf8"));
} catch (error) {
throw new Error(`Release SBOM ${name} is not valid JSON`, { cause: error });
}
const creators = document?.creationInfo?.creators;
if (
typeof document !== "object" ||
document === null ||
!/^SPDX-2\.\d+$/.test(document.spdxVersion) ||
document.SPDXID !== "SPDXRef-DOCUMENT" ||
document.dataLicense !== "CC0-1.0" ||
typeof document.documentNamespace !== "string" ||
!document.documentNamespace.startsWith("https://") ||
typeof document.creationInfo?.created !== "string" ||
!Array.isArray(creators) ||
!creators.some((creator) => /^Tool: syft-/.test(creator)) ||
!Array.isArray(document.packages) ||
document.packages.length === 0 ||
!Array.isArray(document.relationships)
Comment on lines +78 to +90
) {
throw new Error(`Release SBOM ${name} is not a valid SPDX document`);
}
}

async function uploadAssetData(github, owner, repo, release, name, data) {
let response;
let assets;
Expand Down Expand Up @@ -268,6 +296,30 @@ async function reconcileCliAssets(
core.setOutput("verified_assets", String(names.length));
}

/** @param {any} github @param {any} core @param {string} owner @param {string} repo @param {any} release */
async function verifySbomAssets(github, core, owner, repo, release) {
const targetsFile = core.getInput("TARGETS_FILE", { required: true });
const targets = JSON.parse(await readFile(targetsFile, "utf8"));
const cliAssets = /** @type {{name: string}[]} */ (targets.cliAssets);
const names = cliAssets.map((asset) => `${asset.name}.sbom.json`).sort();
const assets = /** @type {{id: number, name: string}[]} */ (
await listAssets(github, owner, repo, release.id)
);
const actual = assets
.map((asset) => asset.name)
.filter((name) => name.endsWith(".sbom.json"))
.sort();
if (JSON.stringify(actual) !== JSON.stringify(names)) {
throw new Error("Release SBOM assets do not match the expected set");
}
for (const name of names) {
const asset = findAsset(assets, name);
const data = await downloadAsset(github, owner, repo, asset.id);
verifySpdxDocument(data, name);
}
core.setOutput("verified_sboms", String(names.length));
}

/** @param {{github: any, core: any}} options */
export default async function releaseAssets({ github, core }) {
const owner = core.getInput("OWNER", { required: true });
Expand All @@ -292,5 +344,9 @@ export default async function releaseAssets({ github, core }) {
await reconcileCliAssets(github, core, owner, repo, release, true);
return;
}
if (mode === "verify-sboms") {
await verifySbomAssets(github, core, owner, repo, release);
return;
}
throw new Error(`Unsupported release asset mode: ${mode}`);
}
99 changes: 99 additions & 0 deletions .github/scripts/release-assets_test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ function fixture({ draft = true, assets = [] } = {}) {
return { calls, core, github, inputs, outputs };
}

function spdxDocument(overrides = {}) {
return JSON.stringify({
spdxVersion: "SPDX-2.3",
SPDXID: "SPDXRef-DOCUMENT",
dataLicense: "CC0-1.0",
documentNamespace: "https://anchore.com/syft/file/rad-test",
creationInfo: {
created: "2026-08-28T00:00:00Z",
creators: ["Organization: Anchore, Inc", "Tool: syft-1.51.0"]
},
packages: [{ SPDXID: "SPDXRef-Package-radius", name: "radius" }],
relationships: [],
...overrides
});
}

test("downloads exact release assets", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-"));
try {
Expand Down Expand Up @@ -242,6 +258,89 @@ test("verifies release binaries against split checksums", async () => {
}
});

test("verifies the exact release SBOM set as SPDX JSON", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-"));
try {
const targets = path.join(root, "targets.json");
await writeFile(targets, '{"cliAssets":[{"name":"rad_linux_amd64"}]}');
const state = fixture({
assets: [
{
id: 1,
name: "rad_linux_amd64.sbom.json",
contents: spdxDocument()
}
]
});
Object.assign(state.inputs, {
OWNER: "radius-project",
REPO: "radius",
TAG: "v0.61.0",
MODE: "verify-sboms",
TARGETS_FILE: targets
});
await releaseAssets(state);
assert.equal(state.outputs.verified_sboms, "1");
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("rejects a malformed release SBOM", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-"));
try {
const targets = path.join(root, "targets.json");
await writeFile(targets, '{"cliAssets":[{"name":"rad_linux_amd64"}]}');
const state = fixture({
assets: [
{
id: 1,
name: "rad_linux_amd64.sbom.json",
contents: spdxDocument({ packages: [] })
}
]
});
Object.assign(state.inputs, {
OWNER: "radius-project",
REPO: "radius",
TAG: "v0.61.0",
MODE: "verify-sboms",
TARGETS_FILE: targets
});
await assert.rejects(() => releaseAssets(state), /valid SPDX document/);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("rejects an unexpected release SBOM asset", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-"));
try {
const targets = path.join(root, "targets.json");
await writeFile(targets, '{"cliAssets":[{"name":"rad_linux_amd64"}]}');
const state = fixture({
assets: [
{
id: 1,
name: "rad_linux_amd64.sbom.json",
contents: spdxDocument()
},
{ id: 2, name: "unexpected.sbom.json", contents: spdxDocument() }
]
});
Object.assign(state.inputs, {
OWNER: "radius-project",
REPO: "radius",
TAG: "v0.61.0",
MODE: "verify-sboms",
TARGETS_FILE: targets
});
await assert.rejects(() => releaseAssets(state), /expected set/);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("normalizes a native GoReleaser split checksum on a draft", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-"));
try {
Expand Down
73 changes: 70 additions & 3 deletions .github/scripts/release-oci-artifacts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ OUTPUT=""
CATEGORIES="production,non-go,test"
NAMES=""
VERIFY_ALIASES=false
VERIFY_SBOMS=false
PROMOTE_LATEST="${RELEASE_PROMOTE_LATEST:-true}"
SOURCE_SHA="${RELEASE_SOURCE_SHA:-}"
TEMP_DIR=""
Expand Down Expand Up @@ -149,7 +150,7 @@ Usage:
release-oci-artifacts.sh verify --version <version> \
[--channel <X.Y>] [--image-lock <images.json>] [--cli-lock <cli.json>] \
[--categories <category,...>] [--names <name,...>] \
[--source-sha <sha>] [--aliases]
[--source-sha <sha>] [--aliases] [--sboms]
release-oci-artifacts.sh assert-images-absent --registry <registry> \
--version <version> [--categories <category,...>] [--names <name,...>] \
[--source-sha <sha>]
Expand Down Expand Up @@ -214,6 +215,10 @@ parse_args() {
VERIFY_ALIASES=true
shift
;;
--sboms)
VERIFY_SBOMS=true
shift
;;
-h | --help)
usage
exit 0
Expand Down Expand Up @@ -660,6 +665,52 @@ verify_cli_alias() {
fi
}

is_production_image() {
local name="$1"

jq -e --arg name "${name}" '
any(.images[];
.name == $name
and .category == "production"
and .radiusBuild == true)
' "${TARGETS_FILE}" > /dev/null
}

verify_image_sboms() {
local immutable_reference="$1"
local platforms="$2"
local sboms

if ! sboms="$(retry_read "image SBOM lookup" \
docker buildx imagetools inspect \
--format '{{json .SBOM}}' "${immutable_reference}")"; then
fail "cannot inspect image SBOMs: ${immutable_reference}"
fi
if ! jq -e --argjson platforms "${platforms}" '
. as $sboms
| type == "object"
and all($platforms[];
. as $platform
| $sboms[$platform].SPDX as $document
| ($document | type == "object")
and ($document.spdxVersion
| type == "string" and test("^SPDX-2\\.[0-9]+$"))
and $document.SPDXID == "SPDXRef-DOCUMENT"
and $document.dataLicense == "CC0-1.0"
and ($document.documentNamespace
| type == "string" and startswith("https://"))
and ($document.creationInfo.created
| type == "string" and length > 0)
and any($document.creationInfo.creators[]?;
startswith("Tool: syft-"))
and ($document.packages | type == "array" and length > 0)
and ($document.relationships | type == "array")
Comment on lines +695 to +707
)
' <<< "${sboms}" > /dev/null; then
fail "image has missing or invalid SPDX SBOMs: ${immutable_reference}"
fi
}

image_aliases_match() {
local repository="$1"
local channel="$2"
Expand Down Expand Up @@ -802,6 +853,8 @@ verify_locks() {
local repository
local digest
local immutable_reference
local name
local platforms

require_command jq
validate_version
Expand All @@ -822,6 +875,9 @@ verify_locks() {
if [[ "${VERIFY_ALIASES}" == "true" && -z "${CHANNEL}" ]]; then
fail "channel is required when verifying aliases"
fi
if [[ "${VERIFY_SBOMS}" == "true" && -z "${IMAGE_LOCK}" ]]; then
fail "image lock is required when verifying SBOMs"
fi

if [[ -n "${IMAGE_LOCK}" ]]; then
require_command docker
Expand Down Expand Up @@ -855,14 +911,24 @@ verify_locks() {
fail "image lock source does not match the release source"
fi

while IFS=$'\t' read -r reference digest immutable_reference; do
while IFS=$'\t' read -r name reference digest immutable_reference \
platforms; do
if [[ "${reference}" != *":${VERSION}" ]]; then
fail "image lock has wrong version: ${reference}"
fi
if [[ "${immutable_reference}" != *"@${digest}" ]]; then
fail "image lock has an invalid immutable reference"
fi
verify_image_alias "${reference}" "${digest}"
if [[ "${VERIFY_SBOMS}" == "true" ]]; then
if is_production_image "${name}"; then
if ! jq -e 'type == "array" and length > 0' \
<<< "${platforms}" > /dev/null; then
fail "image lock has no platforms for ${name}"
fi
verify_image_sboms "${immutable_reference}" "${platforms}"
fi
fi
if [[ "${VERIFY_ALIASES}" == "true" ]]; then
repository="${immutable_reference%@*}"
verify_image_alias "${repository}:${CHANNEL}" "${digest}"
Expand All @@ -871,7 +937,8 @@ verify_locks() {
fi
fi
done < <(jq -r '.[] |
[.reference, .digest, .immutableReference] | @tsv
[.name, .reference, .digest, .immutableReference,
(.platforms | tojson)] | @tsv
' "${IMAGE_LOCK}")
fi

Expand Down
Loading
Loading