-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(workflows): add rendering of SBOM diffs to Markdown/HTML
related to camunda/camunda-bpm-platform#2781
- Loading branch information
1 parent
a6e35d3
commit 3a28741
Showing
7 changed files
with
297 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
const handlebars = require('handlebars'); | ||
const marked = require('marked'); | ||
const createDOMPurify = require('dompurify'); | ||
const { JSDOM } = require('jsdom'); | ||
|
||
// Emoji lookup table: https://www.quackit.com/character_sets/emoji/emoji_v3.0/unicode_emoji_v3.0_characters_all.cfm | ||
// We are using the unicode emoji codes instead of github emoji codes (e.g. ':warning:'), because | ||
// those can also be interpreted by tools outside of github (e.g. browsers) | ||
const licenseEmojis = { | ||
Go: '✔', // heavy check mark | ||
Caution: '⚠', // warning sign | ||
Stop: '❌', // cross mark | ||
Unknown: '❓' // question mark | ||
}; | ||
|
||
const changeTypeEmojis = { | ||
Upgraded: '⬆', // up arrow | ||
Downgraded: '⬇', // down arrow | ||
DependenciesChanged: '🔄', // anticlockwise arrows button | ||
Unknown: '🤷', // person shrugging | ||
Added: '➕', // plus sign | ||
Removed: '➖' // minus sign | ||
}; | ||
|
||
// true limit is 65536, but let's be a bit defensive, at least in Java some | ||
// unicode characters technically count as two | ||
const GITHUB_COMMENT_CHARACTER_LIMIT = 65000; | ||
|
||
module.exports = async function (sbomDiff, template, partials = {}) { | ||
|
||
handlebars.registerPartial(partials); | ||
|
||
// A weakly unique identifier for this template rendering invocation. | ||
// Can be used for example to generate unique anchors in the template | ||
// across multiple uses of the template. | ||
const renderId = Date.now() % 10000; | ||
handlebars.registerHelper('renderId', function() { | ||
return renderId; | ||
}); | ||
|
||
handlebars.registerHelper('urlEncode', function(value) { | ||
return encodeURIComponent(value); | ||
}); | ||
|
||
handlebars.registerHelper('increment', function(value) { | ||
return value + 1; | ||
}); | ||
|
||
// stores if the currently rendered item is the last one for each | ||
// of the nested lists | ||
const currentIndentationState = []; | ||
|
||
handlebars.registerHelper('indent', function(times, lastItemInList) { | ||
|
||
while (times > currentIndentationState.length) { | ||
currentIndentationState.push(false); | ||
} | ||
while (times < currentIndentationState.length) { | ||
currentIndentationState.pop(); | ||
} | ||
|
||
currentIndentationState[times - 1] = lastItemInList; | ||
|
||
const visualizationComponents = currentIndentationState.map((lastItemInListAtCurrentLevel, index) => { | ||
const isMostDeeplyNestedList = index == currentIndentationState.length - 1; | ||
|
||
if (lastItemInListAtCurrentLevel) { | ||
return isMostDeeplyNestedList ? ' └─ ' : ' '; | ||
} else { | ||
return isMostDeeplyNestedList ? ' ├─ ' : ' │ '; | ||
} | ||
}); | ||
|
||
return visualizationComponents.join(''); | ||
}); | ||
|
||
handlebars.registerHelper('emojifyLicense', function(licenseType) { | ||
return new handlebars.SafeString(licenseEmojis[licenseType]); | ||
}); | ||
|
||
handlebars.registerHelper('emojifyChangeType', function(changeType) { | ||
return new handlebars.SafeString(changeTypeEmojis[changeType]); | ||
}); | ||
|
||
handlebars.registerHelper('isDowngrade', function(changeType) { | ||
return changeType === 'Downgraded'; | ||
}); | ||
|
||
handlebars.registerHelper('hasChanges', function(componentDiff) { | ||
return Object.keys(componentDiff.changedDependencies).length != 0 || | ||
Object.keys(componentDiff.addedDependencies).length != 0 || | ||
Object.keys(componentDiff.removedDependencies).length != 0; | ||
}); | ||
|
||
const renderedDiffs = new Set(); | ||
handlebars.registerHelper('shouldRenderComponentDiff', function(componentDiff) { | ||
const componentDiffId = `${componentDiff.baseComponent.purl} => ${componentDiff.comparingComponent.purl}`; | ||
|
||
if (renderedDiffs.has(componentDiffId)) { | ||
return false; | ||
} else { | ||
renderedDiffs.add(componentDiffId); | ||
return true; | ||
} | ||
}); | ||
|
||
handlebars.registerHelper('hasDependencies', function(component) { | ||
return Object.keys(component.dependencies).length != 0; | ||
}); | ||
|
||
const renderedTrees = new Set(); | ||
handlebars.registerHelper('shouldRenderComponentTree', function(component) { | ||
if (renderedTrees.has(component.purl)) { | ||
return false; | ||
} else { | ||
renderedTrees.add(component.purl); | ||
return true; | ||
} | ||
}); | ||
|
||
const compiledTemplate = handlebars.compile(template); | ||
|
||
const fullDiffMd = compiledTemplate({ diff: sbomDiff }); | ||
var githubCommentMd = fullDiffMd; | ||
|
||
if (fullDiffMd.length > GITHUB_COMMENT_CHARACTER_LIMIT) { | ||
|
||
const withoutTreeDiff = compiledTemplate({ diff: sbomDiff, excludeTree: true }); | ||
|
||
if (withoutTreeDiff.length <= GITHUB_COMMENT_CHARACTER_LIMIT) { | ||
githubCommentMd = withoutTreeDiff; | ||
} else { | ||
githubCommentMd = compiledTemplate({ diff: sbomDiff, excludeTree: true, excludeModuleDetails: true }); | ||
} | ||
} | ||
|
||
// these are legacy options of marked that we are disabling to prevent log warnings | ||
marked.use({ | ||
headerIds: false, | ||
mangle: false | ||
}); | ||
const unsanitizedFullDiffHtml = marked.parse(fullDiffMd); | ||
|
||
const window = new JSDOM('').window; | ||
const domPurify = createDOMPurify(window); | ||
|
||
const fullDiffHtml = domPurify.sanitize(unsanitizedFullDiffHtml); | ||
|
||
return { githubComment: githubCommentMd, fullDiff: fullDiffHtml }; | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
const diffSBOMs = require('../sbom-diff/differ'); | ||
const formatHandlebarsTemplate = require('../sbom-diff/format-handlebars-template'); | ||
const fs = require('fs'); | ||
const path = require('path'); | ||
|
||
function readFile(filePath) { | ||
return fs.readFileSync(filePath, 'utf8'); | ||
} | ||
|
||
describe("SBOM diff formatting", () => { | ||
|
||
test("it should format some SBOM diff", async () => { | ||
|
||
// given | ||
const baseSBOM = readFile(path.join(__dirname, 'sbom-diff-test-resources', 'platform-base.json')); | ||
const comparingSBOM = readFile(path.join(__dirname, 'sbom-diff-test-resources', 'platform-head.json')); | ||
|
||
const licenseList = readFile(path.join(__dirname, './../../../java-dependency-check/licenses.json')); | ||
|
||
const diff = await diffSBOMs(baseSBOM, comparingSBOM, '^org\\.camunda', licenseList); | ||
|
||
const template = readFile(path.join(__dirname, './../../../java-dependency-check/diff.hbs')); | ||
|
||
const componentVersionPartial = readFile(path.join(__dirname, './../../../java-dependency-check/component-version.hbs')); | ||
const componentDetailsPartial = readFile(path.join(__dirname, './../../../java-dependency-check/component-details.hbs')); | ||
const componentDiffPartial = readFile(path.join(__dirname, './../../../java-dependency-check/component-diff.hbs')); | ||
const componentTreePartial = readFile(path.join(__dirname, './../../../java-dependency-check/component-tree.hbs')); | ||
const partials = {componentVersion: componentVersionPartial, | ||
componentDetails: componentDetailsPartial, | ||
componentDiff: componentDiffPartial, | ||
componentTree: componentTreePartial}; | ||
|
||
// when | ||
const formattedContent = await formatHandlebarsTemplate(diff, template, partials); | ||
|
||
// then | ||
expect(formattedContent).not.toBeUndefined() | ||
|
||
// no detailed assertions of the result, to complicated to write and maintain | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
<details> | ||
<summary><a id="{{ renderId }}-{{ name }}:{{ version }}">{{ name }}:{{ version }}</a></summary> | ||
|
||
Declared licenses:{{#unless licenses.length}} None{{/unless}} | ||
{{#each licenses}} | ||
* {{string}} {{emojifyLicense type}} | ||
{{/each}} | ||
|
||
Links:{{#unless links.length}} None{{/unless}} | ||
{{#each links}} | ||
* [{{ label }}]({{ link }}) | ||
{{/each}} | ||
</details> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
{{!-- careful: preserve the double space characters at the end of the content lines to make sure that new lines are generated--}} | ||
{{#indent nestingLevel lastItemInList}}{{/indent}}{{emojifyChangeType changeType}} {{#baseComponent}}{{ name }}: {{>componentVersion}}{{/baseComponent}} => {{#comparingComponent}}{{>componentVersion}}{{/comparingComponent}} | ||
{{#if (shouldRenderComponentDiff this)}} | ||
{{#each changedDependencies}} | ||
{{>componentDiff nestingLevel=(increment ../nestingLevel) lastItemInList=@last}} | ||
{{/each}} | ||
{{#each addedDependencies}} | ||
{{>componentTree nestingLevel=(increment ../nestingLevel) lastItemInList=@last changeType='Added'}} | ||
{{/each}} | ||
{{#each removedDependencies}} | ||
{{>componentTree nestingLevel=(increment ../nestingLevel) lastItemInList=@last changeType='Removed'}} | ||
{{/each}} | ||
{{else if (hasChanges this)}}{{#indent (increment nestingLevel)}}{{/indent}}(Repeating subtree omitted) | ||
{{/if}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{{#indent nestingLevel lastItemInList}}{{/indent}}{{emojifyChangeType changeType}} {{ name }}:{{>componentVersion}} | ||
{{#if (shouldRenderComponentTree this)}} | ||
{{#each dependencies}} | ||
{{>componentTree nestingLevel=(increment ../nestingLevel) lastItemInList=@last changeType=../changeType}} | ||
{{/each}} | ||
{{else if (hasDependencies this)}}{{#indent (increment nestingLevel)}}{{/indent}}(Repeating subtree omitted) | ||
{{/if}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{{#if thirdParty~}} | ||
<a href="#{{ renderId }}-{{ urlEncode name }}{{ urlEncode ':' }}{{ urlEncode version }}">{{ version }}</a> {{#if allLicensesGo}}{{emojifyLicense 'Go'}}{{/if}}{{#each carefulLicenseTypes}}{{#if used}}{{emojifyLicense type}}{{/if}}{{/each}}{{#if hasMultipleLicenses}}‼{{/if}} | ||
{{~else~}} | ||
{{version}} | ||
{{~/if}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
# Java dependency diff | ||
|
||
{{#unless excludeTree}} | ||
<pre> | ||
{{#diff.rootComponentDiff}} | ||
{{>componentDiff nestingLevel=0}} | ||
{{/diff.rootComponentDiff}} | ||
</pre> | ||
{{else}} | ||
Omitted due to character limit. See workflow artifacts for full diff file. | ||
{{/unless}} | ||
|
||
# Module details | ||
|
||
{{#unless excludeModuleDetails}} | ||
{{#each diff.involvedComponents}} | ||
{{#if thirdParty}} | ||
{{>componentDetails}} | ||
{{/if}} | ||
{{/each}} | ||
{{else}} | ||
Omitted due to character limit. See workflow artifacts for full diff file. | ||
{{/unless}} | ||
|
||
# Checklist | ||
|
||
## Unique changes | ||
|
||
{{#each diff.changedDependencies}} | ||
{{#if baseComponent.thirdParty}} | ||
- [ ] {{#baseComponent}}{{name}}: {{>componentVersion}}{{/baseComponent}} => {{#comparingComponent}}{{>componentVersion}}{{/comparingComponent}}{{#if (isDowngrade changeType)}} {{emojifyChangeType changeType}}{{/if}} | ||
{{/if}} | ||
{{/each}} | ||
|
||
## Unique additions | ||
|
||
{{#each diff.addedDependencies}} | ||
{{#if thirdParty}} | ||
- [ ] {{name}}:{{>componentVersion}} | ||
{{/if}} | ||
{{/each}} | ||
|
||
# Developer comments | ||
|
||
<!-- Put any explanations of your assessment here --> | ||
|
||
# Glossary | ||
|
||
## Limitations | ||
|
||
* The reported transitive dependencies may not always be accurate in a multi-module project. | ||
The SBOM file format represents a unique dependency (coordinates + type) only once. In a multi-module | ||
project a dependency can be declared in multiple locations with different exclusions of transitive dependencies | ||
or different version overrides for transitive dependencies. | ||
|
||
## Emojies | ||
|
||
* {{emojifyLicense 'Go'}}: All licenses are on the Go list | ||
* {{emojifyLicense 'Caution'}}: (At least one) license is on the Caution list | ||
* {{emojifyLicense 'Stop'}}: (At least one) license is on the Stop list | ||
* {{emojifyLicense 'Unknown'}}: (At least one) license cannot be determined or is unknown | ||
* ‼: Dependency has multiple licenses declared | ||
* {{emojifyChangeType 'Upgraded'}}: New dependency version is higher than previous | ||
* {{emojifyChangeType 'Downgraded'}}: New dependency version is lower than previous | ||
* {{emojifyChangeType 'DependenciesChanged'}}: Dependency version is equal and the dependencies of this component changed (e.g. when comparing snapshots) | ||
* {{emojifyChangeType 'Unknown'}}: The change of the dependency version can not be determined further (e.g. because the version does not follow semantic versioning) |