RubyGemspecAnalyzer.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 com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;
import com.github.packageurl.PackageURLBuilder;
import org.owasp.dependencycheck.Engine;
import org.owasp.dependencycheck.analyzer.exception.AnalysisException;
import org.owasp.dependencycheck.data.nvd.ecosystem.Ecosystem;
import org.owasp.dependencycheck.dependency.Confidence;
import org.owasp.dependencycheck.dependency.Dependency;
import org.owasp.dependencycheck.dependency.EvidenceType;
import org.owasp.dependencycheck.dependency.naming.GenericIdentifier;
import org.owasp.dependencycheck.dependency.naming.PurlIdentifier;
import org.owasp.dependencycheck.exception.InitializationException;
import org.owasp.dependencycheck.utils.FileFilterBuilder;
import org.owasp.dependencycheck.utils.Settings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.concurrent.ThreadSafe;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Used to analyze Ruby Gem specifications and collect information that can be
 * used to determine the associated CPE. Regular expressions are used to parse
 * the well-defined Ruby syntax that forms the specification.
 *
 * @author Dale Visser
 */
@Experimental
@ThreadSafe
public class RubyGemspecAnalyzer extends AbstractFileTypeAnalyzer {

    /**
     * A descriptor for the type of dependencies processed or added by this
     * analyzer.
     */
    public static final String DEPENDENCY_ECOSYSTEM = Ecosystem.RUBY;
    /**
     * The logger.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(RubyGemspecAnalyzer.class);
    /**
     * The name of the analyzer.
     */
    private static final String ANALYZER_NAME = "Ruby Gemspec Analyzer";
    /**
     * The phase that this analyzer is intended to run in.
     */
    private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.INFORMATION_COLLECTION;
    /**
     * The gemspec file extension.
     */
    private static final String GEMSPEC = "gemspec";
    /**
     * The file filter containing the list of file extensions that can be
     * analyzed.
     */
    private static final FileFilter FILTER = FileFilterBuilder.newInstance().addExtensions(GEMSPEC).build();
    //TODO: support Rakefile
    //= FileFilterBuilder.newInstance().addExtensions(GEMSPEC).addFilenames("Rakefile").build();

    /**
     * The name of the version file.
     */
    private static final String VERSION_FILE_NAME = "VERSION";

    /**
     * The capture group #1 is the block variable.
     */
    private static final Pattern GEMSPEC_BLOCK_INIT = Pattern.compile("Gem::Specification\\.new\\s+?do\\s+?\\|(.+?)\\|");

    /**
     * @return a filter that accepts files matching the glob pattern, *.gemspec
     */
    @Override
    protected FileFilter getFileFilter() {
        return FILTER;
    }

    @Override
    protected void prepareFileTypeAnalyzer(Engine engine) throws InitializationException {
        // NO-OP
    }

