PinnedMavenInstallAnalyzer.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 The OWASP Foundation. All Rights Reserved.
 */
package org.owasp.dependencycheck.analyzer;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;
import com.github.packageurl.PackageURLBuilder;
import java.util.Map;
import java.util.stream.Collectors;
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.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.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;

/**
 * Used to analyze Maven pinned dependency files named {@code *install*.json}, a
 * Java Maven dependency lockfile like Python's {@code requirements.txt}.
 *
 * @author dhalperi
 * @see
 * <a href="https://github.com/bazelbuild/rules_jvm_external#pinning-artifacts-and-integration-with-bazels-downloader">rules_jvm_external</a>
 */
@Experimental
@ThreadSafe
public class PinnedMavenInstallAnalyzer extends AbstractFileTypeAnalyzer {

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

    /**
     * The name of the analyzer.
     */
    private static final String ANALYZER_NAME = "Pinned Maven install Analyzer";

    /**
     * The phase that this analyzer is intended to run in.
     */
    private static final AnalysisPhase ANALYSIS_PHASE = AnalysisPhase.INFORMATION_COLLECTION;

    /**
     * Pattern matching files with "install" in the basename and extension
     * "json".
     *
     * <p>
     * This regex is designed to explicitly skip files named
     * {@code install.json} since those are used for Cloudflare installations
     * and this will save on work.
     */
    private static final Pattern MAVEN_INSTALL_JSON_PATTERN = Pattern.compile("(.+install.*|.*install.+)\\.json");

    /**
     * Match any files that look like *install*.json.
     */
    private static final FileFilter FILTER = (File file) -> MAVEN_INSTALL_JSON_PATTERN.matcher(file.getName()).matches();

    @Override
    protected FileFilter getFileFilter() {
        return FILTER;
    }

    @Override
    public String getName() {
        return ANALYZER_NAME;
    }

    @Override
    public AnalysisPhase getAnalysisPhase() {
        return ANALYSIS_PHASE;
    }

    @Override
    protected String getAnalyzerEnabledSettingKey() {
        return Settings.KEYS.ANALYZER_MAVEN_INSTALL_ENABLED;
    }

