NvdApiDataSource.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) 2023 Jeremy Long. All Rights Reserved.
 */
package org.owasp.dependencycheck.data.update;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import io.github.jeremylong.openvulnerability.client.nvd.DefCveItem;
import io.github.jeremylong.openvulnerability.client.nvd.NvdApiException;
import io.github.jeremylong.openvulnerability.client.nvd.NvdCveClient;
import io.github.jeremylong.openvulnerability.client.nvd.NvdCveClientBuilder;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.StringReader;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.time.Duration;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.zip.GZIPOutputStream;
import org.owasp.dependencycheck.Engine;
import org.owasp.dependencycheck.data.nvdcve.CveDB;
import org.owasp.dependencycheck.data.nvdcve.DatabaseException;
import org.owasp.dependencycheck.data.nvdcve.DatabaseProperties;
import org.owasp.dependencycheck.data.update.exception.UpdateException;
import org.owasp.dependencycheck.data.update.nvd.api.DownloadTask;
import org.owasp.dependencycheck.data.update.nvd.api.NvdApiProcessor;
import org.owasp.dependencycheck.utils.DateUtil;
import org.owasp.dependencycheck.utils.DownloadFailedException;
import org.owasp.dependencycheck.utils.Downloader;
import org.owasp.dependencycheck.utils.InvalidSettingException;
import org.owasp.dependencycheck.utils.ResourceNotFoundException;
import org.owasp.dependencycheck.utils.Settings;
import org.owasp.dependencycheck.utils.TooManyRequestsException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 *
 * @author Jeremy Long
 */
public class NvdApiDataSource implements CachedWebDataSource {

    /**
     * The logger.
     */
    private static final Logger LOGGER = LoggerFactory.getLogger(NvdApiDataSource.class);
    /**
     * The thread pool size to use for CPU-intense tasks.
     */
    private static final int PROCESSING_THREAD_POOL_SIZE = Runtime.getRuntime().availableProcessors();
    /**
     * The configured settings.
     */
    private Settings settings;
    /**
     * Reference to the DAO.
     */
    private CveDB cveDb = null;
    /**
     * The properties obtained from the database.
     */
    private DatabaseProperties dbProperties = null;
    /**
     * The key for the NVD API cache properties file's last modified date.
     */
    private static final String NVD_API_CACHE_MODIFIED_DATE = "lastModifiedDate";
    /**
     * The number of results per page from the NVD API. The default is 2000; we
     * are setting the value to be explicit.
     */
    private static final int RESULTS_PER_PAGE = 2000;

    @Override
    public boolean update(Engine engine) throws UpdateException {
        this.settings = engine.getSettings();
        this.cveDb = engine.getDatabase();
        if (isUpdateConfiguredFalse()) {
            return false;
        }
        dbProperties = cveDb.getDatabaseProperties();

        final String nvdDataFeedUrl = settings.getString(Settings.KEYS.NVD_API_DATAFEED_URL);
        if (nvdDataFeedUrl != null) {
            return processDatafeed(nvdDataFeedUrl);
        }
        return processApi();
    }

    protected UrlData extractUrlData(String nvdDataFeedUrl) {
        String url;
        String pattern = null;
        if (nvdDataFeedUrl.endsWith(".json.gz")) {
            final int lio = nvdDataFeedUrl.lastIndexOf("/");
            pattern = nvdDataFeedUrl.substring(lio + 1);
            url = nvdDataFeedUrl.substring(0, lio);
        } else {
            url = nvdDataFeedUrl;
        }
        if (!url.endsWith("/")) {
            url += "/";
        }
        return new UrlData(url, pattern);
    }

