Consolidate attestation actions (#346)

* consolidate attestation actions

Signed-off-by: Brian DeHamer <bdehamer@github.com>

* better errors

Signed-off-by: Brian DeHamer <bdehamer@github.com>

* Update src/sbom.ts

Co-authored-by: Austin Beattie <ajbeattie@github.com>

* clarify dedupe comment

Signed-off-by: Brian DeHamer <bdehamer@github.com>

---------

Signed-off-by: Brian DeHamer <bdehamer@github.com>
Co-authored-by: Austin Beattie <ajbeattie@github.com>
This commit is contained in:
Brian DeHamer
2026-02-13 11:23:24 -08:00
committed by GitHub
parent a82737a684
commit dc4ad3cc6c
15 changed files with 1297 additions and 61 deletions

45
src/detect.ts Normal file
View File

@@ -0,0 +1,45 @@
export type AttestationType = 'provenance' | 'sbom' | 'custom'
export type DetectionInputs = {
sbomPath: string
predicateType: string
predicate: string
predicatePath: string
}
export const detectAttestationType = (
inputs: DetectionInputs
): AttestationType => {
const { sbomPath, predicateType, predicate, predicatePath } = inputs
// SBOM mode takes priority
if (sbomPath) {
return 'sbom'
}
// Custom mode when any predicate inputs are provided
if (predicateType || predicate || predicatePath) {
return 'custom'
}
// Default to provenance mode
return 'provenance'
}
export const validateAttestationInputs = (inputs: DetectionInputs): void => {
const { sbomPath, predicateType, predicate, predicatePath } = inputs
// Cannot combine sbom-path with predicate inputs
if (sbomPath && (predicateType || predicate || predicatePath)) {
throw new Error(
'Cannot specify sbom-path together with predicate-type, predicate, or predicate-path'
)
}
// Custom mode requires predicate-type
if ((predicate || predicatePath) && !predicateType) {
throw new Error(
'predicate-type is required when using predicate or predicate-path'
)
}
}

View File

@@ -9,6 +9,7 @@ const inputs: RunInputs = {
subjectName: core.getInput('subject-name'),
subjectDigest: core.getInput('subject-digest'),
subjectChecksums: core.getInput('subject-checksums'),
sbomPath: core.getInput('sbom-path'),
predicateType: core.getInput('predicate-type'),
predicate: core.getInput('predicate'),
predicatePath: core.getInput('predicate-path'),

View File

@@ -4,8 +4,16 @@ import fs from 'fs'
import os from 'os'
import path from 'path'
import { AttestResult, SigstoreInstance, createAttestation } from './attest'
import {
AttestationType,
DetectionInputs,
detectAttestationType,
validateAttestationInputs
} from './detect'
import { SEARCH_PUBLIC_GOOD_URL } from './endpoints'
import { PredicateInputs, predicateFromInputs } from './predicate'
import { generateProvenancePredicate } from './provenance'
import { parseSBOMFromPath, generateSBOMPredicate } from './sbom'
import * as style from './style'
import {
SubjectInputs,
@@ -13,13 +21,18 @@ import {
subjectFromInputs
} from './subject'
import type { Subject } from '@actions/attest'
import type { Predicate, Subject } from '@actions/attest'
const ATTESTATION_FILE_NAME = 'attestation.json'
const ATTESTATION_PATHS_FILE_NAME = 'created_attestation_paths.txt'
export type SBOMInputs = {
sbomPath: string
}
export type RunInputs = SubjectInputs &
PredicateInputs & {
PredicateInputs &
SBOMInputs & {
pushToRegistry: boolean
createStorageRecord: boolean
githubToken: string
@@ -58,11 +71,24 @@ export async function run(inputs: RunInputs): Promise<void> {
)
}
// Detect attestation type and validate inputs
const detectionInputs: DetectionInputs = {
sbomPath: inputs.sbomPath,
predicateType: inputs.predicateType,
predicate: inputs.predicate,
predicatePath: inputs.predicatePath
}
validateAttestationInputs(detectionInputs)
const attestationType = detectAttestationType(detectionInputs)
logAttestationType(attestationType)
const subjects = await subjectFromInputs({
...inputs,
downcaseName: inputs.pushToRegistry
})
const predicate = predicateFromInputs(inputs)
// Generate predicate based on attestation type
const predicate = await getPredicateForType(attestationType, inputs)
const outputPath = path.join(tempDir(), ATTESTATION_FILE_NAME)
core.setOutput('bundle-path', outputPath)
@@ -207,3 +233,30 @@ const tempDir = (): string => {
const attestationURL = (id: string): string =>
`${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/attestations/${id}`
// Log the detected attestation type
const logAttestationType = (type: AttestationType): void => {
const typeLabels: Record<AttestationType, string> = {
provenance: 'Build Provenance',
sbom: 'SBOM',
custom: 'Custom'
}
core.info(`Attestation type: ${typeLabels[type]}`)
}
// Generate predicate based on attestation type
const getPredicateForType = async (
type: AttestationType,
inputs: RunInputs
): Promise<Predicate> => {
switch (type) {
case 'provenance':
return generateProvenancePredicate()
case 'sbom': {
const sbom = await parseSBOMFromPath(inputs.sbomPath)
return generateSBOMPredicate(sbom)
}
case 'custom':
return predicateFromInputs(inputs)
}
}

7
src/provenance.ts Normal file
View File

@@ -0,0 +1,7 @@
import { buildSLSAProvenancePredicate } from '@actions/attest'
import type { Predicate } from '@actions/attest'
export const generateProvenancePredicate = async (): Promise<Predicate> => {
return buildSLSAProvenancePredicate()
}

90
src/sbom.ts Normal file
View File

@@ -0,0 +1,90 @@
import fs from 'fs'
import type { Predicate } from '@actions/attest'
export type SBOM = {
type: 'spdx' | 'cyclonedx'
object: object
}
// SBOMs cannot exceed 16MB.
const MAX_SBOM_SIZE_BYTES = 16 * 1024 * 1024
export const parseSBOMFromPath = async (filePath: string): Promise<SBOM> => {
if (!fs.existsSync(filePath)) {
throw new Error(`SBOM file not found: ${filePath}`)
}
const stats = fs.statSync(filePath)
if (stats.size > MAX_SBOM_SIZE_BYTES) {
throw new Error(
`SBOM file exceeds maximum allowed size: ${MAX_SBOM_SIZE_BYTES} bytes`
)
}
const fileContent = await fs.promises.readFile(filePath, 'utf8')
const sbom = JSON.parse(fileContent) as object
if (checkIsSPDX(sbom)) {
return { type: 'spdx', object: sbom }
} else if (checkIsCycloneDX(sbom)) {
return { type: 'cyclonedx', object: sbom }
}
throw new Error(
'Unsupported SBOM format. Must be valid SPDX or CycloneDX JSON.'
)
}
const checkIsSPDX = (sbomObject: {
spdxVersion?: string
SPDXID?: string
}): boolean => {
return !!(sbomObject?.spdxVersion && sbomObject?.SPDXID)
}
const checkIsCycloneDX = (sbomObject: {
bomFormat?: string
serialNumber?: string
specVersion?: string
}): boolean => {
return !!(
sbomObject?.bomFormat &&
sbomObject?.serialNumber &&
sbomObject?.specVersion
)
}
export const generateSBOMPredicate = (sbom: SBOM): Predicate => {
switch (sbom.type) {
case 'spdx':
return generateSPDXPredicate(sbom.object)
case 'cyclonedx':
return generateCycloneDXPredicate(sbom.object)
default:
throw new Error('Unsupported SBOM format')
}
}
// ref: https://github.com/in-toto/attestation/blob/main/spec/predicates/spdx.md
const generateSPDXPredicate = (sbom: object): Predicate => {
const spdxVersion = (sbom as { spdxVersion?: string })?.['spdxVersion']
if (!spdxVersion) {
throw new Error('Cannot find spdxVersion in the SBOM')
}
const version = spdxVersion.split('-')[1]
return {
type: `https://spdx.dev/Document/v${version}`,
params: sbom
}
}
// ref: https://github.com/in-toto/attestation/blob/main/spec/predicates/cyclonedx.md
const generateCycloneDXPredicate = (sbom: object): Predicate => {
return {
type: 'https://cyclonedx.org/bom',
params: sbom
}
}

View File

@@ -98,7 +98,7 @@ const getSubjectFromPath = async (
if (files.length > MAX_SUBJECT_COUNT) {
throw new Error(
`Too many subjects specified. The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`
`Too many subjects specified (${files.length}). The maximum number of subjects is ${MAX_SUBJECT_COUNT}.`
)
}
@@ -195,10 +195,15 @@ const getSubjectFromChecksumsString = (checksums: string): Subject[] => {
throw new Error(`Invalid digest: ${digest}`)
}
subjects.push({
name,
digest: { [digestAlgorithm(digest)]: digest }
})
const alg = digestAlgorithm(digest)
// Only add the subject if it is not already in the list (deduplicate by name & digest)
if (!subjects.some(s => s.name === name && s.digest[alg] === digest)) {
subjects.push({
name,
digest: { [alg]: digest }
})
}
}
return subjects