VulnerableSoftware.java

/*
 * This file is part of dependency-check-core.
 *
 * 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.
 *
 * Copyright (c) 2018 Jeremy Long. All Rights Reserved.
 */
package org.owasp.dependencycheck.dependency;

import java.io.Serializable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.annotation.concurrent.ThreadSafe;

import org.apache.commons.lang3.builder.CompareToBuilder;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.jetbrains.annotations.NotNull;
import org.owasp.dependencycheck.analyzer.exception.UnexpectedAnalysisException;
import org.owasp.dependencycheck.utils.DependencyVersion;
import us.springett.parsers.cpe.Cpe;
import us.springett.parsers.cpe.ICpe;
import us.springett.parsers.cpe.exceptions.CpeValidationException;
import us.springett.parsers.cpe.util.Convert;
import us.springett.parsers.cpe.values.LogicalValue;
import us.springett.parsers.cpe.values.Part;

/**
 * A record containing information about vulnerable software. This is referenced
 * from a vulnerability.
 *
 * @author Jeremy Long
 */
@ThreadSafe
public class VulnerableSoftware extends Cpe implements Serializable {

    /**
     * The serial version UID.
     */
    private static final long serialVersionUID = 605319412326651052L;

    /**
     * The ending range, excluding the specified version, for matching
     * vulnerable software
     */
    private final String versionEndExcluding;
    /**
     * The ending range, including the specified version, for matching
     * vulnerable software
     */
    private final String versionEndIncluding;
    /**
     * The starting range, excluding the specified version, for matching
     * vulnerable software
     */
    private final String versionStartExcluding;
    /**
     * the starting range, including the specified version, for matching
     * vulnerable software
     */
    private final String versionStartIncluding;
    /**
     * A flag indicating whether this represents a vulnerable software object.
     */
    private final boolean vulnerable;

    /**
     * Constructs a new immutable VulnerableSoftware object that represents the
     * Well Form Named defined in the CPE 2.3 specification. Specifying
     * <code>null</code> will be set to the default
     * {@link us.springett.parsers.cpe.values.LogicalValue#ANY}. All values
     * passed in must be well formed (i.e. special characters quoted with a
     * backslash).
     *
     * @see <a href="https://cpe.mitre.org/specification/">CPE 2.3</a>
     * @param part the type of entry: application, operating system, or hardware
     * @param vendor the vendor of the CPE entry
     * @param product the product of the CPE entry
     * @param version the version of the CPE entry
     * @param update the update of the CPE entry
     * @param edition the edition of the CPE entry
     * @param language the language of the CPE entry
     * @param swEdition the swEdition of the CPE entry
     * @param targetSw the targetSw of the CPE entry
     * @param targetHw the targetHw of the CPE entry
     * @param other the other of the CPE entry
     * @param versionEndExcluding the ending range, excluding the specified
     * version, for matching vulnerable software
     * @param versionEndIncluding the ending range, including the specified
     * version, for matching vulnerable software
     * @param versionStartExcluding the starting range, excluding the specified
     * version, for matching vulnerable software
     * @param versionStartIncluding the starting range, including the specified
     * version, for matching vulnerable software
     * @param vulnerable whether or not this represents a vulnerable software
     * item
     * @throws CpeValidationException thrown if one of the CPE entries is
     * invalid
     */
    //CSOFF: ParameterNumber
    public VulnerableSoftware(Part part, String vendor, String product, String version,
            String update, String edition, String language, String swEdition,
            String targetSw, String targetHw, String other,
            String versionEndExcluding, String versionEndIncluding, String versionStartExcluding,
            String versionStartIncluding, boolean vulnerable) throws CpeValidationException {
        super(part, vendor, product, version, update, edition, language, swEdition, targetSw, targetHw, other);
        this.versionEndExcluding = versionEndExcluding;
        this.versionEndIncluding = versionEndIncluding;
        this.versionStartExcluding = versionStartExcluding;
        this.versionStartIncluding = versionStartIncluding;
        this.vulnerable = vulnerable;
    }
    //CSON: ParameterNumber

    @Override
    public int compareTo(@NotNull Object o) {
        if (o instanceof VulnerableSoftware) {
            final VulnerableSoftware other = (VulnerableSoftware) o;
            return new CompareToBuilder()
                    .appendSuper(super.compareTo(other))
                    .append(versionStartIncluding, other.versionStartIncluding)
                    .append(versionStartExcluding, other.versionStartExcluding)
                    .append(versionEndIncluding, other.versionEndIncluding)
                    .append(versionEndExcluding, other.versionEndExcluding)
                    .append(this.vulnerable, other.vulnerable)
                    .build();
        } else if (o instanceof Cpe) {
            return super.compareTo(o);
        }
        throw new UnexpectedAnalysisException("Unable to compare " + o.getClass().getCanonicalName());
    }