    private boolean processDatafeed(String nvdDataFeedUrl) throws UpdateException {
        boolean updatesMade = false;
        try {
            dbProperties = cveDb.getDatabaseProperties();
            if (checkUpdate()) {
                final UrlData data = extractUrlData(nvdDataFeedUrl);
                final String url = data.getUrl();
                String pattern = data.getPattern();
                final Properties cacheProperties = getRemoteCacheProperties(url, pattern);
                if (pattern == null) {
                    final String prefix = cacheProperties.getProperty("prefix", "nvdcve-");
                    pattern = prefix + "{0}.json.gz";
                }

                final ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC"));
                final Map<String, String> updateable = getUpdatesNeeded(url, pattern, cacheProperties, now);
                if (!updateable.isEmpty()) {
                    final int max = settings.getInt(Settings.KEYS.MAX_DOWNLOAD_THREAD_POOL_SIZE, 1);
                    final int downloadPoolSize = Math.min(Runtime.getRuntime().availableProcessors(), max);
                    // going over 2 threads does not appear to improve performance
                    final int maxExec = PROCESSING_THREAD_POOL_SIZE;
                    final int execPoolSize = Math.min(maxExec, 2);

                    ExecutorService processingExecutorService = null;
                    ExecutorService downloadExecutorService = null;
                    try {
                        downloadExecutorService = Executors.newFixedThreadPool(downloadPoolSize);
                        processingExecutorService = Executors.newFixedThreadPool(execPoolSize);

                        DownloadTask runLast = null;
                        final Set<Future<Future<NvdApiProcessor>>> downloadFutures = new HashSet<>(updateable.size());
                        runLast = startDownloads(updateable, processingExecutorService, runLast, downloadFutures, downloadExecutorService);

                        //complete downloads
                        final Set<Future<NvdApiProcessor>> processFutures = new HashSet<>(updateable.size());
                        for (Future<Future<NvdApiProcessor>> future : downloadFutures) {
                            processDownload(future, processFutures);
                        }
                        //process the data
                        processFuture(processFutures);
                        processFutures.clear();

                        //download and process the modified as the last entry
                        if (runLast != null) {
                            final Future<Future<NvdApiProcessor>> modified = downloadExecutorService.submit(runLast);
                            processDownload(modified, processFutures);
                            processFuture(processFutures);
                        }

                    } finally {
                        if (processingExecutorService != null) {
                            processingExecutorService.shutdownNow();
                        }
                        if (downloadExecutorService != null) {
                            downloadExecutorService.shutdownNow();
                        }
                    }
                    updatesMade = true;
                }
                storeLastModifiedDates(now, cacheProperties, updateable);
                if (updatesMade) {
                    cveDb.persistEcosystemCache();
                }
                final int updateCount = cveDb.updateEcosystemCache();
                LOGGER.debug("Corrected the ecosystem for {} ecoSystemCache entries", updateCount);
                if (updatesMade || updateCount > 0) {
                    cveDb.cleanupDatabase();
                }
            }
        } catch (UpdateException ex) {
            if (ex.getCause() != null && ex.getCause() instanceof DownloadFailedException) {
                final String jre = System.getProperty("java.version");
                if (jre == null || jre.startsWith("1.4") || jre.startsWith("1.5") || jre.startsWith("1.6") || jre.startsWith("1.7")) {
                    LOGGER.error("An old JRE is being used ({} {}), and likely does not have the correct root certificates or algorithms "
                            + "to connect to the NVD - consider upgrading your JRE.", System.getProperty("java.vendor"), jre);
                }
            }
            throw ex;
        } catch (DatabaseException ex) {
            throw new UpdateException("Database Exception, unable to update the data to use the most current data.", ex);
        }
        return updatesMade;
    }

    private void storeLastModifiedDates(final ZonedDateTime now, final Properties cacheProperties,
            final Map<String, String> updateable) throws UpdateException {

        final ZonedDateTime lastModifiedRequest = DatabaseProperties.getTimestamp(cacheProperties,
                NVD_API_CACHE_MODIFIED_DATE + ".modified");
        dbProperties.save(DatabaseProperties.NVD_CACHE_LAST_CHECKED, now);
        dbProperties.save(DatabaseProperties.NVD_CACHE_LAST_MODIFIED, lastModifiedRequest);
        //allow users to initially load from a cache but then use the API - this may happen with the GH Action
        dbProperties.save(DatabaseProperties.NVD_API_LAST_CHECKED, now);
        dbProperties.save(DatabaseProperties.NVD_API_LAST_MODIFIED, lastModifiedRequest);

        for (String entry : updateable.keySet()) {
            final ZonedDateTime date = DatabaseProperties.getTimestamp(cacheProperties, NVD_API_CACHE_MODIFIED_DATE + "." + entry);
            dbProperties.save(DatabaseProperties.NVD_CACHE_LAST_MODIFIED + "." + entry, date);
        }
    }

