BundlerAuditProcessor.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) 2020 Jeremy Long. All Rights Reserved.
 */
package org.owasp.dependencycheck.processing;

import com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;
import com.github.packageurl.PackageURLBuilder;
import io.github.jeremylong.openvulnerability.client.nvd.CvssV2;
import io.github.jeremylong.openvulnerability.client.nvd.CvssV2Data;
import org.owasp.dependencycheck.Engine;
import org.owasp.dependencycheck.data.nvdcve.CveDB;
import org.owasp.dependencycheck.data.nvdcve.DatabaseException;
import org.owasp.dependencycheck.dependency.Confidence;
import org.owasp.dependencycheck.dependency.Dependency;
import org.owasp.dependencycheck.dependency.EvidenceType;
import org.owasp.dependencycheck.dependency.Reference;
import org.owasp.dependencycheck.dependency.Vulnerability;
import org.owasp.dependencycheck.dependency.VulnerableSoftware;
import org.owasp.dependencycheck.dependency.VulnerableSoftwareBuilder;
import org.owasp.dependencycheck.dependency.naming.GenericIdentifier;
import org.owasp.dependencycheck.dependency.naming.PurlIdentifier;
import org.owasp.dependencycheck.utils.Checksum;
import org.owasp.dependencycheck.utils.processing.Processor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import us.springett.parsers.cpe.exceptions.CpeValidationException;
import us.springett.parsers.cpe.values.Part;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

import static org.owasp.dependencycheck.analyzer.RubyBundleAuditAnalyzer.ADVISORY;
import static org.owasp.dependencycheck.analyzer.RubyBundleAuditAnalyzer.CRITICALITY;
import static org.owasp.dependencycheck.analyzer.RubyBundleAuditAnalyzer.CVE;
import static org.owasp.dependencycheck.analyzer.RubyBundleAuditAnalyzer.DEPENDENCY_ECOSYSTEM;
import static org.owasp.dependencycheck.analyzer.RubyBundleAuditAnalyzer.NAME;
import static org.owasp.dependencycheck.analyzer.RubyBundleAuditAnalyzer.VERSION;

/**
 * Processor for the output of bundler-audit.
 *
 * @author Jeremy Long
 */
public class BundlerAuditProcessor extends Processor<InputStream> {

    /**
     * The logger.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(BundlerAuditProcessor.class);
    /**
     * Reference to the gem lock dependency.
     */
    private final Dependency gemDependency;
    /**
     * Reference to the dependency-check engine.
     */
    private final Engine engine;
    /**
     * Temporary storage for an exception if it occurs during the processing.
     */
    private IOException ioException;
    /**
     * Temporary storage for an exception if it occurs during the processing.
     */
    private CpeValidationException cpeException;

    /**
     * Constructs a new processor to consume the output of `bundler-audit`.
     *
     * @param gemDependency a reference to `gem.lock` dependency
     * @param engine a reference to the dependency-check engine
     */
    public BundlerAuditProcessor(Dependency gemDependency, Engine engine) {
        this.gemDependency = gemDependency;
        this.engine = engine;
    }

    /**
     * Throws any exceptions that occurred during processing.
     *
     * @throws IOException thrown if an IO Exception occurred
     * @throws CpeValidationException thrown if a CPE validation exception
     * occurred
     */
    @Override
    public void close() throws IOException, CpeValidationException {
        if (ioException != null) {
            addSuppressedExceptions(ioException, cpeException);
            throw ioException;
        }
        if (cpeException != null) {
            throw cpeException;
        }
    }

