diff --git a/docs/_docs/usage/policy-compliance.md b/docs/_docs/usage/policy-compliance.md index 6071404004..9ade41ed97 100644 --- a/docs/_docs/usage/policy-compliance.md +++ b/docs/_docs/usage/policy-compliance.md @@ -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 diff --git a/src/main/java/org/dependencytrack/model/Component.java b/src/main/java/org/dependencytrack/model/Component.java index a06542612e..7c1840db9e 100644 --- a/src/main/java/org/dependencytrack/model/Component.java +++ b/src/main/java/org/dependencytrack/model/Component.java @@ -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) @@ -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; } diff --git a/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java b/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java index 27bbcdbc38..5f5e18edeb 100644 --- a/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java +++ b/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java @@ -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; @@ -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 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())); } } } @@ -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) { diff --git a/src/main/java/org/dependencytrack/parser/spdx/expression/SpdxExpressionParser.java b/src/main/java/org/dependencytrack/parser/spdx/expression/SpdxExpressionParser.java new file mode 100644 index 0000000000..60e1e95a1b --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/spdx/expression/SpdxExpressionParser.java @@ -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 infixOperators = List.of(SpdxOperator.OR.getToken(), SpdxOperator.AND.getToken(), + SpdxOperator.WITH.getToken()); + + ArrayDeque operatorStack = new ArrayDeque<>(); + ArrayDeque outputQueue = new ArrayDeque<>(); + Iterator 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 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; + } +} diff --git a/src/main/java/org/dependencytrack/parser/spdx/expression/model/SpdxExpression.java b/src/main/java/org/dependencytrack/parser/spdx/expression/model/SpdxExpression.java new file mode 100644 index 0000000000..a440f322c8 --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/spdx/expression/model/SpdxExpression.java @@ -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 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(); + } +} diff --git a/src/main/java/org/dependencytrack/parser/spdx/expression/model/SpdxExpressionOperation.java b/src/main/java/org/dependencytrack/parser/spdx/expression/model/SpdxExpressionOperation.java new file mode 100644 index 0000000000..567f01e611 --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/spdx/expression/model/SpdxExpressionOperation.java @@ -0,0 +1,61 @@ +/* + * 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; +import java.util.stream.Collectors; + +/** + * A SPDX expression operation with one of the SPDX operators as defined in the spec, and arguments + * to that operator. + * + * @author hborchardt + * @since 4.8.0 + */ +public class SpdxExpressionOperation { + private SpdxOperator operator; + private List arguments; + + public SpdxExpressionOperation(SpdxOperator operator, List arguments) { + this.operator = operator; + this.arguments = arguments; + } + + public SpdxOperator getOperator() { + return operator; + } + + public void setOperator(SpdxOperator operator) { + this.operator = operator; + } + + public List getArguments() { + return arguments; + } + + public void setArguments(List arguments) { + this.arguments = arguments; + } + + @Override + public String toString() { + return operator + "(" + + arguments.stream().map(SpdxExpression::toString).collect(Collectors.joining(", ")) + ")"; + } +} diff --git a/src/main/java/org/dependencytrack/parser/spdx/expression/model/SpdxOperator.java b/src/main/java/org/dependencytrack/parser/spdx/expression/model/SpdxOperator.java new file mode 100644 index 0000000000..ab5d729117 --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/spdx/expression/model/SpdxOperator.java @@ -0,0 +1,48 @@ +/* + * 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; + +/** + * One of the SPDX expression operators as defined in the spec, together with their precedence. + * + * @author hborchardt + * @since 4.8.0 + */ +public enum SpdxOperator { + OR(1, "OR"), AND(2, "AND"), WITH(3, "WITH"), PLUS(4, "+"); + + SpdxOperator(int precedence, String token) { + this.precedence = precedence; + this.token = token; + } + + private final int precedence; + private final String token; + + public int getPrecedence() { + return precedence; + } + public String getToken() { + return token; + } + @Override + public String toString() { + return this.token; + } +} diff --git a/src/main/java/org/dependencytrack/parser/spdx/expression/package-info.java b/src/main/java/org/dependencytrack/parser/spdx/expression/package-info.java new file mode 100644 index 0000000000..37be317d85 --- /dev/null +++ b/src/main/java/org/dependencytrack/parser/spdx/expression/package-info.java @@ -0,0 +1,23 @@ +/* + * 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 contains JSON parser for processing SPDX expressions. + */ +package org.dependencytrack.parser.spdx.expression; \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/policy/LicenseGroupPolicyEvaluator.java b/src/main/java/org/dependencytrack/policy/LicenseGroupPolicyEvaluator.java index 2b61faec1d..c391b01040 100644 --- a/src/main/java/org/dependencytrack/policy/LicenseGroupPolicyEvaluator.java +++ b/src/main/java/org/dependencytrack/policy/LicenseGroupPolicyEvaluator.java @@ -24,8 +24,14 @@ import org.dependencytrack.model.LicenseGroup; import org.dependencytrack.model.Policy; import org.dependencytrack.model.PolicyCondition; +import org.dependencytrack.parser.spdx.expression.SpdxExpressionParser; +import org.dependencytrack.parser.spdx.expression.model.SpdxExpression; +import org.dependencytrack.parser.spdx.expression.model.SpdxExpressionOperation; +import org.dependencytrack.parser.spdx.expression.model.SpdxOperator; +import org.dependencytrack.persistence.QueryManager; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -36,6 +42,41 @@ */ public class LicenseGroupPolicyEvaluator extends AbstractPolicyEvaluator { + /** + * A license group that does not exist in the database and is therefore verified based on its + * licenses list directly instad of a database check + */ + private static class TemporaryLicenseGroup extends LicenseGroup { + private static final long serialVersionUID = -1268650463377651000L; + } + + /** + * Whether a condition provides a positive list or negative list of licenses. + * + *

+ * Configuring a LicenseGroupPolicy allows the user to specify conditions as either "IS + * MyLicenseGroup" or "IS_NOT MyLicenseGroup", and a policy violation is reported when the + * condition is met. The IS and IS_NOT is not very intuitive when actually evaluating a + * condition; what it actually means is that either "IS_NOT" is selected, and the user provides + * a list of licenses that are allowed to be used (violation if license is not in license + * group), or "IS" is selected and the user provides a list of licenses that cannot be used + * (violation if license is in license group). + * + *

+ * In order to simplify thinking about license violations, this license group type is used. + * + */ + private static enum LicenseGroupType { + /** + * License group represents a list of licenses that are explicitly allowed to be used + */ + AllowedLicenseList, + /** + * License group represents a list of licenses that are not allowed to be used + */ + ForbiddenLicenseList; + } + private static final Logger LOGGER = Logger.getLogger(LicenseGroupPolicyEvaluator.class); /** @@ -52,34 +93,204 @@ public PolicyCondition.Subject supportedSubject() { @Override public List evaluate(final Policy policy, final Component component) { final List violations = new ArrayList<>(); - final License license = component.getResolvedLicense(); + final SpdxExpression expression = getSpdxExpressionFromComponent(component); for (final PolicyCondition condition : super.extractSupportedConditions(policy)) { - LOGGER.debug("Evaluating component (" + component.getUuid() + ") against policy condition (" + condition.getUuid() + ")"); + LOGGER.debug("Evaluating component (" + component.getUuid() + ") against policy condition (" + + condition.getUuid() + ")"); final LicenseGroup lg = qm.getObjectByUuid(LicenseGroup.class, condition.getValue()); if (lg == null) { LOGGER.warn("The license group %s does not exist; Skipping evaluation of condition %s of policy %s" .formatted(condition.getValue(), condition.getUuid(), policy.getName())); continue; } - if (license == null) { - if (PolicyCondition.Operator.IS_NOT == condition.getOperator()) { - violations.add(new PolicyConditionViolation(condition, component)); - } + evaluateCondition(qm, condition, expression, lg, component, violations); + } + return violations; + } + + /** + * Retrieves the appropriate spdx expression from a component. If the component has a single + * license, return spdx expression for that. If the component has an expression string as + * license, parse it and return that. + * + * @param component + * the component to retrieve the license expression for + * @return parsed license expression + */ + static SpdxExpression getSpdxExpressionFromComponent(final Component component) { + SpdxExpression expression = null; + + final License license = component.getResolvedLicense(); + if (license != null) { + expression = new SpdxExpression(license.getLicenseId()); + } else { + String licenseString = component.getLicenseExpression(); + if (licenseString != null) { + expression = new SpdxExpressionParser().parse(licenseString); + } else if (component.getLicense() != null) { + expression = new SpdxExpression(component.getLicense()); } else { - final boolean containsLicense = qm.doesLicenseGroupContainLicense(lg, license); - if (PolicyCondition.Operator.IS == condition.getOperator()) { - if (containsLicense) { - violations.add(new PolicyConditionViolation(condition, component)); - } - } else if (PolicyCondition.Operator.IS_NOT == condition.getOperator()) { - if (!containsLicense) { - violations.add(new PolicyConditionViolation(condition, component)); - } + expression = new SpdxExpression("unresolved"); + } + } + + return expression; + } + + static LicenseGroup getTemporaryLicenseGroupForLicense(final License license) { + LicenseGroup temporaryLicenseGroup = new TemporaryLicenseGroup(); + temporaryLicenseGroup.setLicenses(Collections.singletonList(license)); + return temporaryLicenseGroup; + } + + /** + * Evaluate policy condition for spdx expression and license group, and add violations to the + * violations array. + * + * @param qm + * The query manager to use for database queries + * @param condition + * The condition to evaluate + * @param expression + * the spdx expression to be checked for incompatibility with the license group + * @param lg + * the license group to check for incompatibility. If this is null, interpret as + * "unresolved" + * @param component + * the component for which policies are being checked + * @param violations + * the list of violations, will be appended to in case of new violation + * @return true if violations have been added to the list + */ + static boolean evaluateCondition(final QueryManager qm, final PolicyCondition condition, + final SpdxExpression expression, final LicenseGroup lg, final Component component, + final List violations) { + + boolean hasViolations = false; + if (condition.getOperator() == PolicyCondition.Operator.IS) { + // report a violation if a license IS in the license group; + // so check whether the expression is compatible given the provided list of forbidden licenses + if (!canLicenseBeUsed(qm, expression, LicenseGroupType.ForbiddenLicenseList, lg)) { + violations.add(new PolicyConditionViolation(condition, component)); + hasViolations = true; + } + } + if (condition.getOperator() == PolicyCondition.Operator.IS_NOT) { + // report a violation if a license IS_NOT in the license group; + // so check whether the expression is compatible given the provided list of allowed licenses + if (!canLicenseBeUsed(qm, expression, LicenseGroupType.AllowedLicenseList, lg)) { + violations.add(new PolicyConditionViolation(condition, component)); + hasViolations = true; + } + } + + return hasViolations; + } + + /** + * Check spdx expression for compatibility with license group, where the license group is either + * a list of allowed or forbidden licenses (positive or negative list). If the expression is an + * SPDX operator, this function calls itself recursively to determine compatibility of the + * expression's parts. + * + * @param qm + * The query manager to use for database queries + * @param expr + * the spdx expression to be checked for compatibility with the license group + * @param groupType + * whether the given license group is a list of allowed or forbidden licenses + * @param lg + * the license group to check for compatibility. If this is null, interpret as + * "unresolved". + * @return whether the license expression is compatible with the license group under the + * condition + */ + protected static boolean canLicenseBeUsed(final QueryManager qm, final SpdxExpression expr, + final LicenseGroupType groupType, final LicenseGroup lg) { + if (expr.getSpdxLicenseId() != null) { + License license = qm.getLicense(expr.getSpdxLicenseId()); + if (groupType == LicenseGroupType.ForbiddenLicenseList) { + if (license == null && lg != null) { + // unresolved license, and forbidden list given. This is ok + return true; + } + if (license != null && lg == null) { + // license resolved, but only unresolved forbidden. ok + return true; + } + if (license == null && lg == null) { + // license unresolved and unresolved is forbidden + return false; } + // license resolved and negative list given + return !doesLicenseGroupContainLicense(qm, lg, license); + } else if (groupType == LicenseGroupType.AllowedLicenseList) { + if (license == null && lg != null) { + // unresolved license, but list of allowed licenses given + return false; + } + if (license != null && lg == null) { + // license resolved, but only unresolved allowed + return false; + } + if (license == null && lg == null) { + // license unresolved and unresolved is allowed + return true; + } + // license resolved and positive list given + return doesLicenseGroupContainLicense(qm, lg, license); + } else { + // should be unreachable + return true; } } - return violations; + // check according to operation + SpdxExpressionOperation operation = expr.getOperation(); + if (operation.getOperator() == SpdxOperator.OR) { + // any of the OR operator's arguments needs to be compatible + return operation.getArguments().stream().anyMatch(arg -> canLicenseBeUsed(qm, arg, groupType, lg)); + } + if (operation.getOperator() == SpdxOperator.AND) { + // all of the AND operator's arguments needs to be compatible + return operation.getArguments().stream().allMatch(arg -> canLicenseBeUsed(qm, arg, groupType, lg)); + } + if (operation.getOperator() == SpdxOperator.WITH) { + // Transform `GPL-2.0 WITH classpath-exception` to `GPL-2.0-with-classpath-exception` + String licenseName = operation.getArguments().get(0) + "-with-" + operation.getArguments().get(1); + SpdxExpression license = new SpdxExpression(licenseName); + return canLicenseBeUsed(qm, license, groupType, lg); + } + if (operation.getOperator() == SpdxOperator.PLUS) { + // Transform `GPL-2.0+` to `GPL-2.0 OR GPL-2.0-or-later` + SpdxExpression arg = operation.getArguments().get(0); + return canLicenseBeUsed(qm, arg, groupType, lg) + || canLicenseBeUsed(qm, new SpdxExpression(expr.getSpdxLicenseId() + "-or-later"), groupType, lg); + } + // should be unreachable + return true; + } + + /** + * Check if the license is contained in the license group. If this is a temporary license group, + * don't ask the database but verify directly via the license's uuid + * + * @param qm + * The query manager to use for database queries + * @param lg + * The license group to check + * @param license + * The license to check + * @return Whether the license group contains the license + */ + protected static boolean doesLicenseGroupContainLicense(final QueryManager qm, final LicenseGroup lg, + final License license) { + if (lg instanceof TemporaryLicenseGroup) { + // this group was created just for this license check. Check its contents directly without the QueryManager. + return lg.getLicenses().stream().anyMatch(groupLicense -> groupLicense.getUuid().equals(license.getUuid())); + } else { + return qm.doesLicenseGroupContainLicense(lg, license); + } } } diff --git a/src/main/java/org/dependencytrack/policy/LicensePolicyEvaluator.java b/src/main/java/org/dependencytrack/policy/LicensePolicyEvaluator.java index 434fb20b3e..43a51b8d56 100644 --- a/src/main/java/org/dependencytrack/policy/LicensePolicyEvaluator.java +++ b/src/main/java/org/dependencytrack/policy/LicensePolicyEvaluator.java @@ -21,8 +21,10 @@ import alpine.common.logging.Logger; import org.dependencytrack.model.Component; import org.dependencytrack.model.License; +import org.dependencytrack.model.LicenseGroup; import org.dependencytrack.model.Policy; import org.dependencytrack.model.PolicyCondition; +import org.dependencytrack.parser.spdx.expression.model.SpdxExpression; import java.util.ArrayList; import java.util.List; @@ -51,28 +53,27 @@ public PolicyCondition.Subject supportedSubject() { @Override public List evaluate(final Policy policy, final Component component) { final List violations = new ArrayList<>(); - final License license = component.getResolvedLicense(); + // use spdx expression checking logic from the license group policy evaluator + final SpdxExpression expression = LicenseGroupPolicyEvaluator.getSpdxExpressionFromComponent(component); + + boolean allPoliciesViolated = true; for (final PolicyCondition condition: super.extractSupportedConditions(policy)) { LOGGER.debug("Evaluating component (" + component.getUuid() + ") against policy condition (" + condition.getUuid() + ")"); - if (condition.getValue().equals("unresolved")) { - if (license == null && PolicyCondition.Operator.IS == condition.getOperator()) { - violations.add(new PolicyConditionViolation(condition, component)); - } else if (license != null && PolicyCondition.Operator.IS_NOT == condition.getOperator()) { - violations.add(new PolicyConditionViolation(condition, component)); - } - } else if (license != null) { - final License l = qm.getObjectByUuid(License.class, condition.getValue()); - if (l != null && PolicyCondition.Operator.IS == condition.getOperator()) { - if (component.getResolvedLicense().getId() == l.getId()) { - violations.add(new PolicyConditionViolation(condition, component)); - } - } else if (l != null && PolicyCondition.Operator.IS_NOT == condition.getOperator()) { - if (component.getResolvedLicense().getId() != l.getId()) { - violations.add(new PolicyConditionViolation(condition, component)); - } - } + + LicenseGroup licenseGroup = null; + // lg will stay null if we are checking for "unresolved" + if (!condition.getValue().equals("unresolved")) { + License conditionLicense = qm.getObjectByUuid(License.class, condition.getValue()); + licenseGroup = LicenseGroupPolicyEvaluator.getTemporaryLicenseGroupForLicense(conditionLicense); } + + boolean addedViolation = LicenseGroupPolicyEvaluator.evaluateCondition(qm, condition, expression, + licenseGroup, component, violations); + if (addedViolation == false) { + allPoliciesViolated = false; + } + } return violations; } diff --git a/src/main/java/org/dependencytrack/upgrade/v490/v490Updater.java b/src/main/java/org/dependencytrack/upgrade/v490/v490Updater.java index 5b4db59e09..a00f679b77 100644 --- a/src/main/java/org/dependencytrack/upgrade/v490/v490Updater.java +++ b/src/main/java/org/dependencytrack/upgrade/v490/v490Updater.java @@ -21,6 +21,7 @@ import alpine.common.logging.Logger; import alpine.persistence.AlpineQueryManager; import alpine.server.upgrade.AbstractUpgradeItem; +import alpine.server.util.DbUtil; import java.sql.Connection; import java.sql.PreparedStatement; @@ -39,6 +40,7 @@ public String getSchemaVersion() { @Override public void executeUpgrade(final AlpineQueryManager qm, final Connection connection) throws Exception { updateDefaultSnykApiVersion(connection); + addLicenseExpressionColumnToComponents(connection); } /** @@ -62,4 +64,16 @@ private static void updateDefaultSnykApiVersion(final Connection connection) thr } } + private void addLicenseExpressionColumnToComponents(Connection connection) throws Exception { + // The JDBC type "CLOB" is mapped to the type CLOB for H2, MEDIUMTEXT for MySQL, and TEXT for PostgreSQL and SQL Server. + LOGGER.info("Adding \"LICENSE_EXPRESSION\" to \"COMPONENTS\""); + if (DbUtil.isH2()) { + DbUtil.executeUpdate(connection, "ALTER TABLE \"COMPONENTS\" ADD \"LICENSE_EXPRESSION\" CLOB"); + } else if (DbUtil.isMysql()) { + DbUtil.executeUpdate(connection, "ALTER TABLE \"COMPONENTS\" ADD \"LICENSE_EXPRESSION\" MEDIUMTEXT"); + } else { + DbUtil.executeUpdate(connection, "ALTER TABLE \"COMPONENTS\" ADD \"LICENSE_EXPRESSION\" TEXT"); + } + } + } diff --git a/src/test/java/org/dependencytrack/parser/spdx/expression/SpdxExpressionParserTest.java b/src/test/java/org/dependencytrack/parser/spdx/expression/SpdxExpressionParserTest.java new file mode 100644 index 0000000000..64a9b3f4b5 --- /dev/null +++ b/src/test/java/org/dependencytrack/parser/spdx/expression/SpdxExpressionParserTest.java @@ -0,0 +1,65 @@ +package org.dependencytrack.parser.spdx.expression; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; + +import java.io.IOException; + +import org.dependencytrack.parser.spdx.expression.model.SpdxExpression; +import org.dependencytrack.persistence.QueryManager; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +public class SpdxExpressionParserTest { + + private SpdxExpressionParser parser; + + @Before + public void setUp() throws Exception { + parser = new SpdxExpressionParser(); + } + + @Test + public void testParsingOfSuperfluousParentheses() throws IOException { + var exp = parser.parse("(Apache OR MIT WITH (CPE) AND GPL WITH ((CC0 OR GPL-2)))"); + assertEquals("OR(Apache, AND(WITH(MIT, CPE), WITH(GPL, OR(CC0, GPL-2))))", exp.toString()); + } + + @Test + public void testThatAndOperatorBindsStrongerThanOrOperator() throws IOException { + var exp = parser.parse("LGPL-2.1-only OR BSD-3-Clause AND MIT"); + assertEquals("OR(LGPL-2.1-only, AND(BSD-3-Clause, MIT))", exp.toString()); + } + + @Test + public void testThatWithOperatorBindsStrongerThanAndOperator() throws IOException { + var exp = parser.parse("LGPL-2.1-only WITH CPE AND MIT OR BSD-3-Clause"); + assertEquals("OR(AND(WITH(LGPL-2.1-only, CPE), MIT), BSD-3-Clause)", exp.toString()); + } + + @Test + public void testThatParenthesesOverrideOperatorPrecedence() throws IOException { + var exp = parser.parse("MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)"); + assertEquals("AND(MIT, OR(LGPL-2.1-or-later, BSD-3-Clause))", exp.toString()); + } + + @Test + public void testParsingWithMissingSpaceAfterParenthesis() throws IOException { + var exp = parser.parse("(MIT)AND(LGPL-2.1-or-later WITH(CC0 OR GPL-2))"); + assertEquals("AND(MIT, WITH(LGPL-2.1-or-later, OR(CC0, GPL-2)))", exp.toString()); + } + + @Test + public void testMissingClosingParenthesis() throws IOException { + var exp = parser.parse("MIT (OR BSD-3-Clause"); + assertEquals(SpdxExpression.INVALID, exp); + } + + @Test + public void testMissingOpeningParenthesis() throws IOException { + var exp = parser.parse("MIT )(OR BSD-3-Clause"); + assertEquals(SpdxExpression.INVALID, exp); + } + +} diff --git a/src/test/java/org/dependencytrack/policy/LicenseGroupPolicyEvaluatorTest.java b/src/test/java/org/dependencytrack/policy/LicenseGroupPolicyEvaluatorTest.java index 545dacc116..80cfc391f4 100644 --- a/src/test/java/org/dependencytrack/policy/LicenseGroupPolicyEvaluatorTest.java +++ b/src/test/java/org/dependencytrack/policy/LicenseGroupPolicyEvaluatorTest.java @@ -27,11 +27,17 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; + +import junitparams.JUnitParamsRunner; +import junitparams.Parameters; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.UUID; +@RunWith(JUnitParamsRunner.class) public class LicenseGroupPolicyEvaluatorTest extends PersistenceCapableTest { private PolicyEvaluator evaluator; @@ -64,6 +70,104 @@ public void hasMatch() { Assert.assertEquals(1, violations.size()); } + @Test + @Parameters(method = "forbiddenListTestcases") + public void spdxExpressionForbiddenList(String expression, Integer expectedViolations) { + { + License license = new License(); + license.setName("MIT License"); + license.setLicenseId("MIT"); + license.setUuid(UUID.randomUUID()); + license = qm.persist(license); + } + License license = new License(); + license.setName("Apache 2.0"); + license.setLicenseId("Apache-2.0"); + license.setUuid(UUID.randomUUID()); + license = qm.persist(license); + LicenseGroup lg = qm.createLicenseGroup("Test License Group"); + lg.setLicenses(Collections.singletonList(license)); + lg = qm.persist(lg); + lg = qm.detach(LicenseGroup.class, lg.getId()); + license = qm.detach(License.class, license.getId()); + Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO); + + // Operator.IS means it is a forbid list + PolicyCondition condition = qm.createPolicyCondition(policy, PolicyCondition.Subject.LICENSE_GROUP, + PolicyCondition.Operator.IS, lg.getUuid().toString()); + policy = qm.detach(Policy.class, policy.getId()); + qm.detach(PolicyCondition.class, condition.getId()); + + Component component = new Component(); + component.setLicenseExpression(expression); + List violations = evaluator.evaluate(policy, component); + Assert.assertEquals(expectedViolations.intValue(), violations.size()); + } + + private Object[] forbiddenListTestcases() { + return new Object[] { + // nonexistent license means it is not on the negative list + new Object[] { "Apache-2.0 OR NonexistentLicense", 0 }, + // Apache is on the negative list, violation + new Object[] { "Apache-2.0 AND(MIT OR NonexistentLicense OR Apache-2.0)AND(Apache-2.0 AND Apache-2.0)", 1}, + // Apache is on the negative list, violation + new Object[] { "Apache-2.0 AND NonexistentLicense", 1}, + // MIT allowed + new Object[] { "Apache-2.0 OR MIT", 0 } + }; + } + + @Test + @Parameters(method = "allowListTestcases") + public void spdxExpressionAllowList(String licenseName, Integer expectedViolations) { + { + License license = new License(); + license.setName("MIT License"); + license.setLicenseId("MIT"); + license.setUuid(UUID.randomUUID()); + license = qm.persist(license); + } + License license = new License(); + license.setName("Apache 2.0"); + license.setLicenseId("Apache-2.0"); + license.setUuid(UUID.randomUUID()); + license = qm.persist(license); + LicenseGroup lg = qm.createLicenseGroup("Test License Group"); + lg.setLicenses(Collections.singletonList(license)); + lg = qm.persist(lg); + lg = qm.detach(LicenseGroup.class, lg.getId()); + license = qm.detach(License.class, license.getId()); + Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO); + + // Operator.IS_NOT means it is a positive list + PolicyCondition condition = qm.createPolicyCondition(policy, PolicyCondition.Subject.LICENSE_GROUP, + PolicyCondition.Operator.IS_NOT, lg.getUuid().toString()); + policy = qm.detach(Policy.class, policy.getId()); + qm.detach(PolicyCondition.class, condition.getId()); + + Policy _policy = policy; + + Component component = new Component(); + component.setLicenseExpression(licenseName); + List violations = evaluator.evaluate(_policy, component); + Assert.assertEquals("Error for: " + licenseName, expectedViolations.intValue(), violations.size()); + } + + private Object[] allowListTestcases() { + return new Object[] { + // Nonexistent license is not in positive list, violation + //new Object[] { "NonexistentLicense", 1}, + // Apache is on the positive list + //new Object[] { "Apache-2.0 OR NonexistentLicense", 0}, + // Apache is on the positive list + new Object[] { "Apache-2.0 AND(MIT OR NonexistentLicense OR Apache-2.0)AND(Apache-2.0 AND Apache-2.0)", 0}, + // Nonexistent is not on the positive list, violation + new Object[] { "Apache-2.0 AND NonexistentLicense", 1}, + // Apache allowed + new Object[] { "Apache-2.0 OR MIT", 0} + }; + } + @Test public void noMatch() { License license = new License(); diff --git a/src/test/java/org/dependencytrack/policy/LicensePolicyEvaluatorTest.java b/src/test/java/org/dependencytrack/policy/LicensePolicyEvaluatorTest.java index 2b18b2fbac..9941d7b60f 100644 --- a/src/test/java/org/dependencytrack/policy/LicensePolicyEvaluatorTest.java +++ b/src/test/java/org/dependencytrack/policy/LicensePolicyEvaluatorTest.java @@ -67,8 +67,15 @@ public void noMatch() { license.setUuid(UUID.randomUUID()); license = qm.persist(license); + License otherLicense = new License(); + otherLicense.setName("WTFPL"); + otherLicense.setLicenseId("WTFPL"); + otherLicense.setUuid(UUID.randomUUID()); + otherLicense = qm.persist(otherLicense); + Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO); - qm.createPolicyCondition(policy, PolicyCondition.Subject.LICENSE, PolicyCondition.Operator.IS, UUID.randomUUID().toString()); + qm.createPolicyCondition(policy, PolicyCondition.Subject.LICENSE, PolicyCondition.Operator.IS, + otherLicense.getUuid().toString()); Component component = new Component(); component.setResolvedLicense(license); List violations = evaluator.evaluate(policy, component);