    /**
     * 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_RUBY_GEMSPEC_ENABLED;
    }

    @Override
    protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
        dependency.setEcosystem(DEPENDENCY_ECOSYSTEM);
        String contents;
        try {
            contents = new String(Files.readAllBytes(dependency.getActualFile().toPath()), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new AnalysisException(
                    "Problem occurred while reading dependency file.", e);
        }
        final Matcher matcher = GEMSPEC_BLOCK_INIT.matcher(contents);
        if (matcher.find()) {
            contents = contents.substring(matcher.end());
            final String blockVariable = matcher.group(1);

            final String name = addStringEvidence(dependency, EvidenceType.PRODUCT, contents, blockVariable, "name", "name", Confidence.HIGHEST);
            if (!name.isEmpty()) {
                dependency.addEvidence(EvidenceType.VENDOR, GEMSPEC, "name_project", name + "_project", Confidence.LOW);
                dependency.setName(name);
            }
            final String description = addStringEvidence(dependency, EvidenceType.PRODUCT, contents, blockVariable,
                    "summary", "summary", Confidence.LOW);
            if (description != null && !description.isEmpty()) {
                dependency.setDescription(description);
            }
            addStringEvidence(dependency, EvidenceType.VENDOR, contents, blockVariable,
                    "author", "authors?", Confidence.HIGHEST);
            addStringEvidence(dependency, EvidenceType.VENDOR, contents, blockVariable,
                    "email", "emails?", Confidence.MEDIUM);
            addStringEvidence(dependency, EvidenceType.VENDOR, contents, blockVariable,
                    "homepage", "homepage", Confidence.HIGHEST);
            final String license = addStringEvidence(dependency, EvidenceType.VENDOR, contents, blockVariable,
                    "license", "licen[cs]es?", Confidence.HIGHEST);
            if (license != null && !license.isEmpty()) {
                dependency.setLicense(license);
            }
            final String value = addStringEvidence(dependency, EvidenceType.VERSION, contents,
                    blockVariable, "version", "version", Confidence.HIGHEST);
            if (value.length() < 1) {
                final String version = addEvidenceFromVersionFile(dependency, EvidenceType.VERSION, dependency.getActualFile());
                if (version != null) {
                    dependency.setVersion(version);
                }
            } else {
                dependency.setVersion(value);
            }
        }
        if (dependency.getName() != null && dependency.getVersion() != null) {
            dependency.setDisplayFileName(String.format("%s:%s", dependency.getName(), dependency.getVersion()));
        }

        try {
            final PackageURLBuilder builder = PackageURLBuilder.aPackageURL().withType("gem").withName(dependency.getName());
            if (dependency.getVersion() != null) {
                builder.withVersion(dependency.getVersion());
            }
            final PackageURL purl = builder.build();
            dependency.addSoftwareIdentifier(new PurlIdentifier(purl, Confidence.HIGHEST));
        } catch (MalformedPackageURLException ex) {
            LOGGER.debug("Unable to build package url for python", ex);
            final GenericIdentifier id;
            if (dependency.getVersion() != null) {
                id = new GenericIdentifier("gem:" + dependency.getName() + "@" + dependency.getVersion(), Confidence.HIGHEST);
            } else {
                id = new GenericIdentifier("gem:" + dependency.getName(), Confidence.HIGHEST);
            }
            dependency.addSoftwareIdentifier(id);
        }

        setPackagePath(dependency);
    }

    /**
     * Adds the specified evidence to the given evidence collection.
     *
     * @param dependency the dependency being analyzed
     * @param type the type of evidence to add
     * @param contents the evidence contents
     * @param blockVariable the variable
     * @param field the field
     * @param fieldPattern the field pattern
     * @param confidence the confidence of the evidence
     * @return the evidence string value added
     */
    private String addStringEvidence(Dependency dependency, EvidenceType type, String contents,
            String blockVariable, String field, String fieldPattern, Confidence confidence) {
        String value = "";

        //capture array value between [ ]
        final Matcher arrayMatcher = Pattern.compile(
                String.format("\\s*?%s\\.%s\\s*?=\\s*?\\[(.*?)\\]", blockVariable, fieldPattern), Pattern.CASE_INSENSITIVE).matcher(contents);
        if (arrayMatcher.find()) {
            final String arrayValue = arrayMatcher.group(1);
            value = arrayValue.replaceAll("['\"]", "").trim(); //strip quotes
        } else { //capture single value between quotes
            final Matcher matcher = Pattern.compile(
                    String.format("\\s*?%s\\.%s\\s*?=\\s*?(['\"])(.*?)\\1", blockVariable, fieldPattern), Pattern.CASE_INSENSITIVE).matcher(contents);
            if (matcher.find()) {
                value = matcher.group(2);
            }
        }
        if (value.length() > 0) {
            dependency.addEvidence(type, GEMSPEC, field, value, confidence);
        }

        return value;
    }

    /**
     * Adds evidence from the version file.
     *
     * @param dependency the dependency being analyzed
     * @param type the type of evidence to add
     * @param dependencyFile the dependency being analyzed
     * @return the version number added
     */
    private String addEvidenceFromVersionFile(Dependency dependency, EvidenceType type, File dependencyFile) {
        final File parentDir = dependencyFile.getParentFile();
        String version = null;
        int versionCount = 0;
        if (parentDir != null) {
            final File[] matchingFiles = parentDir.listFiles((dir, name) -> name.contains(VERSION_FILE_NAME));
            if (matchingFiles == null) {
                return null;
            }
            for (File f : matchingFiles) {
                try {
                    final List<String> lines = Files.readAllLines(f.toPath(), StandardCharsets.UTF_8);
                    if (lines.size() == 1) { //TODO other checking?
                        final String value = lines.get(0).trim();
                        if (version == null || !version.equals(value)) {
                            version = value;
                            versionCount++;
                        }

                        dependency.addEvidence(type, GEMSPEC, "version", value, Confidence.HIGH);
                    }
                } catch (IOException e) {
                    LOGGER.debug("Error reading gemspec", e);
                }
            }
        }
        if (versionCount == 1) {
            return version;
        }
        return null;
    }

    /**
     * Sets the package path on the dependency.
     *
     * @param dep the dependency to alter
     */
    private void setPackagePath(Dependency dep) {
        final File file = new File(dep.getFilePath());
        final String parent = file.getParent();
        if (parent != null) {
            dep.setPackagePath(parent);
        }
    }
}