diff --git a/scripts/zap-json-to-sarif.mjs b/scripts/zap-json-to-sarif.mjs index 2c3ccc63..580834da 100644 --- a/scripts/zap-json-to-sarif.mjs +++ b/scripts/zap-json-to-sarif.mjs @@ -141,8 +141,32 @@ function resultMessage(alert, instance) { return parts.join('\n\n'); } +/** + * Decompose a raw URI from ZAP into the SARIF artifactLocation shape that + * GitHub Code Scanning will accept. Code Scanning rejects absolute http(s) + * URIs because they don't match the `file://` checkout scheme, so we strip + * the origin and store it in `originalUriBaseIds` at the run level instead. + * + * Returns `{ uri, uriBaseId? }` — callers must include the returned object as + * the `artifactLocation` and register the origin in `originalUriBaseIds`. + */ +function resolveArtifactLocation(rawUri) { + try { + const parsed = new URL(rawUri); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + // Relative path (without leading slash) + uriBaseId referencing the origin. + const relative = (parsed.pathname + parsed.search + parsed.hash).replace(/^\//, ''); + return { uri: relative || '', uriBaseId: 'TARGET', origin: parsed.origin + '/' }; + } + } catch { + // Not a URL — fall through and return as-is. + } + return { uri: rawUri }; +} + function buildResult(alert, instance, siteName) { - const uri = instance.uri || instance.nodeName || siteName || 'zap-target'; + const rawUri = instance.uri || instance.nodeName || siteName || 'zap-target'; + const { uri, uriBaseId, origin } = resolveArtifactLocation(rawUri); const properties = { confidence: alert.confidence, risk: alert.riskdesc, @@ -165,6 +189,7 @@ function buildResult(alert, instance, siteName) { physicalLocation: { artifactLocation: { uri, + ...(uriBaseId ? { uriBaseId } : {}), }, }, }, @@ -172,6 +197,8 @@ function buildResult(alert, instance, siteName) { properties: Object.fromEntries( Object.entries(properties).filter(([, value]) => value !== undefined && value !== ''), ), + // Carry the origin through so convertZapJsonToSarif can collect it. + _origin: origin, }; } @@ -197,6 +224,20 @@ export function convertZapJsonToSarif(zapReport) { } } + // Collect distinct http(s) origins from results so we can populate + // originalUriBaseIds. SARIF spec §3.14.14 requires this for uriBaseId + // references to resolve — GitHub Code Scanning rejects bare http URIs. + const origins = [...new Set(results.map((r) => r._origin).filter(Boolean))]; + const originalUriBaseIds = + origins.length > 0 + ? Object.fromEntries( + origins.map((origin, i) => [`TARGET${i > 0 ? `_${i}` : ''}`, { uri: origin }]), + ) + : undefined; + + // Strip the internal _origin carrier before serialising. + const cleanResults = results.map(({ _origin: _unused, ...rest }) => rest); + return { version: '2.1.0', $schema: SARIF_SCHEMA, @@ -212,7 +253,8 @@ export function convertZapJsonToSarif(zapReport) { automationDetails: { id: 'zap-baseline', }, - results, + ...(originalUriBaseIds ? { originalUriBaseIds } : {}), + results: cleanResults, }, ], }; diff --git a/scripts/zap-json-to-sarif.test.mjs b/scripts/zap-json-to-sarif.test.mjs index ece00615..3a18f7ec 100644 --- a/scripts/zap-json-to-sarif.test.mjs +++ b/scripts/zap-json-to-sarif.test.mjs @@ -75,10 +75,15 @@ describe('zap-json-to-sarif', () => { assert.equal(sarif.runs[0].results.length, 2); assert.equal(sarif.runs[0].results[0].ruleId, '10055-6'); assert.equal(sarif.runs[0].results[0].level, 'warning'); + // http URIs must be relativised: origin goes into originalUriBaseIds, path into uri. + assert.equal(sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uri, ''); assert.equal( - sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uri, - 'http://localhost:3333/', + sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uriBaseId, + 'TARGET', ); + assert.deepEqual(sarif.runs[0].originalUriBaseIds, { + TARGET: { uri: 'http://localhost:3333/' }, + }); assert.match(sarif.runs[0].results[0].message.text, /Evidence:/); }); @@ -130,10 +135,18 @@ describe('zap-json-to-sarif', () => { assert.equal(sarif.runs[0].tool.driver.rules.length, 1); assert.equal(sarif.runs[0].results.length, 1); assert.equal(sarif.runs[0].results[0].ruleId, 'singleton-alert'); + // http URI → relative path + uriBaseId; origin hoisted to originalUriBaseIds. assert.equal( sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uri, - 'http://localhost:3333/singleton', + 'singleton', + ); + assert.equal( + sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uriBaseId, + 'TARGET', ); + assert.deepEqual(sarif.runs[0].originalUriBaseIds, { + TARGET: { uri: 'http://localhost:3333/' }, + }); }); test('suppresses invalid CWE and WASC tags', () => { @@ -178,11 +191,27 @@ describe('zap-json-to-sarif', () => { ], }); + // http URIs are relativised; non-http fallbacks ('zap-target') pass through unchanged. assert.deepEqual( sarif.runs[0].results.map( (result) => result.locations[0].physicalLocation.artifactLocation.uri, ), - ['http://fallback.example/node', 'http://fallback.example', 'zap-target'], + ['node', '', 'zap-target'], + ); + assert.deepEqual( + sarif.runs[0].results.map( + (result) => result.locations[0].physicalLocation.artifactLocation.uriBaseId, + ), + ['TARGET', 'TARGET', undefined], + ); + // Both http results share the same origin so only one key is needed. + assert.deepEqual(sarif.runs[0].originalUriBaseIds, { + TARGET: { uri: 'http://fallback.example/' }, + }); + // The non-http result must not carry a uriBaseId at all. + assert.equal( + 'uriBaseId' in sarif.runs[0].results[2].locations[0].physicalLocation.artifactLocation, + false, ); });