Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions scripts/zap-json-to-sarif.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -165,13 +189,16 @@ function buildResult(alert, instance, siteName) {
physicalLocation: {
artifactLocation: {
uri,
...(uriBaseId ? { uriBaseId } : {}),
},
},
},
],
properties: Object.fromEntries(
Object.entries(properties).filter(([, value]) => value !== undefined && value !== ''),
),
// Carry the origin through so convertZapJsonToSarif can collect it.
_origin: origin,
};
}

Expand All @@ -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,
Expand All @@ -212,7 +253,8 @@ export function convertZapJsonToSarif(zapReport) {
automationDetails: {
id: 'zap-baseline',
},
results,
...(originalUriBaseIds ? { originalUriBaseIds } : {}),
results: cleanResults,
},
],
};
Expand Down
37 changes: 33 additions & 4 deletions scripts/zap-json-to-sarif.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:/);
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
);
});

Expand Down
Loading