diff --git a/scripts/docs-semantic-core.mjs b/scripts/docs-semantic-core.mjs index 199d685..d90127b 100644 --- a/scripts/docs-semantic-core.mjs +++ b/scripts/docs-semantic-core.mjs @@ -1212,13 +1212,27 @@ function describeValue(value) { } function normalizeHeadingText(value) { - return value + return stripHeadingTags(value) .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - .replace(/<[^>]*>/g, '') .replace(/[`*_~]/g, '') .trim(); } +function stripHeadingTags(value) { + let result = ''; + let insideTag = false; + for (const character of value) { + if (character === '<') { + insideTag = true; + } else if (character === '>') { + insideTag = false; + } else if (!insideTag) { + result += character; + } + } + return result; +} + function slugifyHeading(value) { return value .toLowerCase() diff --git a/scripts/release-artifact-core.mjs b/scripts/release-artifact-core.mjs index e100ec3..b4619ec 100644 --- a/scripts/release-artifact-core.mjs +++ b/scripts/release-artifact-core.mjs @@ -199,7 +199,14 @@ export function normalizeNpmViewPublicationState(value) { } export function inspectNpmAttestations(value, { packageName, version }) { - const expectedSuffix = `${encodeURIComponent(packageName).replace('%40', '@')}@${version}`; + const packageNamePattern = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/; + const validPackageName = + typeof packageName === 'string' && + packageName.length <= 214 && + packageNamePattern.test(packageName); + const validVersion = typeof version === 'string' && /^\d+\.\d+\.\d+$/.test(version); + const expectedIdentity = + validPackageName && validVersion ? `${packageName}@${version}` : null; const entries = Array.isArray(value) ? value : [value]; const attestation = entries.length === 1 ? entries[0] : null; const url = attestation?.url; @@ -208,11 +215,10 @@ export function inspectNpmAttestations(value, { packageName, version }) { if (entries.length !== 1) { errors.push(`npm view must return exactly one attestation, received ${entries.length}`); } - if ( - typeof url !== 'string' || - !url.startsWith('https://registry.npmjs.org/-/npm/v1/attestations/') || - !decodeURIComponent(url).endsWith(expectedSuffix) - ) { + if (!validPackageName || !validVersion) { + errors.push('npm attestation identity must use an exact package name and semantic version'); + } + if (!isExactNpmAttestationUrl(url, expectedIdentity)) { errors.push('npm attestation URL does not identify the exact package version'); } if (predicateType !== 'https://slsa.dev/provenance/v1') { @@ -226,6 +232,31 @@ export function inspectNpmAttestations(value, { packageName, version }) { }; } +function isExactNpmAttestationUrl(value, expectedIdentity) { + if (typeof value !== 'string' || expectedIdentity == null) { + return false; + } + try { + const parsed = new URL(value); + const pathPrefix = '/-/npm/v1/attestations/'; + if ( + parsed.protocol !== 'https:' || + parsed.hostname !== 'registry.npmjs.org' || + parsed.port !== '' || + parsed.username !== '' || + parsed.password !== '' || + parsed.search !== '' || + parsed.hash !== '' || + !parsed.pathname.startsWith(pathPrefix) + ) { + return false; + } + return decodeURIComponent(parsed.pathname.slice(pathPrefix.length)) === expectedIdentity; + } catch { + return false; + } +} + function publicationFailure( version, artifactIntegrity, diff --git a/test/docsSemantic.test.mjs b/test/docsSemantic.test.mjs index ec1bc90..a763685 100644 --- a/test/docsSemantic.test.mjs +++ b/test/docsSemantic.test.mjs @@ -494,4 +494,18 @@ describe('documentation semantic gate', () => { { text: 'A.B', anchor: 'ab-1' }, ]); }); + + it('removes nested heading tags without producing a new tag-shaped substring', () => { + const headings = parseHeadings( + '# API\n\n## <\n\n## Before and after\n\n## Unclosed ipt>' + ); + expect(headings).toEqual([ + { text: 'API', anchor: 'api' }, + { text: 'alert', anchor: 'alert' }, + { text: 'Before and after', anchor: 'before-and-after' }, + { text: 'Unclosed', anchor: 'unclosed' }, + { text: 'ipt', anchor: 'ipt' }, + ]); + expect(headings.every(({ text }) => !/[<>]/u.test(text))).toBe(true); + }); }); diff --git a/test/releaseArtifact.test.mjs b/test/releaseArtifact.test.mjs index 0f231fe..5566cfe 100644 --- a/test/releaseArtifact.test.mjs +++ b/test/releaseArtifact.test.mjs @@ -144,4 +144,49 @@ describe('release artifact contract', () => { error: expect.stringContaining('exactly one attestation'), }); }); + + it('parses the exact npm attestation URL identity without substring sanitization', () => { + const provenance = { predicateType: 'https://slsa.dev/provenance/v1' }; + expect( + inspectNpmAttestations( + { + url: 'https://registry.npmjs.org/-/npm/v1/attestations/%40scope%2Fpackage@1.2.3', + provenance, + }, + { packageName: '@scope/package', version: '1.2.3' } + ) + ).toMatchObject({ status: 'passed' }); + + for (const url of [ + 'https://registry.npmjs.org/-/npm/v1/attestations/evil-package@1.2.3', + 'https://registry.npmjs.org/-/npm/v1/attestations/package@1.2.3?identity=package@1.2.3', + 'https://registry.npmjs.org/-/npm/v1/attestations/package@1.2.3#package@1.2.3', + 'https://registry.npmjs.org.evil.example/-/npm/v1/attestations/package@1.2.3', + 'https://package@registry.npmjs.org/-/npm/v1/attestations/package@1.2.3', + 'https://registry.npmjs.org/-/npm/v1/attestations/%E0%A4%A', + ]) { + expect( + inspectNpmAttestations( + { url, provenance }, + { packageName: 'package', version: '1.2.3' } + ) + ).toMatchObject({ + status: 'failed', + error: expect.stringContaining('exact package version'), + }); + } + + expect( + inspectNpmAttestations( + { + url: 'https://registry.npmjs.org/-/npm/v1/attestations/%2540scope%252Fpackage@1.2.3', + provenance, + }, + { packageName: '@scope/package', version: '1.2.3' } + ) + ).toMatchObject({ + status: 'failed', + error: expect.stringContaining('exact package version'), + }); + }); });