Skip to content

Commit

Permalink
Merge pull request #2400 from hborchardt/spdx-expressions
Browse files Browse the repository at this point in the history
Implement SPDX expressions
  • Loading branch information
nscuro authored Aug 19, 2023
2 parents 5b415a6 + fbdf757 commit f4bace4
Show file tree
Hide file tree
Showing 14 changed files with 854 additions and 56 deletions.
28 changes: 25 additions & 3 deletions docs/_docs/usage/policy-compliance.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,31 @@ There are three types of policy violations:
* Operational

## License Violation
Policy conditions can specify zero or more SPDX license IDs as well as license groups. Dependency-Track comes with
pre-configured groups of related licenses (e.g. Copyleft) that provide a starting point for organizations to create
custom license policies.
If you want to check whether the declared licenses of the components in a project are compatible with guidelines that
exist in your organization, it is possible to add license violation conditions to your Policy.

To check a rule that certain licenses are allowed, you can add those licenses to a license group, called for example
'Allowed licenses', and create a license violation condition "License group is not 'Allowed licenses'" that reports a
violation if any of the components are not available under licenses from the 'Allowed licenses' group.

Conversely, if there are some licenses that are not allowed by your organization's rules,
you can add them to a license group, called for example 'Forbidden licenses', and create a license violation condition
"License group is 'Forbidden licenses'" that reports a violation if any of the components are only available under licenses
from the 'Forbidden licenses' group.
To forbid or exclusively allow individual licenses, license violation conditions like "License is Apache-2.0" or
"License is not MIT" can be added as well.