    @Override
    protected void analyzeDependency(Dependency dependency, Engine engine) throws AnalysisException {
        LOGGER.debug("Checking file {}", dependency.getActualFilePath());

        final File dependencyFile = dependency.getActualFile();
        if (!dependencyFile.isFile() || dependencyFile.length() == 0) {
            return;
        }

        final DependencyTree tree;
        List<MavenDependency> deps;
        try {
            final JsonNode jsonNode = MAPPER.readTree(dependencyFile);
            final JsonNode v2Version = jsonNode.path("version");
            final JsonNode v010Version = jsonNode.path("dependency_tree").path("version");

            if (v2Version.isTextual()) {
                final InstallFileV2 installFile = INSTALL_FILE_V2_READER.readValue(dependencyFile);
                if (!Objects.equals(installFile.getAutogeneratedSentinel(), "THERE_IS_NO_DATA_ONLY_ZUUL")) {
                    return;
                }
                if (!Objects.equals(installFile.getVersion(), "2")) {
                    LOGGER.warn("Unsupported pinned maven_install.json version {}. Continuing optimistically.", installFile.getVersion());
                }
                deps = installFile.getArtifacts().entrySet().stream().map(entry -> new MavenDependency(
                        entry.getKey() + ":" + entry.getValue().getVersion()
                )).collect(Collectors.toList());
            } else if (v010Version.isTextual()) {
                final InstallFile installFile = INSTALL_FILE_READER.readValue(dependencyFile);
                tree = installFile.getDependencyTree();
                if (tree == null) {
                    return;
                } else if (!Objects.equals(tree.getAutogeneratedSentinel(), "THERE_IS_NO_DATA_ONLY_ZUUL")) {
                    return;
                }
                if (!Objects.equals(tree.getVersion(), "0.1.0")) {
                    LOGGER.warn("Unsupported pinned maven_install.json version {}. Continuing optimistically.", tree.getVersion());
                }
                deps = tree.getDependencies();
            } else {
                LOGGER.warn("No pinned maven_install.json version found. Cannot Parse");
                return;
            }

        } catch (IOException e) {
            System.out.println("e");
            return;
        }

        engine.removeDependency(dependency);

        if (deps == null) {
            deps = Collections.emptyList();
        }

        for (MavenDependency dep : deps) {
            if (dep.getCoord() == null) {
                LOGGER.warn("Unexpected null coordinate in {}", dependency.getActualFilePath());
                continue;
            }

            LOGGER.debug("Analyzing {}", dep.getCoord());
            final String[] pieces = dep.getCoord().split(":");
            if (pieces.length < 3 || pieces.length > 5) {
                LOGGER.warn("Invalid maven coordinate {}", dep.getCoord());
                continue;
            }

            final String group = pieces[0];
            final String artifact = pieces[1];
            final String version;
            String classifier = null;
            switch (pieces.length) {
                case 3:
                    version = pieces[2];
                    break;
                case 4:
                    classifier = pieces[2];
                    version = pieces[3];
                    break;
                default:
                    // length == 5 as guaranteed above.
                    classifier = pieces[3];
                    version = pieces[4];
                    break;
            }

            if ("sources".equals(classifier) || "javadoc".equals(classifier)) {
                LOGGER.debug("Skipping sources jar {}", dep.getCoord());
                continue;
            }

            final Dependency d = new Dependency(dependency.getActualFile(), true);
            d.setEcosystem(Ecosystem.JAVA);
            d.addEvidence(EvidenceType.VENDOR, "project", "groupid", group, Confidence.HIGHEST);
            d.addEvidence(EvidenceType.PRODUCT, "project", "artifactid", artifact, Confidence.HIGHEST);
            d.addEvidence(EvidenceType.VERSION, "project", "version", version, Confidence.HIGHEST);
            d.setName(String.format("%s:%s", group, artifact));
            d.setFilePath(String.format("%s>>%s", dependency.getActualFile(), dep.getCoord()));
            d.setFileName(dep.getCoord());
            try {
                final PackageURLBuilder purl = PackageURLBuilder.aPackageURL()
                        .withType(PackageURL.StandardTypes.MAVEN)
                        .withNamespace(group)
                        .withName(artifact)
                        .withVersion(version);
                if (classifier != null) {
                    purl.withQualifier("classifier", classifier);
                }
                d.addSoftwareIdentifier(new PurlIdentifier(purl.build(), Confidence.HIGHEST));
            } catch (MalformedPackageURLException e) {
                d.addSoftwareIdentifier(new GenericIdentifier("maven_install JSON coord " + dep.getCoord(), Confidence.HIGH));
            }
            d.setVersion(version);
            engine.addDependency(d);
        }
    }

    @Override
    protected void prepareFileTypeAnalyzer(Engine engine) {
        // No initialization needed.
    }

    /**
     * Represents the entire pinned Maven dependency set in an install.json
     * file.
     *
     * <p>
     * At the time of writing, the latest version is 0.1.0, and the dependencies
     * are stored in {@code .dependency_tree.dependencies[].coord}.
     *
     * <p>
     * The only top-level key we care about is {@code .dependency_tree}.
     */
    private static class InstallFile {

        /**
         * The dependency tree.
         */
        @JsonProperty("dependency_tree")
        private DependencyTree dependencyTree;

        /**
         * Returns dependencyTree.
         *
         * @return dependencyTree
         */
        public DependencyTree getDependencyTree() {
            return dependencyTree;
        }
    }

    /**
     * Represents the values at {@code .dependency_tree} in the
     * {@link InstallFile install file}.
     */
    private static class DependencyTree {