    private DownloadTask startDownloads(final Map<String, String> updateable, ExecutorService processingExecutorService, DownloadTask runLast,
            final Set<Future<Future<NvdApiProcessor>>> downloadFutures, ExecutorService downloadExecutorService) throws UpdateException {
        DownloadTask lastCall = runLast;
        for (Map.Entry<String, String> cve : updateable.entrySet()) {
            final DownloadTask call = new DownloadTask(cve.getValue(), processingExecutorService, cveDb, settings);
            if (call.isModified()) {
                lastCall = call;
            } else {
                final boolean added = downloadFutures.add(downloadExecutorService.submit(call));
                if (!added) {
                    throw new UpdateException("Unable to add the download task for " + cve);
                }
            }
        }
        return lastCall;
    }

    private void processFuture(final Set<Future<NvdApiProcessor>> processFutures) throws UpdateException {
        //complete processing
        for (Future<NvdApiProcessor> future : processFutures) {
            try {
                final NvdApiProcessor task = future.get();
            } catch (InterruptedException ex) {
                LOGGER.debug("Thread was interrupted during processing", ex);
                Thread.currentThread().interrupt();
                throw new UpdateException(ex);
            } catch (ExecutionException ex) {
                LOGGER.debug("Execution Exception during process", ex);
                throw new UpdateException(ex);
            }
        }
    }

    private void processDownload(Future<Future<NvdApiProcessor>> future, final Set<Future<NvdApiProcessor>> processFutures) throws UpdateException {
        final Future<NvdApiProcessor> task;
        try {
            task = future.get();
            if (task != null) {
                processFutures.add(task);
            }
        } catch (InterruptedException ex) {
            LOGGER.debug("Thread was interrupted during download", ex);
            Thread.currentThread().interrupt();
            throw new UpdateException("The download was interrupted", ex);
        } catch (ExecutionException ex) {
            LOGGER.debug("Thread was interrupted during download execution", ex);
            throw new UpdateException("The execution of the download was interrupted", ex);
        }
    }

