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 diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index a3eaef4c..f8d014d6 100644 --- a/src/extensions/SafeClipboard.ts +++ b/src/extensions/SafeClipboard.ts @@ -57,17 +57,24 @@ const ERROR_MESSAGES: Readonly> = invalid_configuration: 'Rich clipboard configuration is invalid.', invalid_html: 'Rich clipboard HTML could not be sanitized.', }); +const clipboardSanitizationErrorInstances = new WeakSet(); /** Error whose stable code and message never disclose clipboard content. */ export class ClipboardSanitizationError extends Error { /** Machine-readable rejection category safe for host telemetry. */ readonly code: ClipboardSanitizationErrorCode; + /** Recognize only errors constructed by this module without touching candidates. */ + static [Symbol.hasInstance](candidate: unknown): boolean { + return clipboardSanitizationErrorInstances.has(candidate as object); + } + /** Create one redacted sanitizer error from a stable category. */ constructor(code: ClipboardSanitizationErrorCode) { super(ERROR_MESSAGES[code]); this.name = 'ClipboardSanitizationError'; this.code = code; + clipboardSanitizationErrorInstances.add(this); } } @@ -443,14 +450,30 @@ function normalizedOutputElement(sourceName: string): string | null { return ALLOWED_ELEMENTS.has(normalized) ? normalized : null; } +/** Reject before queued traversal frames can exceed the configured node budget. */ +function assertTraversalCapacity( + stack: TraversalFrame[], + visitedNodes: number, + additionalNodes: number, + maxNodes: number, +): void { + if (additionalNodes > maxNodes - visitedNodes - stack.length) { + throw new ClipboardSanitizationError('node_limit_exceeded'); + } +} + /** Push child frames in reverse so iterative traversal preserves source order. */ function pushChildren( stack: TraversalFrame[], sourceNode: globalThis.Node, outputParent: globalThis.Node, depth: number, + visitedNodes: number, + maxNodes: number, ): void { - for (let index = sourceNode.childNodes.length - 1; index >= 0; index -= 1) { + const childCount = sourceNode.childNodes.length; + assertTraversalCapacity(stack, visitedNodes, childCount, maxNodes); + for (let index = childCount - 1; index >= 0; index -= 1) { const child = sourceNode.childNodes.item(index); if (child) stack.push({ sourceNode: child, outputParent, depth }); } @@ -462,10 +485,13 @@ function pushClosedDetailsSummary( sourceElement: Element, outputParent: globalThis.Node, depth: number, + visitedNodes: number, + maxNodes: number, ): void { for (let index = 0; index < sourceElement.children.length; index += 1) { const child = sourceElement.children.item(index); if (child?.localName.toLowerCase() !== 'summary') continue; + assertTraversalCapacity(stack, visitedNodes, 1, maxNodes); stack.push({ sourceNode: child, outputParent, depth }); return; } @@ -487,6 +513,7 @@ export function sanitizeRichClipboardHtml( throw new ClipboardSanitizationError('invalid_html'); } if ( + sourceHtml.length > resolvedConfig.maxHtmlBytes || new TextEncoder().encode(sourceHtml).byteLength > resolvedConfig.maxHtmlBytes ) { throw new ClipboardSanitizationError('input_too_large'); @@ -504,7 +531,14 @@ export function sanitizeRichClipboardHtml( sourceTemplate.innerHTML = sourceHtml; const outputContainer = inertDocument.createElement('div'); const stack: TraversalFrame[] = []; - pushChildren(stack, sourceTemplate.content, outputContainer, 1); + pushChildren( + stack, + sourceTemplate.content, + outputContainer, + 1, + 0, + resolvedConfig.maxNodes, + ); let visitedNodes = 0; while (stack.length > 0) { @@ -512,9 +546,6 @@ export function sanitizeRichClipboardHtml( /* v8 ignore next -- stack length guarantees a frame. */ if (!frame) continue; visitedNodes += 1; - if (visitedNodes > resolvedConfig.maxNodes) { - throw new ClipboardSanitizationError('node_limit_exceeded'); - } if (frame.depth > resolvedConfig.maxDepth) { throw new ClipboardSanitizationError('depth_limit_exceeded'); } @@ -544,6 +575,8 @@ export function sanitizeRichClipboardHtml( sourceElement, frame.outputParent, frame.depth + 1, + visitedNodes, + resolvedConfig.maxNodes, ); continue; } @@ -572,7 +605,14 @@ export function sanitizeRichClipboardHtml( } if (sourceName !== 'br' && sourceName !== 'hr') { - pushChildren(stack, sourceElement, childParent, frame.depth + 1); + pushChildren( + stack, + sourceElement, + childParent, + frame.depth + 1, + visitedNodes, + resolvedConfig.maxNodes, + ); } } return outputContainer.innerHTML; diff --git a/src/extensions/SafeClipboardExtension.proxyFailure.test.ts b/src/extensions/SafeClipboardExtension.proxyFailure.test.ts new file mode 100644 index 00000000..a37f26be --- /dev/null +++ b/src/extensions/SafeClipboardExtension.proxyFailure.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_CLIPBOARD_HTML_BYTES, + DEFAULT_CLIPBOARD_MAX_DEPTH, + DEFAULT_CLIPBOARD_MAX_NODES, + sanitizeRichClipboardHtml, + type ClipboardConfig, + type ClipboardSanitizationError, +} from './SafeClipboard.js'; +import { + SafeClipboard, + type SafeClipboardOptions, +} from './SafeClipboardExtension.js'; + +describe('SafeClipboard hostile thrown-value containment', () => { + it('fails closed without inspecting the prototype of an unknown adapter failure', () => { + const privateSentinel = new Error('private adapter prototype sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy(Object.create(null) as object, { + getPrototypeOf, + }); + const onError = vi.fn((_error: ClipboardSanitizationError) => undefined); + const hostileOptions = { + get config() { + throw hostileThrownValue; + }, + maxHtmlBytes: DEFAULT_CLIPBOARD_HTML_BYTES, + maxNodes: DEFAULT_CLIPBOARD_MAX_NODES, + maxDepth: DEFAULT_CLIPBOARD_MAX_DEPTH, + onError, + document, + } as SafeClipboardOptions; + const addPlugins = SafeClipboard.config.addProseMirrorPlugins; + if (!addPlugins) throw new Error('SafeClipboard plugin factory is unavailable'); + const plugins = addPlugins.call({ options: hostileOptions } as never); + const plugin = plugins[0]; + const transform = plugin?.props.transformPastedHTML; + if (!plugin || !transform) { + throw new Error('SafeClipboard paste transform is unavailable'); + } + + let transformed: string | undefined; + expect(() => { + transformed = transform.call(plugin, '

private source

', {} as never); + }).not.toThrow(); + expect(transformed).toBe(''); + expect(getPrototypeOf).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'invalid_html', + message: 'Rich clipboard HTML could not be sanitized.', + }), + ); + }); + + it('normalizes hostile sanitizer configuration failures without prototype inspection', () => { + const privateSentinel = new Error('private sanitizer prototype sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy(Object.create(null) as object, { + getPrototypeOf, + }); + const hostileConfig = new Proxy(Object.create(null) as ClipboardConfig, { + ownKeys() { + throw hostileThrownValue; + }, + }); + + let observed: unknown; + try { + sanitizeRichClipboardHtml('

private source

', hostileConfig, document); + } catch (error) { + observed = error; + } + + expect(getPrototypeOf).not.toHaveBeenCalled(); + expect(observed).toEqual( + expect.objectContaining({ + name: 'ClipboardSanitizationError', + code: 'invalid_configuration', + message: 'Rich clipboard configuration is invalid.', + }), + ); + }); +}); diff --git a/src/extensions/SafeClipboardPreflightSize.test.ts b/src/extensions/SafeClipboardPreflightSize.test.ts new file mode 100644 index 00000000..5dd1b504 --- /dev/null +++ b/src/extensions/SafeClipboardPreflightSize.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; + +describe('rich clipboard size preflight', () => { + it('rejects an obviously oversized string before allocating a UTF-8 copy', () => { + const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode'); + + try { + expect(() => + sanitizeRichClipboardHtml('x'.repeat(9), { maxHtmlBytes: 8 }, document), + ).toThrowError( + expect.objectContaining({ + code: 'input_too_large', + message: 'Rich clipboard HTML exceeds the configured byte limit.', + }), + ); + expect(encodeSpy).not.toHaveBeenCalled(); + } finally { + encodeSpy.mockRestore(); + } + }); +}); diff --git a/src/extensions/SafeClipboardTraversalBudget.test.ts b/src/extensions/SafeClipboardTraversalBudget.test.ts new file mode 100644 index 00000000..cd682827 --- /dev/null +++ b/src/extensions/SafeClipboardTraversalBudget.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { sanitizeRichClipboardHtml } from './SafeClipboard.js'; + +describe('rich clipboard traversal budget', () => { + it('rejects a broad source before materializing children beyond maxNodes', () => { + const originalItem = NodeList.prototype.item; + let broadChildReads = 0; + const itemSpy = vi + .spyOn(NodeList.prototype, 'item') + .mockImplementation(function (this: NodeList, index: number) { + if (this.length === 3) broadChildReads += 1; + return originalItem.call(this, index); + }); + + try { + expect(() => + sanitizeRichClipboardHtml( + '

A

B

C

', + { maxNodes: 2 }, + document, + ), + ).toThrowError( + expect.objectContaining({ + code: 'node_limit_exceeded', + message: 'Rich clipboard HTML exceeds the configured node limit.', + }), + ); + expect(broadChildReads).toBe(0); + } finally { + itemSpy.mockRestore(); + } + }); +}); 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(); 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'");