        /**
         * A sentinel value placed in the file to indicate that it is an
         * auto-generated pinned maven install file.
         */
        @JsonProperty("__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY")
        private String autogeneratedSentinel;

        /**
         * A list of Maven dependencies made available. Note that this list is
         * transitively closed and pinned to a specific version of each
         * artifact.
         */
        @JsonProperty("dependencies")
        private List<MavenDependency> dependencies;

        /**
         * The file format version.
         */
        @JsonProperty("version")
        private String version;

        /**
         * Returns autogeneratedSentinel.
         *
         * @return autogeneratedSentinel
         */
        public String getAutogeneratedSentinel() {
            return autogeneratedSentinel;
        }

        /**
         * Returns dependencies.
         *
         * @return dependencies
         */
        public List<MavenDependency> getDependencies() {
            return dependencies;
        }

        /**
         * Returns version.
         *
         * @return version
         */
        public String getVersion() {
            return version;
        }

    }

    /**
     * Represents a single dependency in the list at
     * {@code .dependency_tree.dependencies}.
     */
    private static class MavenDependency {

        MavenDependency(String coord) {
            this.coord = coord;
        }

        MavenDependency() {
        }
        /**
         * The standard Maven coordinate string
         * {@code group:artifact[:optional classifier][:optional packaging]:version}.
         */
        @JsonProperty("coord")
        private String coord;

        /**
         * Returns the value of coord.
         *
         * @return the value of coord
         */
        public String getCoord() {
            return coord;
        }
    }

    /**
     * A reusable reader for {@link InstallFile}.
     */
    private static final ObjectReader INSTALL_FILE_READER;
    /**
     * A reusable reader for {@link InstallFileV2}.
     */
    private static final ObjectReader INSTALL_FILE_V2_READER;
    /**
     * A reusable object mapper.
     */
    private static final ObjectMapper MAPPER;

    static {
        MAPPER = new ObjectMapper();
        MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        INSTALL_FILE_READER = MAPPER.readerFor(InstallFile.class);
        INSTALL_FILE_V2_READER = MAPPER.readerFor(InstallFileV2.class);
    }

    /**
     * Represents the entire pinned Maven dependency set in an install.json
     * file.
     *
     * <p>
     * At the time of writing, the latest version is 2, and the dependencies are
     * stored in {@code .artifacts}.
     *
     * <p>
     * The top-level keys we care about are {@code .artifacts}.
     * {@code .version}.
     */
    private static class InstallFileV2 {

        /**
         * The file format version.
         */
        @JsonProperty("version")
        private String version;

        /**
         * A list of Maven dependencies made available. Note that this map is
         * transitively closed and pinned to a specific version of each
         * artifact.
         * <p>
         * The key is the Maven coordinate string, less the version
         * {@code group:artifact[:optional classifier][:optional packaging]}.
         * <p>
         * The value contains the version of the artifact.
         */
        @JsonProperty("artifacts")
        private Map<String, Artifactv2> artifacts;

        /**
         * A sentinel value placed in the file to indicate that it is an
         * auto-generated pinned maven install file.
         */
        @JsonProperty("__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY")
        private String autogeneratedSentinel;

        /**
         * Returns artifacts.
         *
         * @return artifacts
         */
        public Map<String, Artifactv2> getArtifacts() {
            return artifacts;
        }

        /**
         * Returns version.
         *
         * @return version
         */
        public String getVersion() {
            return version;
        }

        /**
         * Returns autogeneratedSentinel.
         *
         * @return autogeneratedSentinel
         */
        public String getAutogeneratedSentinel() {
            return autogeneratedSentinel;
        }
    }

    private static class Artifactv2 {

        /**
         * The version of the artifact.
         */
        @JsonProperty("version")
        private String version;

        /**
         * Returns the value of version.
         *
         * @return the value of version
         */
        public String getVersion() {
            return version;
        }
    }

}