From bb93115e3059c8fb54fa08ca6e41e500233cee45 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:16:15 +0900 Subject: [PATCH 01/23] test(image): define decoded-size preflight RED --- src/policy/inlineImagePolicyPreflight.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/policy/inlineImagePolicyPreflight.test.ts diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts new file mode 100644 index 00000000..aa2ea833 --- /dev/null +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Base64SizeError } from '../converter/base64.js'; +import { validateInlineImageSource } from './inlineImagePolicy.js'; + +const OVERSIZED_IMAGE = 'data:image/png;base64,QUJDRA=='; + +describe('inline image decoded-size preflight', () => { + it('rejects an oversized valid image without decoding its base64 payload', () => { + const decodeSpy = vi.spyOn(globalThis.Buffer, 'from'); + + try { + expect(() => validateInlineImageSource(OVERSIZED_IMAGE, 3)).toThrowError( + expect.objectContaining({ + name: 'Base64SizeError', + bytes: 4, + maxBytes: 3, + } satisfies Partial), + ); + expect( + decodeSpy.mock.calls.some( + ([value, encoding]) => + value === 'QUJDRA==' && encoding === 'base64', + ), + ).toBe(false); + } finally { + decodeSpy.mockRestore(); + } + }); +}); From 5135b72ee1f9e52e1e700620dc1c23b209d53ea3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:19:33 +0900 Subject: [PATCH 02/23] test(image): reach decoded-size RED boundary --- src/policy/inlineImagePolicyPreflight.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts index aa2ea833..e17320c7 100644 --- a/src/policy/inlineImagePolicyPreflight.test.ts +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -18,10 +18,10 @@ describe('inline image decoded-size preflight', () => { } satisfies Partial), ); expect( - decodeSpy.mock.calls.some( - ([value, encoding]) => - value === 'QUJDRA==' && encoding === 'base64', - ), + decodeSpy.mock.calls.some((call) => { + const args = call as unknown as readonly unknown[]; + return args[0] === 'QUJDRA==' && args[1] === 'base64'; + }), ).toBe(false); } finally { decodeSpy.mockRestore(); From edcaf2e6afa013990b7e7bc8ce96eb9a32b41520 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 07:22:22 +0900 Subject: [PATCH 03/23] fix(image): bound decoded-size validation --- src/policy/inlineImagePolicy.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index 7d50f0e4..c2dd67af 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -1,7 +1,4 @@ -import { - Base64SizeError, - dataUriByteLength, -} from '../converter/base64.js'; +import { Base64SizeError } from '../converter/base64.js'; /** Strict raster-only data-URI form accepted by Inkspan document surfaces. */ const INLINE_RASTER_SOURCE_PATTERN = @@ -17,6 +14,13 @@ function redactImageSource(source: unknown): string { return ''; } +/** Return decoded bytes for a source that already passed the strict base64 grammar. */ +function inlineRasterByteLength(source: string): number { + const payloadLength = source.length - source.indexOf(',') - 1; + const padding = source.endsWith('==') ? 2 : Number(source.endsWith('=')); + return (payloadLength / 4) * 3 - padding; +} + /** Error thrown when an image source violates Inkspan's inline raster policy. */ export class Base64ImageSourceError extends Error { /** Redacted source category safe for logs and host telemetry. */ @@ -51,7 +55,7 @@ export function validateInlineImageSource( throw new Base64ImageSourceError(source); } if (maxSizeBytes > 0) { - const bytes = dataUriByteLength(source); + const bytes = inlineRasterByteLength(source); if (bytes > maxSizeBytes) { throw new Base64SizeError(bytes, maxSizeBytes); } From 08257617c496b639d44be09f1a510f9bffa5ef02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:20:16 +0900 Subject: [PATCH 04/23] test(images): reject malformed public byte limits --- src/policy/inlineImagePolicyPreflight.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts index e17320c7..729dfaca 100644 --- a/src/policy/inlineImagePolicyPreflight.test.ts +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -27,4 +27,13 @@ describe('inline image decoded-size preflight', () => { decodeSpy.mockRestore(); } }); + + it.each([Number.NaN, -1, 1.5, Number.POSITIVE_INFINITY])( + 'rejects malformed public byte limit %s instead of weakening the resource policy', + (maxSizeBytes) => { + expect(() => validateInlineImageSource(OVERSIZED_IMAGE, maxSizeBytes)).toThrowError( + new RangeError('inline image byte limit must be a non-negative safe integer'), + ); + }, + ); }); From 1c0b2546ce5d8ec152286e392b5d3d7c958c9a25 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:23:38 +0900 Subject: [PATCH 05/23] fix(images): fail closed on malformed byte limits --- src/policy/inlineImagePolicy.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index c2dd67af..2ac0f596 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -21,6 +21,15 @@ function inlineRasterByteLength(source: string): number { return (payloadLength / 4) * 3 - padding; } +/** Reject malformed public byte ceilings without coercion or intent inference. */ +function assertValidInlineImageByteLimit(maxSizeBytes: number): void { + if (!Number.isSafeInteger(maxSizeBytes) || maxSizeBytes < 0) { + throw new RangeError( + 'inline image byte limit must be a non-negative safe integer', + ); + } +} + /** Error thrown when an image source violates Inkspan's inline raster policy. */ export class Base64ImageSourceError extends Error { /** Redacted source category safe for logs and host telemetry. */ @@ -54,6 +63,8 @@ export function validateInlineImageSource( ) { throw new Base64ImageSourceError(source); } + + assertValidInlineImageByteLimit(maxSizeBytes); if (maxSizeBytes > 0) { const bytes = inlineRasterByteLength(source); if (bytes > maxSizeBytes) { From 4979f5d9e6032f1778e0f6413e290ee6ace9a593 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:06:13 +0900 Subject: [PATCH 06/23] test(reliability): preflight inline image byte policy --- src/policy/inlineImagePolicyPreflight.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts index 729dfaca..b97049e9 100644 --- a/src/policy/inlineImagePolicyPreflight.test.ts +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -36,4 +36,20 @@ describe('inline image decoded-size preflight', () => { ); }, ); + + it('rejects an unusable byte limit before scanning caller-controlled image source text', () => { + const source = 'data:image/png;base64,QUJDRA=='; + const regexpTestSpy = vi.spyOn(RegExp.prototype, 'test'); + + try { + expect(() => validateInlineImageSource(source, Number.NaN)).toThrowError( + new RangeError('inline image byte limit must be a non-negative safe integer'), + ); + expect( + regexpTestSpy.mock.calls.some((call) => call[0] === source), + ).toBe(false); + } finally { + regexpTestSpy.mockRestore(); + } + }); }); From 17f6806dfdfa6382ca57886d87961dd067001c3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:14:02 +0900 Subject: [PATCH 07/23] fix(reliability): validate inline image policy before source scan --- src/policy/inlineImagePolicy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index 2ac0f596..2b4edff7 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -56,6 +56,7 @@ export function validateInlineImageSource( source: unknown, maxSizeBytes: number, ): string { + assertValidInlineImageByteLimit(maxSizeBytes); if ( typeof source !== 'string' || source.length === 0 || @@ -64,7 +65,6 @@ export function validateInlineImageSource( throw new Base64ImageSourceError(source); } - assertValidInlineImageByteLimit(maxSizeBytes); if (maxSizeBytes > 0) { const bytes = inlineRasterByteLength(source); if (bytes > maxSizeBytes) { From 151ae9ef1892095f1e4ec529da298ddc26eb1d54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 01:57:01 +0900 Subject: [PATCH 08/23] test(privacy): expose custom image scheme in diagnostics --- src/policy/inlineImagePolicyPreflight.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts index b97049e9..e07dab83 100644 --- a/src/policy/inlineImagePolicyPreflight.test.ts +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { Base64SizeError } from '../converter/base64.js'; -import { validateInlineImageSource } from './inlineImagePolicy.js'; +import { + Base64ImageSourceError, + validateInlineImageSource, +} from './inlineImagePolicy.js'; const OVERSIZED_IMAGE = 'data:image/png;base64,QUJDRA=='; @@ -52,4 +55,12 @@ describe('inline image decoded-size preflight', () => { regexpTestSpy.mockRestore(); } }); + + it('does not reflect a caller-controlled custom URI scheme in diagnostics', () => { + const privateMarker = 'privatetenant42'; + const error = new Base64ImageSourceError(`${privateMarker}:opaque`); + + expect(error.sourcePreview).toBe(''); + expect(error.message).not.toContain(privateMarker); + }); }); From 14fad0e529795707abddb9e3ffefc181d0043d70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:50:01 +0900 Subject: [PATCH 09/23] fix(privacy): redact custom image schemes from diagnostics --- src/policy/inlineImagePolicy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index 2b4edff7..8c438dd2 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -10,7 +10,7 @@ function redactImageSource(source: unknown): string { if (source.length === 0) return ''; if (source.startsWith('//')) return '//'; const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(source)?.[1]; - if (scheme) return `${scheme.toLowerCase()}:`; + if (scheme) return ''; return ''; } From 7a52faf8cebbeb3c3e6f4cda3b91d6ba097b5959 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:54:52 +0900 Subject: [PATCH 10/23] fix(privacy): preserve fixed image scheme categories --- src/policy/inlineImagePolicy.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index 8c438dd2..a57b2799 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -4,13 +4,22 @@ import { Base64SizeError } from '../converter/base64.js'; const INLINE_RASTER_SOURCE_PATTERN = /^data:image\/(?:png|jpe?g|gif|webp|avif|apng|bmp|x-icon|vnd\.microsoft\.icon);base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)$/i; +/** Fixed public categories that reveal no caller-defined scheme label. */ +const PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN = + /^(?:data|https?|blob|file|javascript)$/i; + /** Return a bounded, payload-free category for an untrusted image source. */ function redactImageSource(source: unknown): string { if (typeof source !== 'string') return `<${typeof source}>`; if (source.length === 0) return ''; if (source.startsWith('//')) return '//'; const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(source)?.[1]; - if (scheme) return ''; + if (scheme) { + if (PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN.test(scheme)) { + return `${scheme.toLowerCase()}:`; + } + return ''; + } return ''; } From c89a348196b59db600d3447a67ee5cf7514542e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 19:27:16 +0900 Subject: [PATCH 11/23] test(reliability): bound inline image diagnostic scan --- src/policy/inlineImagePolicyPreflight.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts index e07dab83..0b725eb3 100644 --- a/src/policy/inlineImagePolicyPreflight.test.ts +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -63,4 +63,23 @@ describe('inline image decoded-size preflight', () => { expect(error.sourcePreview).toBe(''); expect(error.message).not.toContain(privateMarker); }); + + it('bounds diagnostic scheme inspection before regex work on untrusted source text', () => { + const source = 'a'.repeat(65_536); + const regexpExecSpy = vi.spyOn(RegExp.prototype, 'exec'); + let inspectedLengths: number[] = []; + + try { + const error = new Base64ImageSourceError(source); + inspectedLengths = regexpExecSpy.mock.calls + .map((call) => call[0]) + .filter((value): value is string => typeof value === 'string') + .map((value) => value.length); + expect(error.sourcePreview).toBe(''); + } finally { + regexpExecSpy.mockRestore(); + } + + expect(Math.max(...inspectedLengths)).toBeLessThanOrEqual(64); + }); }); From 84d3ae09aa945ce9b5b5f9fd18f2064c5faef84f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 19:29:53 +0900 Subject: [PATCH 12/23] fix(reliability): bound inline image diagnostic scan --- src/policy/inlineImagePolicy.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index a57b2799..ce67401c 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -8,12 +8,17 @@ const INLINE_RASTER_SOURCE_PATTERN = const PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN = /^(?:data|https?|blob|file|javascript)$/i; +/** Maximum untrusted prefix inspected while classifying a diagnostic scheme. */ +const IMAGE_SOURCE_SCHEME_INSPECTION_CODE_UNITS = 64; + /** Return a bounded, payload-free category for an untrusted image source. */ function redactImageSource(source: unknown): string { if (typeof source !== 'string') return `<${typeof source}>`; if (source.length === 0) return ''; if (source.startsWith('//')) return '//'; - const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(source)?.[1]; + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec( + source.slice(0, IMAGE_SOURCE_SCHEME_INSPECTION_CODE_UNITS), + )?.[1]; if (scheme) { if (PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN.test(scheme)) { return `${scheme.toLowerCase()}:`; From ad6fb67a3ed56c4509e634d37895699be18f7d0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 19:53:12 +0900 Subject: [PATCH 13/23] test(reliability): preflight oversized inline source scans --- src/policy/inlineImagePolicyPreflight.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts index 0b725eb3..da0c5e4d 100644 --- a/src/policy/inlineImagePolicyPreflight.test.ts +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -56,6 +56,25 @@ describe('inline image decoded-size preflight', () => { } }); + it('rejects a provably oversized valid raster source before full-payload regex scanning', () => { + const source = `data:image/png;base64,${'QUJD'.repeat(16_384)}`; + const regexpTestSpy = vi.spyOn(RegExp.prototype, 'test'); + + try { + expect(() => validateInlineImageSource(source, 3)).toThrowError( + expect.objectContaining({ + name: 'Base64SizeError', + maxBytes: 3, + } satisfies Partial), + ); + expect( + regexpTestSpy.mock.calls.some((call) => call[0] === source), + ).toBe(false); + } finally { + regexpTestSpy.mockRestore(); + } + }); + it('does not reflect a caller-controlled custom URI scheme in diagnostics', () => { const privateMarker = 'privatetenant42'; const error = new Base64ImageSourceError(`${privateMarker}:opaque`); From e0a92157263b2ad1627d8ea356072629e6487baf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 19:56:13 +0900 Subject: [PATCH 14/23] fix(reliability): preflight oversized inline source scans --- src/policy/inlineImagePolicy.ts | 57 ++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index ce67401c..dd0bf08c 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -4,12 +4,16 @@ import { Base64SizeError } from '../converter/base64.js'; const INLINE_RASTER_SOURCE_PATTERN = /^data:image\/(?:png|jpe?g|gif|webp|avif|apng|bmp|x-icon|vnd\.microsoft\.icon);base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)$/i; +/** Bounded prefix recognizer used before whole-payload grammar validation. */ +const INLINE_RASTER_SOURCE_PREFIX_PATTERN = + /^data:image\/(?:png|jpe?g|gif|webp|avif|apng|bmp|x-icon|vnd\.microsoft\.icon);base64,/i; + /** Fixed public categories that reveal no caller-defined scheme label. */ const PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN = /^(?:data|https?|blob|file|javascript)$/i; -/** Maximum untrusted prefix inspected while classifying a diagnostic scheme. */ -const IMAGE_SOURCE_SCHEME_INSPECTION_CODE_UNITS = 64; +/** Maximum untrusted prefix inspected while classifying source metadata. */ +const IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS = 64; /** Return a bounded, payload-free category for an untrusted image source. */ function redactImageSource(source: unknown): string { @@ -17,7 +21,7 @@ function redactImageSource(source: unknown): string { if (source.length === 0) return ''; if (source.startsWith('//')) return '//'; const scheme = /^([a-z][a-z0-9+.-]*):/i.exec( - source.slice(0, IMAGE_SOURCE_SCHEME_INSPECTION_CODE_UNITS), + source.slice(0, IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS), )?.[1]; if (scheme) { if (PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN.test(scheme)) { @@ -28,13 +32,40 @@ function redactImageSource(source: unknown): string { return ''; } -/** Return decoded bytes for a source that already passed the strict base64 grammar. */ -function inlineRasterByteLength(source: string): number { - const payloadLength = source.length - source.indexOf(',') - 1; +/** Return decoded bytes for a source whose strict base64 shape is known. */ +function inlineRasterByteLength(source: string, payloadOffset: number): number { + const payloadLength = source.length - payloadOffset; const padding = source.endsWith('==') ? 2 : Number(source.endsWith('=')); return (payloadLength / 4) * 3 - padding; } +/** + * Reject an obviously oversized raster candidate before scanning its payload. + * + * A bounded prefix plus base64 quartet length/padding is sufficient to derive + * the exact decoded size for every candidate that could satisfy the strict + * grammar. The full grammar remains authoritative for every in-bound source. + */ +function preflightInlineRasterByteLength( + source: string, + maxSizeBytes: number, +): void { + if (maxSizeBytes === 0) return; + const prefixMatch = INLINE_RASTER_SOURCE_PREFIX_PATTERN.exec( + source.slice(0, IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS), + ); + if (!prefixMatch) return; + + const payloadOffset = prefixMatch[0].length; + const payloadLength = source.length - payloadOffset; + if (payloadLength < 4 || payloadLength % 4 !== 0) return; + + const bytes = inlineRasterByteLength(source, payloadOffset); + if (bytes > maxSizeBytes) { + throw new Base64SizeError(bytes, maxSizeBytes); + } +} + /** Reject malformed public byte ceilings without coercion or intent inference. */ function assertValidInlineImageByteLimit(maxSizeBytes: number): void { if (!Number.isSafeInteger(maxSizeBytes) || maxSizeBytes < 0) { @@ -71,16 +102,18 @@ export function validateInlineImageSource( maxSizeBytes: number, ): string { assertValidInlineImageByteLimit(maxSizeBytes); - if ( - typeof source !== 'string' || - source.length === 0 || - !INLINE_RASTER_SOURCE_PATTERN.test(source) - ) { + if (typeof source !== 'string' || source.length === 0) { + throw new Base64ImageSourceError(source); + } + + preflightInlineRasterByteLength(source, maxSizeBytes); + if (!INLINE_RASTER_SOURCE_PATTERN.test(source)) { throw new Base64ImageSourceError(source); } if (maxSizeBytes > 0) { - const bytes = inlineRasterByteLength(source); + const payloadOffset = source.indexOf(',') + 1; + const bytes = inlineRasterByteLength(source, payloadOffset); if (bytes > maxSizeBytes) { throw new Base64SizeError(bytes, maxSizeBytes); } From fa7c46e4cd27bba89bbac82cab672e9f23b2d151 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 19:59:42 +0900 Subject: [PATCH 15/23] test(reliability): cover bounded image preflight paths --- src/policy/inlineImagePolicyPreflight.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts index da0c5e4d..6da7efe0 100644 --- a/src/policy/inlineImagePolicyPreflight.test.ts +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -75,6 +75,30 @@ describe('inline image decoded-size preflight', () => { } }); + it.each([ + 'https://example.invalid/image.png', + 'data:image/png;base64,', + 'data:image/png;base64,AAA', + 'data:image/png;base64,AA*A', + ])('defers in-bound malformed candidate %s to the strict source grammar', (source) => { + expect(() => validateInlineImageSource(source, 4)).toThrow( + Base64ImageSourceError, + ); + }); + + it('accounts for a single canonical padding byte in an in-bound valid source', () => { + const source = 'data:image/png;base64,QUJDRAA='; + + expect(validateInlineImageSource(source, 5)).toBe(source); + expect(() => validateInlineImageSource(source, 4)).toThrowError( + expect.objectContaining({ + name: 'Base64SizeError', + bytes: 5, + maxBytes: 4, + } satisfies Partial), + ); + }); + it('does not reflect a caller-controlled custom URI scheme in diagnostics', () => { const privateMarker = 'privatetenant42'; const error = new Base64ImageSourceError(`${privateMarker}:opaque`); From 450847bda35b5950e8521255583afbda2af717d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:02:15 +0900 Subject: [PATCH 16/23] test(reliability): preserve malformed inline precedence --- src/policy/inlineImagePolicyPreflight.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts index 6da7efe0..93c476d1 100644 --- a/src/policy/inlineImagePolicyPreflight.test.ts +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -75,6 +75,14 @@ describe('inline image decoded-size preflight', () => { } }); + it('preserves malformed-source precedence even when the candidate is oversized', () => { + const source = `data:image/png;base64,${'QUJD'.repeat(16_383)}QU*D`; + + expect(() => validateInlineImageSource(source, 3)).toThrow( + Base64ImageSourceError, + ); + }); + it.each([ 'https://example.invalid/image.png', 'data:image/png;base64,', From 7701b9d35efa26284b4e0894b24cf050e3994031 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 20:03:39 +0900 Subject: [PATCH 17/23] fix(reliability): preserve strict image grammar precedence --- src/policy/inlineImagePolicy.ts | 43 ++++++++++++++++----------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index dd0bf08c..a4944949 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -1,13 +1,12 @@ import { Base64SizeError } from '../converter/base64.js'; -/** Strict raster-only data-URI form accepted by Inkspan document surfaces. */ -const INLINE_RASTER_SOURCE_PATTERN = - /^data:image\/(?:png|jpe?g|gif|webp|avif|apng|bmp|x-icon|vnd\.microsoft\.icon);base64,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)$/i; - -/** Bounded prefix recognizer used before whole-payload grammar validation. */ +/** Bounded raster data-URI prefix recognized before payload validation. */ const INLINE_RASTER_SOURCE_PREFIX_PATTERN = /^data:image\/(?:png|jpe?g|gif|webp|avif|apng|bmp|x-icon|vnd\.microsoft\.icon);base64,/i; +/** One canonical base64 payload code unit; padding is handled separately. */ +const BASE64_PAYLOAD_CODE_UNIT_PATTERN = /^[A-Za-z0-9+/]$/; + /** Fixed public categories that reveal no caller-defined scheme label. */ const PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN = /^(?:data|https?|blob|file|javascript)$/i; @@ -40,30 +39,31 @@ function inlineRasterByteLength(source: string, payloadOffset: number): number { } /** - * Reject an obviously oversized raster candidate before scanning its payload. + * Validate the strict raster/base64 grammar without decoding or whole-source regex work. * - * A bounded prefix plus base64 quartet length/padding is sufficient to derive - * the exact decoded size for every candidate that could satisfy the strict - * grammar. The full grammar remains authoritative for every in-bound source. + * The MIME/prefix regex sees only a bounded prefix. Payload code units are then + * inspected incrementally so malformed-source precedence remains authoritative + * even for oversized candidates. Canonical padding is inferred only from the + * final one or two code units; any earlier `=` is rejected by the payload scan. */ -function preflightInlineRasterByteLength( - source: string, - maxSizeBytes: number, -): void { - if (maxSizeBytes === 0) return; +function strictInlineRasterPayloadOffset(source: string): number | null { const prefixMatch = INLINE_RASTER_SOURCE_PREFIX_PATTERN.exec( source.slice(0, IMAGE_SOURCE_PREFIX_INSPECTION_CODE_UNITS), ); - if (!prefixMatch) return; + if (!prefixMatch) return null; const payloadOffset = prefixMatch[0].length; const payloadLength = source.length - payloadOffset; - if (payloadLength < 4 || payloadLength % 4 !== 0) return; + if (payloadLength < 4 || payloadLength % 4 !== 0) return null; - const bytes = inlineRasterByteLength(source, payloadOffset); - if (bytes > maxSizeBytes) { - throw new Base64SizeError(bytes, maxSizeBytes); + const padding = source.endsWith('==') ? 2 : Number(source.endsWith('=')); + const payloadDataEnd = source.length - padding; + for (let index = payloadOffset; index < payloadDataEnd; index += 1) { + if (!BASE64_PAYLOAD_CODE_UNIT_PATTERN.test(source.charAt(index))) { + return null; + } } + return payloadOffset; } /** Reject malformed public byte ceilings without coercion or intent inference. */ @@ -106,13 +106,12 @@ export function validateInlineImageSource( throw new Base64ImageSourceError(source); } - preflightInlineRasterByteLength(source, maxSizeBytes); - if (!INLINE_RASTER_SOURCE_PATTERN.test(source)) { + const payloadOffset = strictInlineRasterPayloadOffset(source); + if (payloadOffset === null) { throw new Base64ImageSourceError(source); } if (maxSizeBytes > 0) { - const payloadOffset = source.indexOf(',') + 1; const bytes = inlineRasterByteLength(source, payloadOffset); if (bytes > maxSizeBytes) { throw new Base64SizeError(bytes, maxSizeBytes); From 12ed1fcf5c287cb20c20f8cd53ce900646203ab7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:02:54 +0900 Subject: [PATCH 18/23] test(data-integrity): reject non-canonical inline base64 padding bits --- src/policy/inlineImagePolicyPreflight.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/policy/inlineImagePolicyPreflight.test.ts b/src/policy/inlineImagePolicyPreflight.test.ts index 93c476d1..e4c55f12 100644 --- a/src/policy/inlineImagePolicyPreflight.test.ts +++ b/src/policy/inlineImagePolicyPreflight.test.ts @@ -83,6 +83,15 @@ describe('inline image decoded-size preflight', () => { ); }); + it.each([ + 'data:image/png;base64,AR==', + 'data:image/png;base64,AQJ=', + ])('rejects non-canonical base64 padding bits in %s', (source) => { + expect(() => validateInlineImageSource(source, 0)).toThrow( + Base64ImageSourceError, + ); + }); + it.each([ 'https://example.invalid/image.png', 'data:image/png;base64,', From c947e9f2aa458f361d068bdf755231e0d2220907 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:06:27 +0900 Subject: [PATCH 19/23] fix(data-integrity): enforce canonical inline base64 padding bits --- src/policy/inlineImagePolicy.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index a4944949..263c3087 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -7,6 +7,12 @@ const INLINE_RASTER_SOURCE_PREFIX_PATTERN = /** One canonical base64 payload code unit; padding is handled separately. */ const BASE64_PAYLOAD_CODE_UNIT_PATTERN = /^[A-Za-z0-9+/]$/; +/** Valid final sextets before `==`; their four unused low bits are zero. */ +const BASE64_DOUBLE_PADDING_FINAL_CODE_UNIT_PATTERN = /^[AQgw]$/; + +/** Valid final sextets before `=`; their two unused low bits are zero. */ +const BASE64_SINGLE_PADDING_FINAL_CODE_UNIT_PATTERN = /^[AEIMQUYcgkosw048]$/; + /** Fixed public categories that reveal no caller-defined scheme label. */ const PUBLIC_IMAGE_SOURCE_SCHEME_PATTERN = /^(?:data|https?|blob|file|javascript)$/i; @@ -44,7 +50,8 @@ function inlineRasterByteLength(source: string, payloadOffset: number): number { * The MIME/prefix regex sees only a bounded prefix. Payload code units are then * inspected incrementally so malformed-source precedence remains authoritative * even for oversized candidates. Canonical padding is inferred only from the - * final one or two code units; any earlier `=` is rejected by the payload scan. + * final one or two code units; any earlier `=` is rejected by the payload scan, + * and unused bits in the final data sextet must be zero. */ function strictInlineRasterPayloadOffset(source: string): number | null { const prefixMatch = INLINE_RASTER_SOURCE_PREFIX_PATTERN.exec( @@ -63,6 +70,20 @@ function strictInlineRasterPayloadOffset(source: string): number | null { return null; } } + + const finalDataCodeUnit = source.charAt(payloadDataEnd - 1); + if ( + padding === 2 && + !BASE64_DOUBLE_PADDING_FINAL_CODE_UNIT_PATTERN.test(finalDataCodeUnit) + ) { + return null; + } + if ( + padding === 1 && + !BASE64_SINGLE_PADDING_FINAL_CODE_UNIT_PATTERN.test(finalDataCodeUnit) + ) { + return null; + } return payloadOffset; } From 46ace6d660146d1532ab2d0a3e592afab58d9b1f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:32:20 +0900 Subject: [PATCH 20/23] fix(ci): reconcile release workflow with protected main --- .github/workflows/release.yml | 94 ++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfb80a5a..2cabb6da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: with: fetch-depth: 0 - name: Set up pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -91,7 +91,8 @@ jobs: mv "$package_file" release/ - name: Install hash-locked Office dependencies working-directory: office - run: python -m pip install --require-hashes --only-binary=:all: -r requirements-ci.txt + run: | + python -m pip install --require-hashes --only-binary=:all: -r requirements-ci.txt - name: Verify Office dependency consistency working-directory: office run: python -m pip check @@ -121,11 +122,65 @@ jobs: assert any(name.endswith('.dist-info/licenses/LICENSE') for name in names) PY mv dist/*.whl ../release/ + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: 'v3.0.6' + - name: Install signature-verified Syft + run: | + set -euo pipefail + syft_installer="$RUNNER_TEMP/syft-install.sh" + curl --fail --silent --show-error --location \ + --proto '=https' \ + --output "$syft_installer" \ + https://raw.githubusercontent.com/anchore/syft/16223e6dd7893fe578787658ceb876257483d404/install.sh + mkdir -p "$RUNNER_TEMP/syft-bin" + DOWNLOAD_TAG_INSTALL_SCRIPT=false \ + sh "$syft_installer" -v -b "$RUNNER_TEMP/syft-bin" v1.50.0 + "$RUNNER_TEMP/syft-bin/syft" version + echo "$RUNNER_TEMP/syft-bin" >> "$GITHUB_PATH" + - name: Generate release SBOM + run: | + set -euo pipefail + syft scan dir:. -o spdx-json > release/inkspan.spdx.json + - name: Validate release SBOM + run: | + set -euo pipefail + node <<'NODE' + const { readFileSync, statSync } = require('node:fs'); + + const sbomPath = 'release/inkspan.spdx.json'; + const sbom = JSON.parse(readFileSync(sbomPath, 'utf8')); + const packageMetadata = JSON.parse(readFileSync('package.json', 'utf8')); + const officeMetadata = readFileSync('office/pyproject.toml', 'utf8'); + if (statSync(sbomPath).size > 16 * 1024 * 1024) { + throw new Error('Release SBOM exceeds the 16 MiB actions/attest input limit.'); + } + if (sbom.spdxVersion !== 'SPDX-2.3') { + throw new Error(`Release SBOM must be SPDX-2.3; found ${sbom.spdxVersion ?? 'missing'}.`); + } + if (!Array.isArray(sbom.packages) || sbom.packages.length === 0) { + throw new Error('Release SBOM package inventory must not be empty.'); + } + const sbomPackageNames = new Set(sbom.packages.map((pkg) => pkg.name)); + if (packageMetadata.name !== '@contextualwisdomlab/cwl-editor') { + throw new Error('Release source has an unexpected editor package identity.'); + } + if (!/^name\s*=\s*["']inkspan-office["']\s*$/m.test(officeMetadata)) { + throw new Error('Release source has an unexpected Office package identity.'); + } + if (!sbomPackageNames.has(packageMetadata.name)) { + throw new Error('Release SBOM inventory must include the editor package identity.'); + } + if (!sbomPackageNames.has('inkspan-office')) { + throw new Error('Release SBOM inventory must include the Office package identity.'); + } + NODE - name: Generate release checksums run: | set -euo pipefail cd release - sha256sum -- *.tgz *.whl > SHA256SUMS + sha256sum -- *.tgz *.whl inkspan.spdx.json > SHA256SUMS - name: Transfer exact release artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -152,7 +207,7 @@ jobs: ref: ${{ github.sha }} persist-credentials: false - name: Set up pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -237,7 +292,7 @@ jobs: - name: Verify bounded local release artifact set run: | set -euo pipefail - expected_asset_count=3 + expected_asset_count=4 mapfile -t local_entries < <( find release -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort ) @@ -254,8 +309,9 @@ jobs: || ${#local_assets[@]} -ne $expected_asset_count \ || ${#npm_assets[@]} -ne 1 \ || ${#wheel_assets[@]} -ne 1 \ + || ! -f release/inkspan.spdx.json \ || ! -f release/SHA256SUMS ]]; then - echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, and SHA256SUMS." + echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, inkspan.spdx.json, and SHA256SUMS." exit 1 fi - name: Attest release artifacts @@ -264,15 +320,28 @@ jobs: subject-path: | release/*.tgz release/*.whl + release/inkspan.spdx.json release/SHA256SUMS + - name: Attest release packages with SBOM + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: | + release/*.tgz + release/*.whl + sbom-path: release/inkspan.spdx.json - name: Verify generated attestations env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - for artifact in release/*.tgz release/*.whl release/SHA256SUMS; do + for artifact in release/*.tgz release/*.whl release/inkspan.spdx.json release/SHA256SUMS; do gh attestation verify "$artifact" --repo "$GITHUB_REPOSITORY" done + for artifact in release/*.tgz release/*.whl; do + gh attestation verify "$artifact" \ + --repo "$GITHUB_REPOSITORY" \ + --predicate-type https://spdx.dev/Document/v2.3 + done - name: Prepare draft GitHub release env: GH_TOKEN: ${{ github.token }} @@ -304,7 +373,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - expected_asset_count=3 + expected_asset_count=4 mapfile -t local_entries < <( find release -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort ) @@ -321,8 +390,9 @@ jobs: || ${#local_assets[@]} -ne $expected_asset_count \ || ${#npm_assets[@]} -ne 1 \ || ${#wheel_assets[@]} -ne 1 \ + || ! -f release/inkspan.spdx.json \ || ! -f release/SHA256SUMS ]]; then - echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, and SHA256SUMS." + echo "::error::Unexpected local release artifact set; require exactly one *.tgz, one *.whl, inkspan.spdx.json, and SHA256SUMS." exit 1 fi @@ -414,7 +484,7 @@ jobs: fi gh release verify "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" - for artifact in release/*.tgz release/*.whl release/SHA256SUMS; do + for artifact in release/*.tgz release/*.whl release/inkspan.spdx.json release/SHA256SUMS; do gh release verify-asset "$GITHUB_REF_NAME" "$artifact" \ --repo "$GITHUB_REPOSITORY" done @@ -600,7 +670,7 @@ jobs: process.exit(2); } process.stdout.write(url.origin); - NODE + NODE )" || { echo "::error::npm dist.tarball must stay on the canonical registry.npmjs.org HTTPS origin." exit 1 @@ -646,4 +716,4 @@ jobs: done echo "::error::Registry publication verification did not converge to the exact artifact digests." - exit 1 + exit 1 \ No newline at end of file From f0a41045ccb41be0bd23d68cb272b7da8567f2e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:32:51 +0900 Subject: [PATCH 21/23] fix(ci): align release asset inventory contract --- src/releaseDraftAssetInventory.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/releaseDraftAssetInventory.test.ts b/src/releaseDraftAssetInventory.test.ts index 972abe0f..ff785fee 100644 --- a/src/releaseDraftAssetInventory.test.ts +++ b/src/releaseDraftAssetInventory.test.ts @@ -79,6 +79,7 @@ function runReleaseInventory( const localFiles = { 'inkspan.tgz': 'npm-package', 'inkspan_office.whl': 'office-wheel', + 'inkspan.spdx.json': '{"spdxVersion":"SPDX-2.3","packages":[]}', SHA256SUMS: 'checksums', } as const; for (const [name, content] of Object.entries(localFiles)) { @@ -153,9 +154,10 @@ describe('release draft asset inventory contract', () => { localValidationIndex, attestIndex, ); - expect(localValidationStep).toContain('expected_asset_count=3'); + expect(localValidationStep).toContain('expected_asset_count=4'); expect(localValidationStep).toContain('*.tgz'); expect(localValidationStep).toContain('*.whl'); + expect(localValidationStep).toContain('inkspan.spdx.json'); expect(localValidationStep).toContain('SHA256SUMS'); expect(localValidationStep).toContain( 'Unexpected local release artifact set', @@ -195,7 +197,7 @@ describe('release draft asset inventory contract', () => { expect(inventoryStep).toContain('Draft release asset digest mismatch'); }); - it('admits only the expected npm, wheel, and checksum artifact set', () => { + it('admits only the expected npm, wheel, SBOM, and checksum artifact set', () => { const inventoryIndex = workflow.indexOf( '- name: Verify exact draft release asset inventory', ); @@ -204,9 +206,10 @@ describe('release draft asset inventory contract', () => { ); const inventoryStep = workflow.slice(inventoryIndex, publishIndex); - expect(inventoryStep).toContain('expected_asset_count=3'); + expect(inventoryStep).toContain('expected_asset_count=4'); expect(inventoryStep).toContain('*.tgz'); expect(inventoryStep).toContain('*.whl'); + expect(inventoryStep).toContain('inkspan.spdx.json'); expect(inventoryStep).toContain('SHA256SUMS'); expect(inventoryStep).toContain('Unexpected local release artifact set'); expect(inventoryStep).toContain("asset_name='.assets[].name'"); From 921c906cdbf0a89aefd9a9e3b04a793d96beb608 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:33:14 +0900 Subject: [PATCH 22/23] fix(ci): align release entry-type contract --- src/releaseDraftAssetEntryType.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/releaseDraftAssetEntryType.test.ts b/src/releaseDraftAssetEntryType.test.ts index 4b1d9dd3..be84fbb1 100644 --- a/src/releaseDraftAssetEntryType.test.ts +++ b/src/releaseDraftAssetEntryType.test.ts @@ -56,6 +56,10 @@ function runLocalReleaseInventory( mkdirSync(releaseDirectory); writeFileSync(join(releaseDirectory, 'inkspan.tgz'), 'npm-package'); writeFileSync(join(releaseDirectory, 'inkspan_office.whl'), 'office-wheel'); + writeFileSync( + join(releaseDirectory, 'inkspan.spdx.json'), + '{"spdxVersion":"SPDX-2.3","packages":[]}', + ); writeFileSync(join(releaseDirectory, 'SHA256SUMS'), 'checksums'); mutate?.(releaseDirectory); @@ -74,7 +78,7 @@ function runLocalReleaseInventory( } describe('local release artifact entry-type boundary', () => { - it('accepts exactly the three expected regular release files', () => { + it('accepts exactly the four expected regular release files', () => { if (process.platform !== 'linux') return; const result = runLocalReleaseInventory(); From 6f17bc31c84b6522ee709d4cce076a3e9b270002 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:35:03 +0900 Subject: [PATCH 23/23] docs(release): align SBOM provenance contract --- docs/release-security.md | 53 +++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/docs/release-security.md b/docs/release-security.md index 5e1e0157..08e1d6ff 100644 --- a/docs/release-security.md +++ b/docs/release-security.md @@ -1,6 +1,6 @@ # Release security and provenance contract -Inkspan release artifacts are part of the product boundary. Buyers and CWL/naruon integrators must be able to determine which source revision produced an npm tarball or Office wheel, verify that the artifact was not substituted, and reproduce the repository's release gates without trusting a long-lived publication secret. +Inkspan release artifacts are part of the product boundary. Buyers and CWL/naruon integrators must be able to determine which source revision produced an npm tarball or Office wheel, inspect the release SBOM, verify that no artifact was substituted, and reproduce the repository's release gates without trusting a long-lived publication secret. ## Release trigger and identity @@ -20,7 +20,7 @@ For stable registry releases, the root and Office package versions must both equ The GitHub Release path has a source-bearing build stage followed by a source-free publication stage, and external registry publication is downstream of that validated artifact boundary: -1. `build-release-artifacts` has read-only repository access. It checks identity, installs dependencies, runs all quality gates, builds both distributions, and creates checksums. +1. `build-release-artifacts` has read-only repository access. It checks identity, installs dependencies, runs all quality gates, builds both distributions, generates an SPDX 2.3 SBOM with signature-verified Syft, validates the SBOM, and creates checksums for the complete release set. 2. `publish-release` receives only the validated files through GitHub's workflow artifact service. This smaller job alone receives the GitHub release, OpenID Connect, and attestation authority needed to create the immutable GitHub Release. 3. `publish-npm` and `publish-pypi` consume the same validated npm tarball and Office wheel after the GitHub Release boundary. They receive OIDC only inside their protected registry environments and do not rebuild the packages. 4. `verify-registry-publication` has no publishing credential. It performs post-publication digest verification against the public npm and PyPI registry identities and the exact validated local artifacts. @@ -44,13 +44,23 @@ The release workflow repeats merge and product gates against the tagged source r 9. hash-locked Office dependency installation on Python 3.14 for the release build; 10. Office dependency consistency, 100% shipped-symbol docstring coverage, and 100% branch coverage; 11. Office wheel construction and inspection for the bundled schema and license; -12. SHA-256 checksum generation for every distributable artifact; -13. checksum verification after the privilege boundary; -14. exact draft asset inventory and digest verification before GitHub publication; and -15. public npm and PyPI post-publication digest verification for stable registry releases. +12. installation of the exact Syft v1.50.0 release through its commit-pinned installer with Cosign verification enabled, so the signed checksum material is verified before the Syft binary is accepted; +13. deterministic SPDX 2.3 SBOM generation and validation for a non-empty inventory containing both `@contextualwisdomlab/cwl-editor` and `inkspan-office`; +14. SHA-256 checksum generation for the npm tarball, Office wheel, `inkspan.spdx.json`, and checksum manifest boundary; +15. checksum verification after the privilege boundary; +16. exact draft asset inventory and digest verification before GitHub publication; and +17. public npm and PyPI post-publication digest verification for stable registry releases. No release draft is created or modified unless every source-bearing build gate succeeds on the tagged commit. A stable release is not treated as registry-complete until both registry publication jobs and the downstream public digest verification succeed. +## SBOM generator trust boundary + +The release path does not delegate Syft installation to an action that can retrieve a mutable installer from another branch. It installs Cosign from a full-commit-pinned `sigstore/cosign-installer` action, downloads Syft's installer from the exact commit behind the annotated `v1.50.0` tag, disables installer-script redirection with `DOWNLOAD_TAG_INSTALL_SCRIPT=false`, and invokes the installer with `-v`. The Syft installer therefore verifies the release checksum signature and certificate before accepting the downloaded Syft binary, then still verifies the binary checksum. + +Only that signature-verified Syft executable is added to the workflow `PATH` and used to generate `release/inkspan.spdx.json`. The workflow then validates the SPDX version, package inventory, expected Inkspan package identities, and the bounded attestation-input size before the SBOM can cross the build/publication privilege boundary. + +This controls the generator bootstrap path; it does not assert that an SBOM is a vulnerability scan or license-policy decision. Consumers and release operators must interpret the inventory separately from provenance and security-scan results. + ## Immutable GitHub publication Immutable releases must be enabled for the canonical repository before a release tag is pushed. Reading or changing that repository setting requires Administration permission, which the release workflow intentionally does not receive. Instead, the workflow verifies the immutable state of the published release through the ordinary release API available to its narrowly scoped contents token. @@ -71,11 +81,12 @@ A resumed draft is not assumed to contain only artifacts from the current workfl Immediately after upload and before the draft is published, the workflow therefore fails closed unless all of these conditions hold: -- the local release directory contains exactly one npm `*.tgz`, one Office `*.whl`, and `SHA256SUMS`; +- the local release directory contains exactly one npm `*.tgz`, one Office `*.whl`, `inkspan.spdx.json`, and `SHA256SUMS`; +- `SHA256SUMS` binds the npm tarball, Office wheel, and SBOM digest to the transferred local release set; - the canonical GitHub Releases API still reports the release as a draft; - the sorted remote asset-name set exactly equals the sorted local artifact-name set; - every remote asset reports the `uploaded` state; and -- every GitHub release-asset `sha256:` digest exactly equals a newly computed SHA-256 digest of the corresponding transferred local file. +- every GitHub release-asset `sha256:` digest, including the SBOM digest and checksum-manifest digest, exactly equals a newly computed SHA-256 digest of the corresponding transferred local file. The draft lookup deliberately uses the authenticated, paginated **List releases** REST endpoint and filters its complete result for the exact tag. GitHub documents that authenticated callers with repository push access receive draft releases from this endpoint. The `Get a release by tag name` endpoint is documented for a **published** release, so it is not used as evidence for this pre-publication gate. The publish job fails unless the paginated listing contains exactly one release matching the tag and that object still reports `draft: true`. @@ -91,19 +102,20 @@ Enabling immutable releases is an administrative repository control. Repository ## Published artifacts -Each successful GitHub release contains: +Each successful GitHub release contains exactly four files: - the exact npm tarball produced by `npm pack`; -- the `inkspan-office` wheel built from `office/`; and -- `SHA256SUMS` covering both distributable artifacts. +- the `inkspan-office` wheel built from `office/`; +- `inkspan.spdx.json`, the validated SPDX 2.3 SBOM generated by signature-verified Syft; and +- `SHA256SUMS` covering the npm tarball, Office wheel, and SBOM. -The workflow does not rebuild artifacts after the read-only build job. The same transferred files are checksum-verified, attested, uploaded, inventory-checked against the draft, published to GitHub, and—on stable releases—forwarded to npm and PyPI. +The workflow does not rebuild artifacts after the read-only build job. The same transferred files are checksum-verified, attested, uploaded, inventory-checked against the draft, and published to GitHub; on stable releases, the npm tarball and Office wheel are then forwarded unchanged to npm and PyPI. ## Provenance and verification -The isolated GitHub publication job requests a short-lived OpenID Connect identity and uses GitHub artifact attestations to create signed SLSA provenance for the npm tarball, Office wheel, and checksum manifest. The repository is public, so the attestation is backed by the public Sigstore transparency infrastructure used by GitHub. +The isolated GitHub publication job requests a short-lived OpenID Connect identity and uses GitHub artifact attestations to create signed SLSA provenance for the npm tarball, Office wheel, `inkspan.spdx.json`, and checksum manifest. It also creates SPDX SBOM attestations binding the npm tarball and Office wheel to the validated `inkspan.spdx.json` predicate. The repository is public, so the attestation is backed by the public Sigstore transparency infrastructure used by GitHub. -Consumers should verify release-level and file-level provenance as well as checksums, using the actual version and filenames from the selected release: +Consumers should verify release-level and file-level provenance, the SBOM predicate, and checksums, using the actual version and filenames from the selected release: ```bash VERSION=0.6.0 @@ -113,9 +125,16 @@ gh release verify "v${VERSION}" --repo ContextualWisdomLab/inkspan gh release verify-asset "v${VERSION}" "contextualwisdomlab-cwl-editor-${VERSION}.tgz" \ --repo ContextualWisdomLab/inkspan +gh release verify-asset "v${VERSION}" "inkspan.spdx.json" \ + --repo ContextualWisdomLab/inkspan + gh attestation verify "inkspan_office-${VERSION}-py3-none-any.whl" \ --repo ContextualWisdomLab/inkspan +gh attestation verify "contextualwisdomlab-cwl-editor-${VERSION}.tgz" \ + --repo ContextualWisdomLab/inkspan \ + --predicate-type https://spdx.dev/Document/v2.3 + sha256sum --check SHA256SUMS ``` @@ -134,11 +153,15 @@ npm and PyPI are independent immutable publication domains. If one registry acce ## Workflow security properties - Every third-party GitHub Action is pinned to a complete commit SHA. +- Syft is installed from the exact commit behind v1.50.0 with signed-checksum verification enabled; a mutable branch installer is not part of the supported generator path. +- Only the signature-verified Syft binary generates the release SBOM. - The default and source-bearing build-job workflow tokens are read-only. - GitHub release, OpenID Connect, and attestation permissions are scoped to the source-free jobs that actually require them. - Release tags must identify the exact current protected-main tip. - Stable root, Office, and tag versions must match before registry publication. +- The local and draft release contract is exactly one npm tarball, one Office wheel, `inkspan.spdx.json`, and `SHA256SUMS`. - The draft asset set and every GitHub-reported SHA-256 asset digest must exactly match the transferred local release set before GitHub publication. +- The SBOM digest is covered by `SHA256SUMS`, remote release-asset digest verification, and release provenance; package attestations additionally bind the distributable packages to the SPDX predicate. - The published GitHub release must report an immutable state; a mutable outcome is deleted and rejected. - Existing published assets are never refreshed, replaced, or deleted by a successful workflow path. - Stable npm and PyPI publication uses protected OIDC environments rather than long-lived registry secrets. @@ -161,6 +184,8 @@ The release pipeline does not add runtime coupling. The npm package remains a ho - GitHub release attestation verification: - GitHub artifact attestations: - GitHub artifact-attestation concepts: +- Syft signed-release installer verification: +- Sigstore Cosign installer: - npm Trusted Publishing and automatic provenance: - PyPI Trusted Publishing: - PyPI Trusted Publishing security model: