diff --git a/src/rules/alt-text-quality.ts b/src/rules/alt-text-quality.ts index 8cf3825..5fe6e50 100644 --- a/src/rules/alt-text-quality.ts +++ b/src/rules/alt-text-quality.ts @@ -5,6 +5,7 @@ import {createJudge} from '../judges/index.js' import type {JudgeAltText, JudgeVerdict} from '../judges/index.js' import type {Rule, RuleContext, RuleResult, ImageRecord} from '../types.js' import {loadImageAsDataUrl} from '../utils/load-image-data-url.js' +import {redactUrl} from '../utils/redact-url.js' // Lazily build the judge so missing tokens surface only when the rule actually // runs @@ -47,7 +48,7 @@ function buildContextString(image: ImageRecord, pageUrl: string): string { if (image.pageTitle) parts.push(`Page title: ${JSON.stringify(image.pageTitle)}`) if (image.sectionHeading) parts.push(`Nearest heading above the image: ${JSON.stringify(image.sectionHeading)}`) parts.push(`Image HTML: ${sanitizeImageHtml(image.outerHTML)}`) - if (image.inLink) parts.push(`The image is inside a link with href="${image.inLink.href}".`) + if (image.inLink) parts.push(`The image is inside a link with href="${redactUrl(image.inLink.href)}".`) if (image.inButton) parts.push('The image is inside a button (or role="button" element).') if (image.figcaption) parts.push(`Adjacent figcaption: ${JSON.stringify(image.figcaption)}`) if (image.nearbyText) parts.push(`Surrounding body text: ${JSON.stringify(image.nearbyText)}`) @@ -102,7 +103,8 @@ export const altTextQuality: Rule = { try { dataUrl = await loadImageAsDataUrl(resolved) } catch (err) { - console.error(`[alt-text-quality] failed to load ${resolved}:`, err) + const msg = (err instanceof Error ? err.message : String(err)).replace(/([?#])[^\s]*/g, '$1…') + console.error(`[alt-text-quality] failed to load ${redactUrl(resolved)}: ${redactUrl(msg)}`) continue } @@ -116,7 +118,8 @@ export const altTextQuality: Rule = { naturalHeight: image.naturalHeight, }) } catch (err) { - console.error(`[alt-text-quality] judge failed for ${resolved}:`, err) + const msg = (err instanceof Error ? err.message : String(err)).replace(/([?#])[^\s]*/g, '$1…') + console.error(`[alt-text-quality] judge failed for ${redactUrl(resolved)}: ${redactUrl(msg)}`) continue } diff --git a/src/utils/redact-url.ts b/src/utils/redact-url.ts new file mode 100644 index 0000000..f2a6624 --- /dev/null +++ b/src/utils/redact-url.ts @@ -0,0 +1,15 @@ +// Strips the query string and fragment from a URL so potentially sensitive +// values (signed-CDN tokens, session ids, user identifiers) are not forwarded +// to the model context or written to CI logs. Origin + path are preserved for +// debuggability. Inputs that don't parse as URLs are returned with anything +// after '?' or '#' dropped as a fallback. +export function redactUrl(url: string): string { + try { + const u = new URL(url) + u.search = '' + u.hash = '' + return u.toString() + } catch { + return url.split(/[?#]/)[0] ?? url + } +} diff --git a/tests/unit/alt-text-quality.test.ts b/tests/unit/alt-text-quality.test.ts index d179fa8..ba0d13a 100644 --- a/tests/unit/alt-text-quality.test.ts +++ b/tests/unit/alt-text-quality.test.ts @@ -125,4 +125,20 @@ describe('alt-text-quality', () => { expect(context).toContain('src="(omitted)"') expect(context).toContain('srcset="(omitted)"') }) + + it('redacts query/fragment from the link href in the judge context', async () => { + const fake = new FakeJudge(() => verdict()) + __setJudge(fake) + await run([ + makeImage({ + src: DATA_URL, + alt: 'a dog', + inLink: {href: 'https://example.com/page?sig=secret123#frag'}, + }), + ]) + const context = fake.calls[0]!.context + expect(context).not.toContain('sig=secret123') + expect(context).not.toContain('#frag') + expect(context).toContain('https://example.com/page') + }) }) diff --git a/tests/unit/redact-url.test.ts b/tests/unit/redact-url.test.ts new file mode 100644 index 0000000..427c317 --- /dev/null +++ b/tests/unit/redact-url.test.ts @@ -0,0 +1,17 @@ +import {describe, it, expect} from 'vitest' +import {redactUrl} from '../../src/utils/redact-url.js' + +describe('redactUrl', () => { + it('strips the query string', () => { + expect(redactUrl('https://cdn.example.com/img.png?sig=secret123')).toBe('https://cdn.example.com/img.png') + }) + it('strips the fragment', () => { + expect(redactUrl('https://example.com/page#section')).toBe('https://example.com/page') + }) + it('leaves a clean URL unchanged', () => { + expect(redactUrl('https://example.com/img.png')).toBe('https://example.com/img.png') + }) + it('falls back gracefully on non-URL input', () => { + expect(redactUrl('not a url?x=1')).toBe('not a url') + }) +})