RubyBundleAuditAnalyzer.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) 2015 Institute for Defense Analyses. All Rights Reserved.
 */
package org.owasp.dependencycheck.analyzer;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.annotation.concurrent.ThreadSafe;
import org.apache.commons.lang3.StringUtils;

import org.owasp.dependencycheck.Engine;
import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
import org.owasp.dependencycheck.data.nvd.ecosystem.Ecosystem;
import org.owasp.dependencycheck.data.nvdcve.CveDB;
import org.owasp.dependencycheck.dependency.Dependency;
import org.owasp.dependencycheck.exception.InitializationException;
import org.owasp.dependencycheck.processing.BundlerAuditProcessor;
import org.owasp.dependencycheck.utils.FileFilterBuilder;
import org.owasp.dependencycheck.utils.processing.ProcessReader;
import org.owasp.dependencycheck.utils.Settings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import us.springett.parsers.cpe.exceptions.CpeValidationException;

/**
 * Used to analyze Ruby Bundler Gemspec.lock files utilizing the 3rd party
 * bundle-audit tool.
 *
 * @author Dale Visser
 */
@ThreadSafe
public class RubyBundleAuditAnalyzer extends AbstractFileTypeAnalyzer {

    /**
     * The logger.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(RubyBundleAuditAnalyzer.class);

    /**
     * A descriptor for the type of dependencies processed or added by this
     * analyzer.
     */
    public static final String DEPENDENCY_ECOSYSTEM = Ecosystem.RUBY;

    /**
     * The name of the analyzer.
     */
    private static final String ANALYZER_NAME = "Ruby Bundle Audit Analyzer";

    /**
     * The phase that this analyzer is intended to run in.
     */
    private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.PRE_INFORMATION_COLLECTION;
    /**
     * The filter defining which files will be analyzed.
     */
    private static final FileFilter FILTER = FileFilterBuilder.newInstance().addFilenames("Gemfile.lock").build();
    /**
     * Name.
     */
    public static final String NAME = "Name: ";
    /**
     * Version.
     */
    public static final String VERSION = "Version: ";
    /**
     * Advisory.
     */
    public static final String ADVISORY = "Advisory: ";
    /**
     * CVE.
     */
    public static final String CVE = "CVE: ";
    /**
     * Criticality.
     */
    public static final String CRITICALITY = "Criticality: ";

    /**
     * The DAL.
     */
    private CveDB cvedb = null;

    /**
     * If {@link #analyzeDependency(Dependency, Engine)} is called, then we have
     * successfully initialized, and it will be necessary to disable
     * {@link RubyGemspecAnalyzer}.
     */
    private boolean needToDisableGemspecAnalyzer = true;

    /**
     * @return a filter that accepts files named Gemfile.lock
     */
    @Override
    protected FileFilter getFileFilter() {
        return FILTER;
    }

    /**
     * Returns the name of the analyzer.
     *
     * @return the name of the analyzer.
     */
    @Override
    public String getName() {
        return ANALYZER_NAME;
    }

    /**
     * Returns the phase that the analyzer is intended to run in.
     *
     * @return the phase that the analyzer is intended to run in.
     */
    @Override
    public AnalysisPhase getAnalysisPhase() {
        return ANALYSIS_PHASE;
    }

    /**
     * Returns the key used in the properties file to reference the analyzer's
     * enabled property.
     *
     * @return the analyzer's enabled property setting key
     */
    @Override
    protected String getAnalyzerEnabledSettingKey() {
        return Settings.KEYS.ANALYZER_BUNDLE_AUDIT_ENABLED;
    }

    /**
     * Launch bundle-audit.
     *
     * @param folder directory that contains bundle audit
     * @param bundleAuditArgs the arguments to pass to bundle audit
     * @return a handle to the process
     * @throws AnalysisException thrown when there is an issue launching bundle
     * audit
     */
    private Process launchBundleAudit(File folder, List<String> bundleAuditArgs) throws AnalysisException {
        if (!folder.isDirectory()) {
            throw new AnalysisException(String.format("%s should have been a directory.", folder.getAbsolutePath()));
        }
        final List<String> args = new ArrayList<>();
        final String bundleAuditPath = getSettings().getString(Settings.KEYS.ANALYZER_BUNDLE_AUDIT_PATH);
        File bundleAudit = null;
        if (bundleAuditPath != null) {
            bundleAudit = new File(bundleAuditPath);
            if (!bundleAudit.isFile()) {
                LOGGER.warn("Supplied `bundleAudit` path is incorrect: {}", bundleAuditPath);
                bundleAudit = null;
            }
        }
        args.add(bundleAudit != null ? bundleAudit.getAbsolutePath() : "bundle-audit");
        args.addAll(bundleAuditArgs);
        final ProcessBuilder builder = new ProcessBuilder(args);

        final String bundleAuditWorkingDirectoryPath = getSettings().getString(Settings.KEYS.ANALYZER_BUNDLE_AUDIT_WORKING_DIRECTORY);
        File bundleAuditWorkingDirectory = null;
        if (bundleAuditWorkingDirectoryPath != null) {
            bundleAuditWorkingDirectory = new File(bundleAuditWorkingDirectoryPath);
            if (!bundleAuditWorkingDirectory.isDirectory()) {
                LOGGER.warn("Supplied `bundleAuditWorkingDirectory` path is incorrect: {}",
                        bundleAuditWorkingDirectoryPath);
                bundleAuditWorkingDirectory = null;
            }
        }
        final File launchBundleAuditFromDirectory = bundleAuditWorkingDirectory != null ? bundleAuditWorkingDirectory : folder;
        builder.directory(launchBundleAuditFromDirectory);
        try {
            LOGGER.info("Launching: {} from {}", args, launchBundleAuditFromDirectory);
            return builder.start();
        } catch (IOException ioe) {
            throw new AnalysisException("bundle-audit initialization failure; this error "
                    + "can be ignored if you are not analyzing Ruby. Otherwise ensure that "
                    + "bundle-audit is installed and the path to bundle audit is correctly "
                    + "specified", ioe);
        }
    }