For components that are licensed under a combination of licenses, like dual licensing, this can be
captured in an [SPDX expression](https://spdx.github.io/spdx-spec/v2-draft/SPDX-license-expressions/), which can be
specified for the components. If your project includes such components, and you set up a
"License group is 'Forbidden licenses'" violation condition, then a violation is reported only when all choices of license
combinations allowed by the SPDX expression would lead to a license from the 'Forbidden licenses' list being used.
For a violation condition like "License group is not 'Allowed licenses'", a violation is reported when all choices of
license combinations according to the SPDX expression would include a license that does not appear in the
'Allowed licenses' list.

Dependency-Track comes with pre-configured groups of related licenses (e.g. Copyleft) that provide a starting point for
organizations to create custom license policies.

## Security Violation
Policy conditions can specify the severity of vulnerabilities. A vulnerability affecting a component can result in a
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/org/dependencytrack/model/Component.java
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,11 @@ public enum FetchGroup {
@Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The license may only contain printable characters")
private String license;

@Persistent
@Column(name = "LICENSE_EXPRESSION", jdbcType = "CLOB", allowsNull = "true")
@Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The license expression may only contain printable characters")
private String licenseExpression;

@Persistent
@Column(name = "LICENSE_URL", jdbcType = "VARCHAR")
@Size(max = 255)
Expand Down Expand Up @@ -625,6 +630,14 @@ public void setLicense(String license) {
this.license = StringUtils.abbreviate(license, 255);
}

public String getLicenseExpression() {
return licenseExpression;
}

public void setLicenseExpression(String licenseExpression) {
this.licenseExpression = licenseExpression;
}

public String getLicenseUrl() {
return licenseUrl;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
import org.dependencytrack.model.Vulnerability;
import org.dependencytrack.parser.common.resolver.CweResolver;
import org.dependencytrack.parser.cyclonedx.CycloneDXExporter;
import org.dependencytrack.parser.spdx.expression.SpdxExpressionParser;
import org.dependencytrack.parser.spdx.expression.model.SpdxExpression;
import org.dependencytrack.persistence.QueryManager;
import org.dependencytrack.util.InternalComponentIdentificationUtil;
import org.dependencytrack.util.PurlUtil;
Expand Down Expand Up @@ -155,24 +157,52 @@ public static Component convert(final QueryManager qm, final org.cyclonedx.model
}

final LicenseChoice licenseChoice = cycloneDxComponent.getLicenseChoice();
if (licenseChoice != null && licenseChoice.getLicenses() != null && !licenseChoice.getLicenses().isEmpty()) {
for (final org.cyclonedx.model.License cycloneLicense : licenseChoice.getLicenses()) {
if (cycloneLicense != null) {
if (StringUtils.isNotBlank(cycloneLicense.getId())) {
final License license = qm.getLicense(StringUtils.trimToNull(cycloneLicense.getId()));
if (license != null) {
component.setResolvedLicense(license);
if (licenseChoice != null) {
final List<org.cyclonedx.model.License> licenseOptions = new ArrayList<>();
if (licenseChoice.getExpression() != null) {
// store license expression, but don't overwrite manual changes to the field
if (component.getLicenseExpression() == null) {
component.setLicenseExpression(licenseChoice.getExpression());
}
// if the expression just consists of one license id, we can add it as another license option
SpdxExpressionParser parser = new SpdxExpressionParser();
SpdxExpression parsedExpression = parser.parse(licenseChoice.getExpression());
if (parsedExpression.getSpdxLicenseId() != null) {
org.cyclonedx.model.License expressionLicense = null;
expressionLicense = new org.cyclonedx.model.License();
expressionLicense.setId(parsedExpression.getSpdxLicenseId());
expressionLicense.setName(parsedExpression.getSpdxLicenseId());
licenseOptions.add(expressionLicense);
}
}
// add license options from the component's license array. These will have higher priority
// than the one from the parsed expression, because the following loop iterates through all
// the options and does not stop once it found a match.
if (licenseChoice.getLicenses() != null && !licenseChoice.getLicenses().isEmpty()) {
licenseOptions.addAll(licenseChoice.getLicenses());
}

// try to find a license in the database among the license options, but only if none has been
// selected previously.
if (component.getResolvedLicense() == null) {
for (final org.cyclonedx.model.License cycloneLicense : licenseOptions) {
if (cycloneLicense != null) {
if (StringUtils.isNotBlank(cycloneLicense.getId())) {
final License license = qm.getLicense(StringUtils.trimToNull(cycloneLicense.getId()));
if (license != null) {
component.setResolvedLicense(license);
}
}
}
else if (StringUtils.isNotBlank(cycloneLicense.getName()))
{
final License license = qm.getCustomLicense(StringUtils.trimToNull(cycloneLicense.getName()));
if (license != null) {
component.setResolvedLicense(license);
else if (StringUtils.isNotBlank(cycloneLicense.getName()))
{
final License license = qm.getCustomLicense(StringUtils.trimToNull(cycloneLicense.getName()));
if (license != null) {
component.setResolvedLicense(license);
}
}
component.setLicense(StringUtils.trimToNull(cycloneLicense.getName()));
component.setLicenseUrl(StringUtils.trimToNull(cycloneLicense.getUrl()));
}
component.setLicense(StringUtils.trimToNull(cycloneLicense.getName()));
component.setLicenseUrl(StringUtils.trimToNull(cycloneLicense.getUrl()));
}
}
}
Expand Down Expand Up @@ -253,27 +283,29 @@ public static org.cyclonedx.model.Component convert(final QueryManager qm, final
cycloneComponent.addHash(new Hash(Hash.Algorithm.SHA3_512, component.getSha3_512()));
}

final LicenseChoice licenseChoice = new LicenseChoice();
if (component.getResolvedLicense() != null) {
final org.cyclonedx.model.License license = new org.cyclonedx.model.License();
license.setId(component.getResolvedLicense().getLicenseId());
license.setUrl(component.getLicenseUrl());
final LicenseChoice licenseChoice = new LicenseChoice();
licenseChoice.addLicense(license);
cycloneComponent.setLicenseChoice(licenseChoice);
} else if (component.getLicense() != null) {
final org.cyclonedx.model.License license = new org.cyclonedx.model.License();
license.setName(component.getLicense());
license.setUrl(component.getLicenseUrl());
final LicenseChoice licenseChoice = new LicenseChoice();
licenseChoice.addLicense(license);
cycloneComponent.setLicenseChoice(licenseChoice);
} else if (StringUtils.isNotEmpty(component.getLicenseUrl())) {
final org.cyclonedx.model.License license = new org.cyclonedx.model.License();
license.setUrl(component.getLicenseUrl());
final LicenseChoice licenseChoice = new LicenseChoice();
licenseChoice.addLicense(license);
cycloneComponent.setLicenseChoice(licenseChoice);
}
if (component.getLicenseExpression() != null) {
licenseChoice.setExpression(component.getLicenseExpression());
cycloneComponent.setLicenseChoice(licenseChoice);
}


if (component.getExternalReferences() != null && component.getExternalReferences().size() > 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* This file is part of Dependency-Track.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
* Copyright (c) Steve Springett. All Rights Reserved.
*/
package org.dependencytrack.parser.spdx.expression;

import java.util.ArrayDeque;
import java.util.Iterator;
import java.util.List;

import org.dependencytrack.parser.spdx.expression.model.SpdxOperator;
import org.dependencytrack.parser.spdx.expression.model.SpdxExpression;

/**
* This class parses SPDX expressions according to
* https://spdx.github.io/spdx-spec/v2-draft/SPDX-license-expressions/ into a tree of
* SpdxExpressions and SpdxExpressionOperations
*
* @author hborchardt
* @since 4.8.0
*/
public class SpdxExpressionParser {

/**
* Reads in a SPDX expression and returns a parsed tree of SpdxExpressionOperators and license
* ids.
*
* @param spdxExpression
* spdx expression string
* @return parsed SpdxExpression tree, or SpdxExpression.INVALID if an error has occurred during
* parsing
*/
public SpdxExpression parse(final String spdxExpression) {
// operators are surrounded by spaces or brackets. Let's make our life easier and surround brackets by spaces.
var _spdxExpression = spdxExpression.replace("(", " ( ").replace(")", " ) ").split(" ");
if (_spdxExpression.length == 1) {
return new SpdxExpression(spdxExpression);
}

// Shunting yard algorithm to convert SPDX expression to reverse polish notation
// specify list of infix operators
List<String> infixOperators = List.of(SpdxOperator.OR.getToken(), SpdxOperator.AND.getToken(),
SpdxOperator.WITH.getToken());

ArrayDeque<String> operatorStack = new ArrayDeque<>();
ArrayDeque<String> outputQueue = new ArrayDeque<>();
Iterator<String> it = List.of(_spdxExpression).iterator();
while(it.hasNext()) {
var token = it.next();
if (token.length() == 0) {
continue;
}
if (infixOperators.contains(token)) {
int opPrecedence = SpdxOperator.valueOf(token).getPrecedence();
for (String o2; (o2 = operatorStack.peek()) != null && !o2.equals("(")
&& SpdxOperator.valueOf(o2).getPrecedence() > opPrecedence;) {
outputQueue.push(operatorStack.pop());
}
;
operatorStack.push(token);
} else if (token.equals("(")) {
operatorStack.push(token);
} else if (token.equals(")")) {
for (String o2; (o2 = operatorStack.peek()) == null || !o2.equals("(");) {
if (o2 == null) {
// Mismatched parentheses
return SpdxExpression.INVALID;
}
outputQueue.push(operatorStack.pop());
}
;
String leftParens = operatorStack.pop();

if (!"(".equals(leftParens)) {
// Mismatched parentheses
return SpdxExpression.INVALID;
}
// no function tokens implemented
} else {
outputQueue.push(token);
}
}
for (String o2; (o2 = operatorStack.peek()) != null;) {
if ("(".equals(o2)) {
// Mismatched parentheses
return SpdxExpression.INVALID;
}
outputQueue.push(operatorStack.pop());
}

// convert RPN stack into tree
// this is easy because all infix operators have two arguments
ArrayDeque<SpdxExpression> expressions = new ArrayDeque<>();
SpdxExpression expr = null;
while (!outputQueue.isEmpty()) {
var token = outputQueue.pollLast();
if (infixOperators.contains(token)) {
var rhs = expressions.pop();
var lhs = expressions.pop();
expr = new SpdxExpression(SpdxOperator.valueOf(token), List.of(lhs, rhs));
} else {
if (token.endsWith("+")) {
// trailing `+` is not a whitespace-delimited operator - process it separately
expr = new SpdxExpression(SpdxOperator.PLUS,
List.of(new SpdxExpression(token.substring(0, token.length() - 1))));
} else {
expr = new SpdxExpression(token);
}
}
expressions.push(expr);
}
return expr;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* This file is part of Dependency-Track.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
* Copyright (c) Steve Springett. All Rights Reserved.
*/
package org.dependencytrack.parser.spdx.expression.model;

import java.util.List;

/**
* A node of an SPDX expression tree. If it is a leaf node, it contains a spdxLicenseId. If it is an
* inner node, containss an operation.
*
* @author hborchardt
* @since 4.8.0
*/
public class SpdxExpression {
public static final SpdxExpression INVALID = new SpdxExpression(null);
public SpdxExpression(String spdxLicenseId) {
this.spdxLicenseId = spdxLicenseId;
}

public SpdxExpression(SpdxOperator operator, List<SpdxExpression> arguments) {
this.operation = new SpdxExpressionOperation(operator, arguments);
}

private SpdxExpressionOperation operation;
private String spdxLicenseId;

public SpdxExpressionOperation getOperation() {
return operation;
}

public void setOperation(SpdxExpressionOperation operation) {
this.operation = operation;
}

public String getSpdxLicenseId() {
return spdxLicenseId;
}

public void setSpdxLicenseId(String spdxLicenseId) {
this.spdxLicenseId = spdxLicenseId;
}

@Override
public String toString() {
if (this == INVALID) {
return "INVALID";
}
if (spdxLicenseId != null) {
return spdxLicenseId;
}
return operation.toString();
}
}
Loading

0 comments on commit f4bace4

Please sign in to comment.