    @Override
    public int hashCode() {
        // you pick a hard-coded, randomly chosen, non-zero, odd number
        // ideally different for each class
        return new HashCodeBuilder(13, 59)
                .appendSuper(super.hashCode())
                .append(versionEndExcluding)
                .append(versionEndIncluding)
                .append(versionStartExcluding)
                .append(versionStartIncluding)
                .toHashCode();
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == null || !(obj instanceof VulnerableSoftware)) {
            return false;
        }
        if (this == obj) {
            return true;
        }
        final VulnerableSoftware rhs = (VulnerableSoftware) obj;
        return new EqualsBuilder()
                .appendSuper(super.equals(obj))
                .append(versionEndExcluding, rhs.versionEndExcluding)
                .append(versionEndIncluding, rhs.versionEndIncluding)
                .append(versionStartExcluding, rhs.versionStartExcluding)
                .append(versionStartIncluding, rhs.versionStartIncluding)
                .isEquals();
    }

    /**
     * <p>
     * Determines if the VulnerableSoftware matches the given target
     * VulnerableSoftware. This does not follow the CPE 2.3 Specification
     * exactly as there are cases where undefined comparisons will result in
     * either true or false. For instance, 'ANY' will match 'm+wild cards' and
     * NA will return false when the target has 'm+wild cards'.</p>
     * <p>
     * For vulnerable software matching, the implementation also takes into
     * account version ranges as specified within the NVD data feeds.</p>
     *
     * @param target the target CPE to evaluate
     * @return <code>true</code> if the CPE matches the target; otherwise
     * <code>false</code>
     */
    @Override
    public boolean matches(ICpe target) {
        boolean result = this.vulnerable;
        result &= compareAttributes(this.getPart(), target.getPart());
        result &= compareAttributes(this.getVendor(), target.getVendor());
        result &= compareAttributes(this.getProduct(), target.getProduct());

        //TODO implement versionStart etc.
        result &= compareVersionRange(target.getVersion());

        //todo - if the vulnerablity has an update we are might not be collecting it correctly...
        // as such, this check might cause FN if the CVE has an update in the data set
        result &= compareUpdateAttributes(this.getUpdate(), target.getUpdate());
        result &= compareAttributes(this.getEdition(), target.getEdition());
        result &= compareAttributes(this.getLanguage(), target.getLanguage());
        result &= compareAttributes(this.getSwEdition(), target.getSwEdition());
        result &= compareAttributes(this.getTargetSw(), target.getTargetSw());
        result &= compareAttributes(this.getTargetHw(), target.getTargetHw());
        result &= compareAttributes(this.getOther(), target.getOther());
        return result;
    }

    /**
     * Performs the same operation as Cpe.compareAttributes() - except
     * additional rules are applied to match a1 to alpha1 and the comparison of
     * update attributes will also return true if the only difference between
     * the strings is an underscore or hyphen.
     *
     * @param left the left value to compare
     * @param right the right value to compare
     * @return <code>true</code> if there is a match; otherwise
     * <code>false</code>
     */
    protected static boolean compareUpdateAttributes(String left, String right) {
        //the numbers below come from the CPE Matching standard
        //Table 6-2: Enumeration of Attribute Comparison Set Relations
        //https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir7696.pdf

        if (left.equalsIgnoreCase(right)) {
            //1 6 9 - equals
            return true;
        } else if (LogicalValue.ANY.getAbbreviation().equals(left)) {
            //2 3 4 - superset (4 is undefined - treating as true)
            return true;
        } else if (LogicalValue.NA.getAbbreviation().equals(left)
                && LogicalValue.ANY.getAbbreviation().equals(right)) {
            //5 - subset
            return true;
        } else if (LogicalValue.NA.getAbbreviation().equals(left)) {
            //7 8 - disjoint, undefined
            return false;
        } else if (LogicalValue.NA.getAbbreviation().equals(right)) {
            //12 16 - disjoint
            return false;
        } else if (LogicalValue.ANY.getAbbreviation().equals(right)) {
            //13 15 - subset
            return true;
        }
        final String leftValue = left.replace("-", "").replace("_", "");
        final String rightValue = right.replace("-", "").replace("_", "");
        if (leftValue.equalsIgnoreCase(rightValue)) {
            //1 6 9 - equals
            return true;
        }

        boolean results = false;
        //10 11 14 17
        if (containsSpecialCharacter(left)) {
            final Pattern p = Convert.wellFormedToPattern(left.toLowerCase());
            final Matcher m = p.matcher(right.toLowerCase());
            results = m.matches();
        }
        if (!results && rightValue.matches("^[abu]\\d.*") && leftValue.matches("^(update|alpha|beta).*")) {
            switch (right.charAt(0)) {
                case 'u':
                    results = compareUpdateAttributes(leftValue, "update" + rightValue.substring(1));
                    break;
                case 'a':
                    results = compareUpdateAttributes(leftValue, "alpha" + rightValue.substring(1));
                    break;
                case 'b':
                    results = compareUpdateAttributes(leftValue, "beta" + rightValue.substring(1));
                    break;
                default:
                    break;
            }
        }
        if (!results && leftValue.matches("^[abu]\\d.*") && rightValue.matches("^(update|alpha|beta).*")) {
            switch (left.charAt(0)) {
                case 'u':
                    results = compareUpdateAttributes("update" + leftValue.substring(1), rightValue);
                    break;
                case 'a':
                    results = compareUpdateAttributes("alpha" + leftValue.substring(1), rightValue);
                    break;
                case 'b':
                    results = compareUpdateAttributes("beta" + leftValue.substring(1), rightValue);
                    break;
                default:
                    break;
            }
        }
        return results;
    }

    /**
     * Determines if the string has an unquoted special character.
     *
     * @param value the string to check
     * @return <code>true</code> if the string contains an unquoted special
     * character; otherwise <code>false</code>
     */
    private static boolean containsSpecialCharacter(String value) {
        for (int x = 0; x < value.length(); x++) {
            final char c = value.charAt(x);
            if (c == '?' || c == '*') {
                return true;
            } else if (c == '\\') {
                //skip the next character because it is quoted
                x += 1;
            }
        }
        return false;
    }

    /**
     * Tests if the left matches the right.
     *
     * @param left the cpe to compare
     * @param right the cpe to check
     * @return <code>true</code> if a match is found; otherwise
     * <code>false</code>
     */
    public static boolean testMatch(ICpe left, ICpe right) {
        boolean result = true;
        result &= compareAttributes(left.getPart(), right.getPart());
        result &= compareAttributes(left.getWellFormedVendor(), right.getWellFormedVendor());
        result &= compareAttributes(left.getWellFormedProduct(), right.getWellFormedProduct());

        if (right instanceof VulnerableSoftware) {
            final VulnerableSoftware vs = (VulnerableSoftware) right;
            result &= vs.vulnerable;
            result &= compareVersions(vs, left.getVersion());
        } else if (left instanceof VulnerableSoftware) {
            final VulnerableSoftware vs = (VulnerableSoftware) left;
            result &= vs.vulnerable;
            result &= compareVersions(vs, right.getVersion());
        } else {
            result &= compareAttributes(left.getWellFormedVersion(), right.getWellFormedVersion());
        }

        //todo - if the vulnerablity has an update we are might not be collecting it correctly...
        // as such, this check might cause FN if the CVE has an update in the data set
        result &= compareUpdateAttributes(left.getWellFormedUpdate(), right.getWellFormedUpdate());
        result &= compareAttributes(left.getWellFormedEdition(), right.getWellFormedEdition());
        result &= compareAttributes(left.getWellFormedLanguage(), right.getWellFormedLanguage());
        result &= compareAttributes(left.getWellFormedSwEdition(), right.getWellFormedSwEdition());
        result &= compareAttributes(left.getWellFormedTargetSw(), right.getWellFormedTargetSw());
        result &= compareAttributes(left.getWellFormedTargetHw(), right.getWellFormedTargetHw());
        result &= compareAttributes(left.getWellFormedOther(), right.getWellFormedOther());
        return result;
    }

    /**
     * <p>
     * Determines if the target VulnerableSoftware matches the
     * VulnerableSoftware. This does not follow the CPE 2.3 Specification
     * exactly as there are cases where undefined comparisons will result in
     * either true or false. For instance, 'ANY' will match 'm+wild cards' and
     * NA will return false when the target has 'm+wild cards'.</p>
     * <p>
     * For vulnerable software matching, the implementation also takes into
     * account version ranges as specified within the NVD data feeds.</p>
     *
     * @param target the VulnerableSoftware to evaluate
     * @return <code>true</code> if the target CPE matches CPE; otherwise
     * <code>false</code>
     */
    @Override
    public boolean matchedBy(ICpe target) {
        return testMatch(target, this);
    }

    /**
     * Evaluates the target against the version and version range checks:
     * versionEndExcluding, versionStartExcluding versionEndIncluding, and
     * versionStartIncluding.
     *
     * @param targetVersion the version to compare
     * @return <code>true</code> if the target version is matched; otherwise
     * <code>false</code>
     */
    protected boolean compareVersionRange(String targetVersion) {
        return compareVersions(this, targetVersion);
    }

    /**
     * Evaluates the target against the version and version range checks:
     * versionEndExcluding, versionStartExcluding versionEndIncluding, and
     * versionStartIncluding.
     *
     * @param vs a reference to the vulnerable software to compare
     * @param targetVersion the version to compare
     * @return <code>true</code> if the target version is matched; otherwise
     * <code>false</code>
     */
    protected static boolean compareVersions(VulnerableSoftware vs, String targetVersion) {
        if (LogicalValue.NA.getAbbreviation().equals(vs.getVersion())) {
            return false;
        }
        //if any of the four conditions will be evaluated - then true;
        boolean result = (vs.versionEndExcluding != null && !vs.versionEndExcluding.isEmpty())
                || (vs.versionStartExcluding != null && !vs.versionStartExcluding.isEmpty())
                || (vs.versionEndIncluding != null && !vs.versionEndIncluding.isEmpty())
                || (vs.versionStartIncluding != null && !vs.versionStartIncluding.isEmpty());

        if (!result && compareAttributes(vs.getVersion(), targetVersion)) {
            return true;
        }

        final DependencyVersion target = new DependencyVersion(targetVersion);
        if (target.getVersionParts().isEmpty()) {
            return false;
        }
        if (result && vs.versionEndExcluding != null && !vs.versionEndExcluding.isEmpty()) {
            final DependencyVersion endExcluding = new DependencyVersion(vs.versionEndExcluding);
            result = endExcluding.compareTo(target) > 0;
        }
        if (result && vs.versionStartExcluding != null && !vs.versionStartExcluding.isEmpty()) {
            final DependencyVersion startExcluding = new DependencyVersion(vs.versionStartExcluding);
            result = startExcluding.compareTo(target) < 0;
        }
        if (result && vs.versionEndIncluding != null && !vs.versionEndIncluding.isEmpty()) {
            final DependencyVersion endIncluding = new DependencyVersion(vs.versionEndIncluding);
            result &= endIncluding.compareTo(target) >= 0;
        }
        if (result && vs.versionStartIncluding != null && !vs.versionStartIncluding.isEmpty()) {
            final DependencyVersion startIncluding = new DependencyVersion(vs.versionStartIncluding);
            result &= startIncluding.compareTo(target) <= 0;
        }
        return result;
    }

    /**
     * Returns the versionEndExcluding.
     *
     * @return the versionEndExcluding
     */
    public String getVersionEndExcluding() {
        return versionEndExcluding;
    }

    /**
     * Returns the versionEndIncluding.
     *
     * @return the versionEndIncluding
     */
    public String getVersionEndIncluding() {
        return versionEndIncluding;
    }

    /**
     * Returns the versionStartExcluding.
     *
     * @return the versionStartExcluding
     */
    public String getVersionStartExcluding() {
        return versionStartExcluding;
    }

    /**
     * Returns the versionStartIncluding.
     *
     * @return the versionStartIncluding
     */
    public String getVersionStartIncluding() {
        return versionStartIncluding;
    }

    /**
     * Returns the value of vulnerable.
     *
     * @return the value of vulnerable
     */
    public boolean isVulnerable() {
        return vulnerable;
    }

    @Override
    public String toString() {
        final StringBuilder sb = new StringBuilder();
        sb.append(this.toCpe23FS());
        boolean textAdded = false;
        if (versionStartIncluding != null && !versionStartIncluding.isEmpty()) {
            sb.append(" versions from (including) ")
                    .append(versionStartIncluding);
            textAdded = true;
        }
        if (versionStartExcluding != null && !versionStartExcluding.isEmpty()) {
            if (textAdded) {
                sb.append(";");
            }
            sb.append(" versions from (excluding) ")
                    .append(versionStartExcluding);
            textAdded = true;
        }
        if (versionEndIncluding != null && !versionEndIncluding.isEmpty()) {
            if (textAdded) {
                sb.append(";");
            }
            sb.append(" versions up to (including) ")
                    .append(versionEndIncluding);
            textAdded = true;
        }
        if (versionEndExcluding != null && !versionEndExcluding.isEmpty()) {
            if (textAdded) {
                sb.append(";");
            }
            sb.append(" versions up to (excluding) ")
                    .append(versionEndExcluding);
            textAdded = true;
        }
        if (!vulnerable) {
            if (textAdded) {
                sb.append(";");
            }
            sb.append(" version is NOT VULNERABLE");
        }
        return sb.toString();
    }
}