    @Override
    public void run() {
        final String parentName = gemDependency.getActualFile().getParentFile().getName();
        final String fileName = gemDependency.getFileName();
        final String filePath = gemDependency.getFilePath();
        Dependency dependency = null;
        Vulnerability vulnerability = null;
        String gem = null;
        final Map<String, Dependency> map = new HashMap<>();
        boolean appendToDescription = false;

        try (InputStreamReader ir = new InputStreamReader(getInput(), StandardCharsets.UTF_8); BufferedReader br = new BufferedReader(ir)) {

            String nextLine;
            while ((nextLine = br.readLine()) != null) {
                if (nextLine.startsWith(NAME)) {
                    appendToDescription = false;
                    gem = nextLine.substring(NAME.length());
                    if (!map.containsKey(gem)) {
                        map.put(gem, createDependencyForGem(engine, gemDependency.getActualFile(), parentName, fileName, filePath, gem));
                    }
                    dependency = map.get(gem);
                    LOGGER.debug("bundle-audit ({}): {}", parentName, nextLine);
                } else if (nextLine.startsWith(VERSION)) {
                    vulnerability = createVulnerability(parentName, dependency, gem, nextLine);
                } else if (nextLine.startsWith(ADVISORY) || nextLine.startsWith(CVE)) {
                    setVulnerabilityName(parentName, dependency, vulnerability, nextLine);
                } else if (nextLine.startsWith(CRITICALITY)) {
                    addCriticalityToVulnerability(parentName, vulnerability, nextLine);
                } else if (nextLine.startsWith("URL: ")) {
                    addReferenceToVulnerability(parentName, vulnerability, nextLine);
                } else if (nextLine.startsWith("Description:") || nextLine.startsWith("Title:")) {
                    appendToDescription = true;
                    if (null != vulnerability) {
                        vulnerability.setDescription("*** Vulnerability obtained from bundle-audit verbose report. "
                                + "Title link may not work. CPE below is guessed. CVSS score is estimated (-1.0 "
                                + " indicates unknown). See link below for full details. *** ");
                    }
                } else if (appendToDescription && null != vulnerability) {
                    vulnerability.setDescription(vulnerability.getDescription() + nextLine + "\n");
                }
            }
        } catch (IOException ex) {
            this.ioException = ex;
        } catch (CpeValidationException ex) {
            this.cpeException = ex;
        }
    }

    /**
     * Sets the vulnerability name.
     *
     * @param parentName the parent name
     * @param dependency the dependency
     * @param vulnerability the vulnerability
     * @param nextLine the line to parse
     */
    private void setVulnerabilityName(String parentName, Dependency dependency, Vulnerability vulnerability, String nextLine) {
        final String advisory;
        if (nextLine.startsWith(CVE)) {
            advisory = nextLine.substring(CVE.length());
        } else {
            advisory = nextLine.substring(ADVISORY.length());
        }
        if (null != vulnerability) {
            vulnerability.setName(advisory);
        }
        if (null != dependency) {
            dependency.addVulnerability(vulnerability);
        }
        LOGGER.debug("bundle-audit ({}): {}", parentName, nextLine);
    }

    /**
     * Adds a reference to the vulnerability.
     *
     * @param parentName the parent name
     * @param vulnerability the vulnerability
     * @param nextLine the line to parse
     */
    private void addReferenceToVulnerability(String parentName, Vulnerability vulnerability, String nextLine) {
        final String url = nextLine.substring("URL: ".length());
        if (null != vulnerability) {
            final Reference ref = new Reference();
            ref.setName(vulnerability.getName());
            ref.setSource("bundle-audit");
            ref.setUrl(url);
            vulnerability.getReferences().add(ref);
        }
        LOGGER.debug("bundle-audit ({}): {}", parentName, nextLine);
    }

    /**
     * Adds the criticality to the vulnerability
     *
     * @param parentName the parent name
     * @param vulnerability the vulnerability
     * @param nextLine the line to parse
     */
    private void addCriticalityToVulnerability(String parentName, Vulnerability vulnerability, String nextLine) {
        if (null != vulnerability) {
            final String criticality = nextLine.substring(CRITICALITY.length()).trim();
            Double score = -1.0;
            Vulnerability v = null;
            final CveDB cvedb = engine.getDatabase();
            if (cvedb != null) {
                try {
                    v = cvedb.getVulnerability(vulnerability.getName());
                } catch (DatabaseException ex) {
                    LOGGER.debug("Unable to look up vulnerability {}", vulnerability.getName());
                }
            }
            if (v != null && (v.getCvssV2() != null || v.getCvssV3() != null)) {
                if (v.getCvssV2() != null) {
                    vulnerability.setCvssV2(v.getCvssV2());
                }
                if (v.getCvssV3() != null) {
                    vulnerability.setCvssV3(v.getCvssV3());
                }
            } else {
                if ("High".equalsIgnoreCase(criticality)) {
                    score = 8.5;
                } else if ("Medium".equalsIgnoreCase(criticality)) {
                    score = 5.5;
                } else if ("Low".equalsIgnoreCase(criticality)) {
                    score = 2.0;
                }
                LOGGER.debug("bundle-audit vulnerability missing CVSS data: {}", vulnerability.getName());
                final CvssV2Data cvssData = new CvssV2Data("2.0", null, null, null, null, null, null, null, score, criticality.toUpperCase(),
                        null, null, null, null, null, null, null, null, null, null);
                final CvssV2 cvssV2 = new CvssV2(null, null, cvssData, criticality.toUpperCase(), null, null, null, null, null, null, null);
                vulnerability.setCvssV2(cvssV2);
                vulnerability.setUnscoredSeverity(null);
            }
        }
        LOGGER.debug("bundle-audit ({}): {}", parentName, nextLine);
    }