    private boolean processApi() throws UpdateException {
        final ZonedDateTime lastChecked = dbProperties.getTimestamp(DatabaseProperties.NVD_API_LAST_CHECKED);
        final int validForHours = settings.getInt(Settings.KEYS.NVD_API_VALID_FOR_HOURS, 0);
        if (cveDb.dataExists() && lastChecked != null && validForHours > 0) {
            // ms Valid = valid (hours) x 60 min/hour x 60 sec/min x 1000 ms/sec
            final long validForSeconds = validForHours * 60L * 60L;
            final ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC"));
            final Duration duration = Duration.between(lastChecked, now);
            final long difference = duration.getSeconds();
            if (difference < validForSeconds) {
                LOGGER.info("Skipping the NVD API Update as it was completed within the last {} minutes", validForSeconds / 60);
                return false;
            }
        }

        ZonedDateTime lastModifiedRequest = dbProperties.getTimestamp(DatabaseProperties.NVD_API_LAST_MODIFIED);
        final NvdCveClientBuilder builder = NvdCveClientBuilder.aNvdCveApi();
        final String endpoint = settings.getString(Settings.KEYS.NVD_API_ENDPOINT);
        if (endpoint != null) {
            builder.withEndpoint(endpoint);
        }
        if (lastModifiedRequest != null) {
            final ZonedDateTime end = lastModifiedRequest.minusDays(-120);
            builder.withLastModifiedFilter(lastModifiedRequest, end);
        }
        final String key = settings.getString(Settings.KEYS.NVD_API_KEY);
        if (key != null) {
            //using a higher delay as the system may not be able to process these faster.
            builder.withApiKey(key)
                    .withDelay(5000)
                    .withThreadCount(4);
        } else {
            LOGGER.warn("An NVD API Key was not provided - it is highly recommended to use "
                    + "an NVD API key as the update can take a VERY long time without an API Key");
            builder.withDelay(10000);
        }

        final int resultsPerPage = Math.min(settings.getInt(Settings.KEYS.NVD_API_RESULTS_PER_PAGE, RESULTS_PER_PAGE), RESULTS_PER_PAGE);

        builder.withResultsPerPage(resultsPerPage);
        //removed due to the virtualMatch filter causing overhead with the NVD API
        //final String virtualMatch = settings.getString(Settings.KEYS.CVE_CPE_STARTS_WITH_FILTER);
        //if (virtualMatch != null) {
        //    builder.withVirtualMatchString(virtualMatch);
        //}

        final int retryCount = settings.getInt(Settings.KEYS.NVD_API_MAX_RETRY_COUNT, 10);
        builder.withMaxRetryCount(retryCount);
        long delay = 0;
        try {
            delay = settings.getLong(Settings.KEYS.NVD_API_DELAY);
        } catch (InvalidSettingException ex) {
            LOGGER.warn("Invalid setting `NVD_API_DELAY`? ({}), using default delay", settings.getString(Settings.KEYS.NVD_API_DELAY));
        }
        if (delay > 0) {
            builder.withDelay(delay);
        }

        ExecutorService processingExecutorService = null;
        try {
            processingExecutorService = Executors.newFixedThreadPool(PROCESSING_THREAD_POOL_SIZE);
            final List<Future<NvdApiProcessor>> submitted = new ArrayList<>();
            int max = -1;
            int ctr = 0;
            try (NvdCveClient api = builder.build()) {
                while (api.hasNext()) {
                    final Collection<DefCveItem> items = api.next();
                    max = api.getTotalAvailable();
                    if (ctr == 0) {
                        LOGGER.info(String.format("NVD API has %,d records in this update", max));
                    }
                    if (items != null && !items.isEmpty()) {
                        final ObjectMapper objectMapper = new ObjectMapper();
                        objectMapper.registerModule(new JavaTimeModule());
                        final File outputFile = settings.getTempFile("nvd-data-", ".jsonarray.gz");
                        try (FileOutputStream fos = new FileOutputStream(outputFile); GZIPOutputStream out = new GZIPOutputStream(fos);) {
                            objectMapper.writeValue(out, items);
                            final Future<NvdApiProcessor> f = processingExecutorService.submit(new NvdApiProcessor(cveDb, outputFile));
                            submitted.add(f);
                        }
                        ctr += 1;
                        if ((ctr % 5) == 0) {
                            //TODO get results per page from the API as it could adjust automatically
                            final double percent = (double) (ctr * resultsPerPage) / max * 100;
                            if (percent < 100) {
                                LOGGER.info(String.format("Downloaded %,d/%,d (%.0f%%)", ctr * resultsPerPage, max, percent));
                            }
                        }
                    }
                    final ZonedDateTime last = api.getLastUpdated();
                    if (last != null && (lastModifiedRequest == null || lastModifiedRequest.compareTo(last) < 0)) {
                        lastModifiedRequest = last;
                    }
                }

            } catch (Exception e) {
                if (e instanceof NvdApiException && (e.getMessage().equals("NVD Returned Status Code: 404")
                        || e.getMessage().equals("NVD Returned Status Code: 403"))) {
                    final String msg;
                    if (key != null) {
                        msg = "Error updating the NVD Data; the NVD returned a 403 or 404 error\n\nPlease ensure your API Key is valid; "
                                + "see https://github.com/jeremylong/Open-Vulnerability-Project/tree/main/vulnz#api-key-is-used-and-a-403-or-404-error-occurs\n\n"
                                + "If your NVD API Key is valid try increasing the NVD API Delay.\n\n"
                                + "If this is occurring in a CI environment";
                    } else {
                        msg = "Error updating the NVD Data; the NVD returned a 403 or 404 error\n\nConsider using an NVD API Key; "
                                + "see https://github.com/jeremylong/DependencyCheck?tab=readme-ov-file#nvd-api-key-highly-recommended";
                    }
                    throw new UpdateException(msg);
                } else {
                    throw new UpdateException("Error updating the NVD Data", e);
                }
            }
            LOGGER.info(String.format("Downloaded %,d/%,d (%.0f%%)", max, max, 100f));
            max = submitted.size();
            final boolean updated = max > 0;
            ctr = 0;
            for (Future<NvdApiProcessor> f : submitted) {
                try {
                    final NvdApiProcessor proc = f.get();
                    ctr += 1;
                    final double percent = (double) ctr / max * 100;
                    LOGGER.info(String.format("Completed processing batch %d/%d (%.0f%%) in %,dms", ctr, max, percent, proc.getDurationMillis()));
                } catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                    throw new RuntimeException(ex);
                } catch (ExecutionException ex) {
                    LOGGER.error("Exception processing NVD API Results", ex);
                    throw new RuntimeException(ex);
                }
            }
            if (lastModifiedRequest != null) {
                dbProperties.save(DatabaseProperties.NVD_API_LAST_CHECKED, ZonedDateTime.now());
                dbProperties.save(DatabaseProperties.NVD_API_LAST_MODIFIED, lastModifiedRequest);
            }
            return updated;
        } finally {
            if (processingExecutorService != null) {
                processingExecutorService.shutdownNow();
            }
        }
    }

    /**
     * Checks if the system is configured NOT to update.
     *
     * @return false if the system is configured to perform an update; otherwise
     * true
     */
    private boolean isUpdateConfiguredFalse() {
        if (!settings.getBoolean(Settings.KEYS.UPDATE_NVDCVE_ENABLED, true)) {
            return true;
        }
        boolean autoUpdate = true;
        try {
            autoUpdate = settings.getBoolean(Settings.KEYS.AUTO_UPDATE);
        } catch (InvalidSettingException ex) {
            LOGGER.debug("Invalid setting for auto-update; using true.");
        }
        return !autoUpdate;
    }

    @Override
    public boolean purge(Engine engine) {
        boolean result = true;
        try {
            final File dataDir = engine.getSettings().getDataDirectory();
            final File db = new File(dataDir, engine.getSettings().getString(Settings.KEYS.DB_FILE_NAME, "odc.mv.db"));
            if (db.exists()) {
                if (db.delete()) {
                    LOGGER.info("Database file purged; local copy of the NVD has been removed");
                } else {
                    LOGGER.error("Unable to delete '{}'; please delete the file manually", db.getAbsolutePath());
                    result = false;
                }
            } else {
                LOGGER.info("Unable to purge database; the database file does not exist: {}", db.getAbsolutePath());
                result = false;
            }
            final File traceFile = new File(dataDir, "odc.trace.db");
            if (traceFile.exists() && !traceFile.delete()) {
                LOGGER.error("Unable to delete '{}'; please delete the file manually", traceFile.getAbsolutePath());
                result = false;
            }
            final File lockFile = new File(dataDir, "odc.update.lock");
            if (lockFile.exists() && !lockFile.delete()) {
                LOGGER.error("Unable to delete '{}'; please delete the file manually", lockFile.getAbsolutePath());
                result = false;
            }
        } catch (IOException ex) {
            final String msg = "Unable to delete the database";
            LOGGER.error(msg, ex);
            result = false;
        }
        return result;
    }

    /**
     * Checks if the NVD API Cache JSON files were last checked recently. As an
     * optimization, we can avoid repetitive checks against the NVD cache.
     *
     * @return true to proceed with the check, or false to skip
     * @throws UpdateException thrown when there is an issue checking for
     * updates
     */
    private boolean checkUpdate() throws UpdateException {
        boolean proceed = true;
        // If the valid setting has not been specified, then we proceed to check...
        final int validForHours = settings.getInt(Settings.KEYS.NVD_API_VALID_FOR_HOURS, 0);
        if (dataExists() && 0 < validForHours) {
            // ms Valid = valid (hours) x 60 min/hour x 60 sec/min x 1000 ms/sec
            final long validForSeconds = validForHours * 60L * 60L;
            final ZonedDateTime lastChecked = dbProperties.getTimestamp(DatabaseProperties.NVD_CACHE_LAST_CHECKED);
            if (lastChecked != null) {
                final ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC"));
                final Duration duration = Duration.between(lastChecked, now);
                final long difference = duration.getSeconds();
                proceed = difference > validForSeconds;
                if (!proceed) {
                    LOGGER.info("Skipping NVD API Cache check since last check was within {} hours.", validForHours);
                    LOGGER.debug("Last NVD API was at {}, and now {} is within {} s.", lastChecked, now, validForSeconds);
                }
            } else {
                LOGGER.warn("NVD cache last checked not present; updating the entire database. This could occur if you are "
                        + "switching back and forth from using the API vs a datafeed or if you are using a database created prior to ODC 9.x");
            }
        }
        return proceed;
    }

    /**
     * Checks the CVE Index to ensure data exists and analysis can continue.
     *
     * @return true if the database contains data
     */
    private boolean dataExists() {
        return cveDb.dataExists();
    }

    /**
     * Determines if the index needs to be updated. This is done by fetching the
     * NVD CVE meta data and checking the last update date. If the data needs to
     * be refreshed this method will return the NvdCveUrl for the files that
     * need to be updated.
     *
     * @param url the URL of the NVD API cache
     * @param filePattern the string format pattern for the cached files (e.g.
     * "nvdcve-{0}.json.gz")
     * @param cacheProperties the properties from the remote NVD API cache
     * @param now the start time of the update process
     * @return the map of key to URLs - where the key is the year or `modified`
     * @throws UpdateException Is thrown if there is an issue with the last
     * updated properties file
     */
    protected final Map<String, String> getUpdatesNeeded(String url, String filePattern,
            Properties cacheProperties, ZonedDateTime now) throws UpdateException {
        LOGGER.debug("starting getUpdatesNeeded() ...");
        final Map<String, String> updates = new HashMap<>();
        if (dbProperties != null && !dbProperties.isEmpty()) {
            final int startYear = settings.getInt(Settings.KEYS.NVD_API_DATAFEED_START_YEAR, 2002);
            // for establishing the current year use the timezone where the new year starts first
            // as from that moment on CNAs might start assigning CVEs with the new year depending
            // on the CNA's timezone
            final int endYear = now.withZoneSameInstant(ZoneId.of("UTC+14:00")).getYear();
            boolean needsFullUpdate = false;
            for (int y = startYear; y <= endYear; y++) {
                final ZonedDateTime val = dbProperties.getTimestamp(DatabaseProperties.NVD_CACHE_LAST_MODIFIED + "." + y);
                if (val == null) {
                    needsFullUpdate = true;
                    break;
                }
            }
            final ZonedDateTime lastUpdated = dbProperties.getTimestamp(DatabaseProperties.NVD_CACHE_LAST_MODIFIED);
            final int days = settings.getInt(Settings.KEYS.NVD_API_DATAFEED_VALID_FOR_DAYS, 7);

            if (!needsFullUpdate && lastUpdated.equals(DatabaseProperties.getTimestamp(cacheProperties, NVD_API_CACHE_MODIFIED_DATE))) {
                return updates;
            } else {
                updates.put("modified", url + MessageFormat.format(filePattern, "modified"));
                if (needsFullUpdate) {
                    for (int i = startYear; i <= endYear; i++) {
                        if (cacheProperties.containsKey(NVD_API_CACHE_MODIFIED_DATE + "." + i)) {
                            updates.put(String.valueOf(i), url + MessageFormat.format(filePattern, String.valueOf(i)));
                        }
                    }
                } else if (!DateUtil.withinDateRange(lastUpdated, now, days)) {
                    for (int i = startYear; i <= endYear; i++) {
                        if (cacheProperties.containsKey(NVD_API_CACHE_MODIFIED_DATE + "." + i)) {
                            final ZonedDateTime lastModifiedCache = DatabaseProperties.getTimestamp(cacheProperties,
                                    NVD_API_CACHE_MODIFIED_DATE + "." + i);
                            final ZonedDateTime lastModifiedDB = dbProperties.getTimestamp(DatabaseProperties.NVD_CACHE_LAST_MODIFIED + "." + i);
                            if (lastModifiedDB == null || lastModifiedCache.compareTo(lastModifiedDB) > 0) {
                                updates.put(String.valueOf(i), url + MessageFormat.format(filePattern, String.valueOf(i)));
                            }
                        }
                    }
                }
            }
        }
        if (updates.size() > 3) {
            LOGGER.info("NVD API Cache requires several updates; this could take a couple of minutes.");
        }
        return updates;
    }

    /**
     * Downloads the metadata properties of the NVD API cache.
     *
     * @param url the base URL to the NVD API cache
     * @param pattern the pattern of the datafile name for the NVD API cache
     * @return the cache properties
     * @throws UpdateException thrown if the properties file could not be
     * downloaded
     */
    protected final Properties getRemoteCacheProperties(String url, String pattern) throws UpdateException {
        final Properties properties = new Properties();
        try {
            final URL u = new URI(url + "cache.properties").toURL();
            final String content = Downloader.getInstance().fetchContent(u, StandardCharsets.UTF_8);
            properties.load(new StringReader(content));

        } catch (URISyntaxException ex) {
            throw new UpdateException("Invalid NVD Cache URL", ex);
        } catch (DownloadFailedException | ResourceNotFoundException ex) {
            final String metaPattern;
            if (pattern == null) {
                metaPattern = "nvdcve-{0}.meta";
            } else {
                metaPattern = pattern.replace(".json.gz", ".meta");
            }
            try {
                URL metaUrl = new URI(url + MessageFormat.format(metaPattern, "modified")).toURL();
                String content = Downloader.getInstance().fetchContent(metaUrl, StandardCharsets.UTF_8);
                final Properties props = new Properties();
                props.load(new StringReader(content));
                ZonedDateTime lmd = DatabaseProperties.getIsoTimestamp(props, "lastModifiedDate");
                DatabaseProperties.setTimestamp(properties, "lastModifiedDate.modified", lmd);
                DatabaseProperties.setTimestamp(properties, "lastModifiedDate", lmd);
                final int startYear = settings.getInt(Settings.KEYS.NVD_API_DATAFEED_START_YEAR, 2002);
                final ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC"));
                final int endYear = now.withZoneSameInstant(ZoneId.of("UTC+14:00")).getYear();
                for (int y = startYear; y <= endYear; y++) {
                    metaUrl = new URI(url + MessageFormat.format(metaPattern, String.valueOf(y))).toURL();
                    content = Downloader.getInstance().fetchContent(metaUrl, StandardCharsets.UTF_8);
                    props.clear();
                    props.load(new StringReader(content));
                    lmd = DatabaseProperties.getIsoTimestamp(props, "lastModifiedDate");
                    DatabaseProperties.setTimestamp(properties, "lastModifiedDate." + String.valueOf(y), lmd);
                }
            } catch (URISyntaxException | TooManyRequestsException | ResourceNotFoundException | IOException ex1) {
                throw new UpdateException("Unable to download the data feed META files", ex);
            }
        } catch (TooManyRequestsException ex) {
            throw new UpdateException("Unable to download the NVD API cache.properties", ex);
        } catch (IOException ex) {
            throw new UpdateException("Invalid NVD Cache Properties file contents", ex);
        }
        return properties;
    }

    protected static class UrlData {

        /**
         * The URL to download resources from.
         */
        private final String url;

        /**
         * The pattern to construct the file names for resources from.
         */
        private final String pattern;

        public UrlData(String url, String pattern) {
            this.url = url;
            this.pattern = pattern;
        }

        /**
         * Get the value of pattern
         *
         * @return the value of pattern
         */
        public String getPattern() {
            return pattern;
        }

        /**
         * Get the value of url
         *
         * @return the value of url
         */
        public String getUrl() {
            return url;
        }

    }
}