    /**
     * Initialize the analyzer.
     *
     * @param engine a reference to the dependency-checkException engine
     * @throws InitializationException if anything goes wrong
     */
    @Override
    public void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
        if (engine != null) {
            this.cvedb = engine.getDatabase();
        }
        String bundleAuditVersionDetails = null;
        try {
            final List<String> bundleAuditArgs = Collections.singletonList("version");
            final Process process = launchBundleAudit(getSettings().getTempDirectory(), bundleAuditArgs);
            try (ProcessReader processReader = new ProcessReader(process)) {
                processReader.readAll();
                final String error = processReader.getError();
                if (error != null) {
                    LOGGER.warn("Warnings from bundle-audit {}", error);
                }
                bundleAuditVersionDetails = processReader.getOutput();
                final int exitValue = process.exitValue();
                if (exitValue != 0) {
                    setEnabled(false);
                    final String msg = String.format("bundle-audit execution failed - "
                            + "exit code: %d; error: %s ", exitValue, error);
                    throw new InitializationException(msg);
                }
            }
        } catch (AnalysisException ae) {
            setEnabled(false);
            final String msg = String.format("Exception from bundle-audit process: %s. "
                    + "Disabling %s", ae.getCause(), ANALYZER_NAME);
            throw new InitializationException(msg, ae);
        } catch (UnsupportedEncodingException ex) {
            setEnabled(false);
            throw new InitializationException("Unexpected bundle-audit encoding when "
                    + "reading input stream.", ex);
        } catch (IOException ex) {
            setEnabled(false);
            throw new InitializationException("Unable to read bundle-audit output.", ex);
        } catch (InterruptedException ex) {
            setEnabled(false);
            final String msg = String.format("Bundle-audit process was interrupted. "
                    + "Disabling %s", ANALYZER_NAME);
            Thread.currentThread().interrupt();
            throw new InitializationException(msg);
        }
        LOGGER.info("{} is enabled and is using bundle-audit with version details: {}. "
                + "Note: It is necessary to manually run \"bundle-audit update\" "
                + "occasionally to keep its database up to date.", ANALYZER_NAME,
                bundleAuditVersionDetails);
    }

    /**
     * Determines if the analyzer can analyze the given file type.
     *
     * @param dependency the dependency to determine if it can analyze
     * @param engine the dependency-checkException engine
     * @throws AnalysisException thrown if there is an analysis exception.
     */
    @Override
    protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
        if (needToDisableGemspecAnalyzer) {
            boolean failed = true;
            final String className = RubyGemspecAnalyzer.class.getName();
            for (FileTypeAnalyzer analyzer : engine.getFileTypeAnalyzers()) {
                if (analyzer instanceof RubyBundlerAnalyzer) {
                    ((RubyBundlerAnalyzer) analyzer).setEnabled(false);
                    LOGGER.info("Disabled {} to avoid noisy duplicate results.",
                            RubyBundlerAnalyzer.class.getName());
                } else if (analyzer instanceof RubyGemspecAnalyzer) {
                    ((RubyGemspecAnalyzer) analyzer).setEnabled(false);
                    LOGGER.info("Disabled {} to avoid noisy duplicate results.", className);
                    failed = false;
                }
            }
            needToDisableGemspecAnalyzer = false;
        }
        final File parentFile = dependency.getActualFile().getParentFile();
        final List<String> bundleAuditArgs = Arrays.asList("check", "--verbose");

        final Process process = launchBundleAudit(parentFile, bundleAuditArgs);
        try (BundlerAuditProcessor processor = new BundlerAuditProcessor(dependency, engine);
                ProcessReader processReader = new ProcessReader(process, processor)) {

            processReader.readAll();
            final String error = processReader.getError();
            if (StringUtils.isNoneBlank(error)) {
                LOGGER.warn("Warnings from bundle-audit {}", error);
            }
            final int exitValue = process.exitValue();
            if (exitValue < 0 || exitValue > 1) {
                final String msg = String.format("Unexpected exit code from bundle-audit "
                        + "process; exit code: %s", exitValue);
                throw new AnalysisException(msg);
            }
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
            throw new AnalysisException("bundle-audit process interrupted", ie);
        } catch (IOException | CpeValidationException ioe) {
            LOGGER.warn("bundle-audit failure", ioe);
            throw new AnalysisException("bunder-audit error: " + ioe.getMessage(), ioe);
        }
    }
}