    /**
     * Creates a vulnerability.
     *
     * @param parentName the parent name
     * @param dependency the dependency
     * @param gem the gem name
     * @param nextLine the line to parse
     * @return the vulnerability
     * @throws CpeValidationException thrown if there is an error building the
     * CPE vulnerability object
     */
    private Vulnerability createVulnerability(String parentName, Dependency dependency, String gem, String nextLine) throws CpeValidationException {
        Vulnerability vulnerability = null;
        if (null != dependency) {
            final String version = nextLine.substring(VERSION.length());
            dependency.addEvidence(EvidenceType.VERSION,
                    "bundler-audit",
                    "Version",
                    version,
                    Confidence.HIGHEST);
            dependency.setVersion(version);
            dependency.setName(gem);
            try {
                final PackageURL purl = PackageURLBuilder.aPackageURL().withType("gem").withName(dependency.getName())
                        .withVersion(dependency.getVersion()).build();
                dependency.addSoftwareIdentifier(new PurlIdentifier(purl, Confidence.HIGHEST));
            } catch (MalformedPackageURLException ex) {
                LOGGER.debug("Unable to build package url for python", ex);
                final GenericIdentifier id = new GenericIdentifier("gem:" + dependency.getName() + "@" + dependency.getVersion(),
                        Confidence.HIGHEST);
                dependency.addSoftwareIdentifier(id);
            }

            vulnerability = new Vulnerability(); // don't add to dependency until we have name set later
            vulnerability.setSource(Vulnerability.Source.BUNDLEAUDIT);
            final VulnerableSoftwareBuilder builder = new VulnerableSoftwareBuilder();
            final VulnerableSoftware vs = builder.part(Part.APPLICATION)
                    .vendor(gem)
                    .product(String.format("%s_project", gem))
                    .version(version).build();
            vulnerability.addVulnerableSoftware(vs);
            vulnerability.setMatchedVulnerableSoftware(vs);
            vulnerability.setUnscoredSeverity("UNKNOWN");
        }
        LOGGER.debug("bundle-audit ({}): {}", parentName, nextLine);
        return vulnerability;
    }

    /**
     * Creates the dependency based off of the gem.
     *
     * @param engine the engine used for scanning
     * @param gemFile the gem file
     * @param parentName the gem parent
     * @param fileName the file name
     * @param filePath the file path
     * @param gem the gem name
     * @return the dependency to add
     * @throws IOException thrown if a temporary gem file could not be written
     */
    private Dependency createDependencyForGem(Engine engine, File gemFile, String parentName, String fileName,
            String filePath, String gem) throws IOException {
        final String displayFileName = String.format("%s%c%s:%s", parentName, File.separatorChar, fileName, gem);
        final Dependency dependency = new Dependency(gemFile, true);
        dependency.setSha1sum(Checksum.getSHA1Checksum(displayFileName));
        dependency.setEcosystem(DEPENDENCY_ECOSYSTEM);
        dependency.addEvidence(EvidenceType.PRODUCT, "bundler-audit", "Name", gem, Confidence.HIGHEST);
        //TODO add package URL - note, this may require parsing the gemfile.lock and getting the version for each entry

        dependency.setDisplayFileName(displayFileName);
        dependency.setFileName(fileName);
        dependency.setFilePath(filePath);
        //sha1sum is used for anchor links in the HtML report
        dependency.setSha1sum(Checksum.getSHA1Checksum(displayFileName));
        engine.addDependency(dependency);
        return dependency;
    }
}