Skip to content
Closed
94 changes: 82 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
)
Expand All @@ -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
Expand All @@ -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 }}
Expand Down Expand Up @@ -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
)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -646,4 +716,4 @@ jobs:
done

echo "::error::Registry publication verification did not converge to the exact artifact digests."
exit 1
exit 1
52 changes: 46 additions & 6 deletions src/extensions/SafeClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,24 @@ const ERROR_MESSAGES: Readonly<Record<ClipboardSanitizationErrorCode, string>> =
invalid_configuration: 'Rich clipboard configuration is invalid.',
invalid_html: 'Rich clipboard HTML could not be sanitized.',
});
const clipboardSanitizationErrorInstances = new WeakSet<object>();

/** 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not merge this Symbol.hasInstance helper as written. WeakSet.prototype.has throws TypeError for primitives (null, strings, numbers). A hostile or accidental throw "x" on this path therefore escapes the sanitizer catch instead of becoming redacted invalid_html.

Default instanceof already returns false for primitives. If this brand check stays, guard first:

if (candidate === null || (typeof candidate !== "object" && typeof candidate !== "function")) {
  return false;
}
return clipboardSanitizationErrorInstances.has(candidate);

Hostile-throw containment on current main is owned by Draft #351. Resource preflight (#163, #327) is rebased onto current main in the successor opened from this run. Keep this stale branch unmerged.

}

/** 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);
}
}

Expand Down Expand Up @@ -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 });
}
Expand All @@ -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;
}
Expand All @@ -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');
Expand All @@ -504,17 +531,21 @@ 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) {
const frame = stack.pop();
/* 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');
}
Expand Down Expand Up @@ -544,6 +575,8 @@ export function sanitizeRichClipboardHtml(
sourceElement,
frame.outputParent,
frame.depth + 1,
visitedNodes,
resolvedConfig.maxNodes,
);
continue;
}
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading