Skip to content
Merged
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
39 changes: 39 additions & 0 deletions __tests__/integration/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,5 +662,44 @@ describe('run', () => {
})
)
})

it('should fail when subject-digest uses sha512 with push-to-registry', async () => {
await run({
...registryInputs,
subjectDigest: `sha512:${'a'.repeat(128)}`
})

expect(setFailedMock).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(
/push-to-registry requires a subject with a SHA-256 digest/
)
})
)
expect(mockAttest).not.toHaveBeenCalled()
})
})

describe('non-sha256 digest without registry', () => {
it('should succeed with sha512 subject-digest when not pushing to registry', async () => {
await run({
...defaultInputs,
subjectName: 'artifact',
subjectDigest: `sha512:${'a'.repeat(128)}`,
predicateType: 'https://example.com/predicate',
predicate: '{}'
})

expect(setFailedMock).not.toHaveBeenCalled()
expect(mockAttest).toHaveBeenCalledWith(
expect.objectContaining({
subjects: [
expect.objectContaining({
digest: { sha512: 'a'.repeat(128) }
})
]
})
)
})
})
})
130 changes: 122 additions & 8 deletions __tests__/unit/subject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,48 @@ describe('subjectFromInputs', () => {
expect(subjects[0].name).toBe('ghcr.io/foo/bar')
})

// Table-driven acceptance tests for all six canonical SHA-2 algorithms
const algorithmTests: { algorithm: string; hexLength: number }[] = [
{ algorithm: 'sha224', hexLength: 56 },
{ algorithm: 'sha256', hexLength: 64 },
{ algorithm: 'sha384', hexLength: 96 },
{ algorithm: 'sha512', hexLength: 128 },
{ algorithm: 'sha512_224', hexLength: 56 },
{ algorithm: 'sha512_256', hexLength: 64 }
Comment thread
bdehamer marked this conversation as resolved.
]

it.each(algorithmTests)(
'should accept canonical $algorithm digest ($hexLength hex chars)',
async ({ algorithm, hexLength }) => {
const hex = 'a'.repeat(hexLength)
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: `${algorithm}:${hex}`
}

const subjects = await subjectFromInputs(inputs)

expect(subjects).toHaveLength(1)
expect(subjects[0].digest).toEqual({ [algorithm]: hex })
}
)

it('should accept uppercase hex digits in digest', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: 'sha256:7D070F6B64D9BCC530FE99CC21EAAA4B3C364E0B2D367D7735671FA202A03B32'
}

const subjects = await subjectFromInputs(inputs)

expect(subjects).toHaveLength(1)
expect(subjects[0].digest).toEqual({
sha256: '7D070F6B64D9BCC530FE99CC21EAAA4B3C364E0B2D367D7735671FA202A03B32'
})
})

it('should throw for malformed digest format', async () => {
const inputs: SubjectInputs = {
...blankInputs,
Expand All @@ -366,7 +408,7 @@ describe('subjectFromInputs', () => {
}

await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format/
/subject-digest has unsupported algorithm "md5"/
)
})

Expand All @@ -377,34 +419,106 @@ describe('subjectFromInputs', () => {
subjectDigest: 'sha256:deadbeef'
}

await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest has invalid length for algorithm "sha256"/
)
})

it('should reject uppercase algorithm names', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: `SHA256:${'a'.repeat(64)}`
}

await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest has unsupported algorithm "SHA256"/
)
})

it('should reject non-canonical algorithm aliases', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: `sha-256:${'a'.repeat(64)}`
}

await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest has unsupported algorithm "sha-256"/
)
})

it('should reject extra colons in digest', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: `sha256:${'a'.repeat(32)}:${'b'.repeat(32)}`
}

await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format/
)
})

it('should throw for non-hex characters in digest', async () => {
it('should reject empty algorithm component', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: `sha256:${'g'.repeat(64)}`
subjectDigest: `:${'a'.repeat(64)}`
}

await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format/
)
})

it('should accept valid uppercase hex digest', async () => {
it('should reject empty digest component', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: `sha256:${'A'.repeat(64)}`
subjectDigest: 'sha256:'
}

const subjects = await subjectFromInputs(inputs)
await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format/
)
})

expect(subjects).toHaveLength(1)
expect(subjects[0].digest).toEqual({ sha256: 'A'.repeat(64) })
it('should reject non-hex characters in digest', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: `sha256:${'g'.repeat(64)}`
}

await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest has invalid hex digits/
)
})

it('should reject npm SRI format', async () => {
// npm SRI: "sha512-<base64>" is not the canonical "algorithm:hex" form
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: 'sha512-n4bQgYhMfWtsxiT7nrnlA0leSv4+C2CDkMXOUOEJoiQ='
}

await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format/
)
})

it('should reject digest with no colon separator', async () => {
const inputs: SubjectInputs = {
...blankInputs,
subjectName: 'artifact',
subjectDigest: `sha256${'a'.repeat(64)}`
}

await expect(subjectFromInputs(inputs)).rejects.toThrow(
/subject-digest must be in the format/
)
})
})

Expand Down
37 changes: 32 additions & 5 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 45 additions & 7 deletions src/subject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ const MAX_SUBJECT_CHECKSUM_SIZE_BYTES = 512 * MAX_SUBJECT_COUNT
const DIGEST_ALGORITHM = 'sha256'
const HEX_STRING_RE = /^[0-9a-fA-F]+$/

// Canonical SHA-2 algorithms and their expected hex digest lengths
const SUPPORTED_DIGEST_ALGORITHMS: Record<string, number> = {
sha224: 56,
sha256: 64,
sha384: 96,
sha512: 128,
sha512_224: 56,
sha512_256: 64
Comment thread
bdehamer marked this conversation as resolved.
}

export type SubjectInputs = {
subjectPath: string
subjectName: string
Expand Down Expand Up @@ -144,22 +154,50 @@ const getSubjectFromPath = async (
return digestedSubjects
}

// Parses a subject digest string of the form "algorithm:hex_digest" and
// validates the algorithm name and hex digest length against the supported
// canonical in-toto SHA-2 algorithm set.
export const parseSubjectDigest = (
input: string
): { algorithm: string; digest: string } => {
const match = input.match(/^([^:]+):([^:]+)$/)
if (!match) {
throw new Error(
'subject-digest must be in the format "algorithm:hex_digest"'
)
}

const [, algorithm, digest] = match

const expectedLength = SUPPORTED_DIGEST_ALGORITHMS[algorithm]
if (expectedLength === undefined) {
throw new Error(`subject-digest has unsupported algorithm "${algorithm}"`)
}

if (!HEX_STRING_RE.test(digest)) {
throw new Error('subject-digest has invalid hex digits')
}

if (digest.length !== expectedLength) {
throw new Error(
`subject-digest has invalid length for algorithm "${algorithm}" (expected ${expectedLength}, got ${digest.length})`
)
}

return { algorithm, digest }
}

// Returns the subject specified by the digest of a file. The digest is returned
// along with the subject's name.
const getSubjectFromDigest = (
subjectDigest: string,
subjectName: string
): Subject => {
if (!subjectDigest.match(/^sha256:[0-9a-fA-F]{64}$/)) {
throw new Error(
'subject-digest must be in the format "sha256:<hex-digest>"'
)
}
const [alg, digest] = subjectDigest.split(':')
const { algorithm, digest } = parseSubjectDigest(subjectDigest)

return {
name: subjectName,
digest: { [alg]: digest }
digest: { [algorithm]: digest }
}
}

Expand Down