diff --git a/backend/controllers/scanController.js b/backend/controllers/scanController.js index 34cd434..2984860 100644 --- a/backend/controllers/scanController.js +++ b/backend/controllers/scanController.js @@ -9,6 +9,8 @@ * See docs/guides/axecore-integration.md for the bug pattern this * sidesteps. */ +const { safeUrl } = require('../services/redactUrl'); + class ScanController { /** * @param {object} deps @@ -46,7 +48,7 @@ class ScanController { return res.status(400).json({ error: guard.reason }); } } - console.log(`Received scan request for URL: ${url}`); + console.log(`Received scan request for URL: ${safeUrl(url)}`); try { const result = await this.scanRunner.run(url); @@ -119,7 +121,7 @@ class ScanController { return res.status(400).json({ error: guard.reason }); } } - console.log(`Received request for scan results of URL: ${url}`); + console.log(`Received request for scan results of URL: ${safeUrl(url)}`); try { const result = await this.scanRunner.getResults(url); if (result === null) { diff --git a/backend/services/redactUrl.js b/backend/services/redactUrl.js new file mode 100644 index 0000000..6f4dfa1 --- /dev/null +++ b/backend/services/redactUrl.js @@ -0,0 +1,32 @@ +/** + * Log-safe URLs. A scanned reset link or signed URL carries its token in the + * query string (or fragment), so logs keep the origin and path only. Pure — no + * side effects. + */ + +/** + * Origin + path of a URL, with query, fragment and any `user:pass@` dropped. + * @param {unknown} url + * @returns {string} + */ +function safeUrl(url) { + try { + const parsed = new URL(String(url)); + return parsed.origin + parsed.pathname; + } catch { + // Unparseable input still gets its query/fragment cut. + return String(url ?? '').split(/[?#]/)[0]; + } +} + +/** + * Apply `safeUrl` to every http(s) URL inside free text, e.g. a Puppeteer error + * like `net::ERR_NAME_NOT_RESOLVED at https://x/?token=…`. + * @param {unknown} text + * @returns {string} + */ +function scrubUrls(text) { + return String(text ?? '').replace(/https?:\/\/\S+/gi, (match) => safeUrl(match)); +} + +module.exports = { safeUrl, scrubUrls }; diff --git a/backend/services/scanRunner.js b/backend/services/scanRunner.js index fc5a370..b3725a2 100644 --- a/backend/services/scanRunner.js +++ b/backend/services/scanRunner.js @@ -17,6 +17,7 @@ const fs = require('node:fs'); const defaultValidate = require('./ssrfGuard').validate; const { transform: defaultTransform } = require('./axeTransformer'); +const { safeUrl, scrubUrls } = require('./redactUrl'); const defaultAxe = require('axe-core'); /** @@ -75,7 +76,12 @@ async function resolveBrowser(injected) { * shipped to an aggregator as-is. */ function logScan(fields) { - const line = JSON.stringify({ event: 'scan', ...fields }); + const line = JSON.stringify({ + event: 'scan', + ...fields, + url: safeUrl(fields.url), + ...(fields.error !== undefined && { error: scrubUrls(fields.error) }), + }); if (fields.outcome === 'ok') console.log(line); else console.error(line); } diff --git a/backend/tests/redactUrl.test.js b/backend/tests/redactUrl.test.js new file mode 100644 index 0000000..cbd443c --- /dev/null +++ b/backend/tests/redactUrl.test.js @@ -0,0 +1,19 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { safeUrl, scrubUrls } = require('../services/redactUrl'); + +test('safeUrl keeps origin + path and drops query, fragment and credentials', () => { + assert.equal(safeUrl('https://a.test/reset?token=x#y'), 'https://a.test/reset'); + assert.equal(safeUrl('https://me:pw@a.test/p'), 'https://a.test/p'); + assert.equal(safeUrl('https://a.test:8443/'), 'https://a.test:8443/'); + assert.equal(safeUrl('not a url?token=x'), 'not a url'); + assert.equal(safeUrl(undefined), ''); +}); + +test('scrubUrls redacts every URL inside free text', () => { + assert.equal( + scrubUrls('failed at https://a.test/x?sig=1 then http://b.test/?t=2'), + 'failed at https://a.test/x then http://b.test/', + ); + assert.equal(scrubUrls('no urls here'), 'no urls here'); +}); diff --git a/backend/tests/scan.test.js b/backend/tests/scan.test.js index bd31db3..d6c2117 100644 --- a/backend/tests/scan.test.js +++ b/backend/tests/scan.test.js @@ -508,3 +508,20 @@ test('deleteSavedScan returns 404 for SCAN_NOT_FOUND', async () => { ); assert.equal(out.statusCode, 404); }); + +test('scan request logs keep origin + path only', async (t) => { + const logged = []; + t.mock.method(console, 'log', (...args) => logged.push(args.join(' '))); + const app = createTestApp(); + const secretUrl = 'https://example.com/reset?token=s3cret'; + + await request(app).post('/api/scan').send({ url: secretUrl }); + await request(app).get('/api/scan-results').query({ url: secretUrl }); + + const requestLines = logged.filter((l) => l.startsWith('Received')); + assert.equal(requestLines.length, 2); + for (const line of requestLines) { + assert.match(line, /https:\/\/example\.com\/reset$/); + assert.doesNotMatch(line, /s3cret/); + } +}); diff --git a/backend/tests/scanRunner.test.js b/backend/tests/scanRunner.test.js index cb6c7ca..2bd5854 100644 --- a/backend/tests/scanRunner.test.js +++ b/backend/tests/scanRunner.test.js @@ -165,5 +165,36 @@ test('run emits one structured log line per scan (ok and error)', async (t) => { assert.equal(typeof lines[0].durationMs, 'number'); assert.equal(lines[1].outcome, 'error'); assert.equal(lines[1].errorClass, 'TimeoutError'); - assert.equal(lines[1].url, 'https://example.com'); + assert.equal(lines[1].url, 'https://example.com/'); +}); + +test('scan log keeps origin + path only, in the url and in error text', async (t) => { + const lines = []; + t.mock.method(console, 'log', (l) => lines.push(JSON.parse(l))); + t.mock.method(console, 'error', (l) => lines.push(JSON.parse(l))); + const secretUrl = 'https://user:pw@example.com/reset?token=s3cret#frag'; + + const ok = new ScanRunner({ + puppeteer: createMockPuppeteer().puppeteer, + axe: { source: '' }, + transform: () => ({ problems: {}, whatsGood: [] }), + validate: () => ({ ok: true }), + }); + await ok.run(secretUrl); + + // Puppeteer navigation errors quote the URL they failed on. + const navErr = new Error(`net::ERR_NAME_NOT_RESOLVED at ${secretUrl}`); + const bad = new ScanRunner({ + puppeteer: createMockPuppeteer({ evaluateError: navErr }).puppeteer, + axe: { source: '' }, + validate: () => ({ ok: true }), + }); + await bad.run(secretUrl); + + assert.equal(lines[0].url, 'https://example.com/reset'); + assert.equal(lines[1].url, 'https://example.com/reset'); + assert.equal(lines[1].error, 'net::ERR_NAME_NOT_RESOLVED at https://example.com/reset'); + for (const line of lines) { + assert.doesNotMatch(JSON.stringify(line), /s3cret|frag|user:pw/); + } });