Skip to content

Commit

Permalink
feat(workflows): add rendering of SBOM diffs to Markdown/HTML
Browse files Browse the repository at this point in the history
  • Loading branch information
ThorbenLindhauer committed Sep 29, 2023
1 parent a6e35d3 commit 3a28741
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 0 deletions.
151 changes: 151 additions & 0 deletions common/src/sbom-diff/format-handlebars-template.js
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 };

}
41 changes: 41 additions & 0 deletions common/src/test/format-handlebars-template.test.js
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
});
});
13 changes: 13 additions & 0 deletions java-dependency-check/component-details.hbs
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>
14 changes: 14 additions & 0 deletions java-dependency-check/component-diff.hbs
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}}
7 changes: 7 additions & 0 deletions java-dependency-check/component-tree.hbs
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}}
5 changes: 5 additions & 0 deletions java-dependency-check/component-version.hbs
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}}&#8252;{{/if}}
{{~else~}}
{{version}}
{{~/if}}
66 changes: 66 additions & 0 deletions java-dependency-check/diff.hbs
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
* &#8252;: 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)

0 comments on commit 3a28741

Please sign in to comment.