Skip to content

Commit 4e9d846

Browse files
committed
fix: Don't consider a new year's feed files or metadata mandatory until it is January 2nd everywhere
This avoids failures on January 1st given feed upload times are not guaranteed to be at any particular time of day in any particular timezone. We assume the metadaa and feed should exist once it is January 2nd in the "earliest" TZ on earth. Signed-off-by: Chad Wilson <[email protected]>
1 parent 98066f7 commit 4e9d846

File tree

2 files changed

+221
-42
lines changed

2 files changed

+221
-42
lines changed

core/src/main/java/org/owasp/dependencycheck/data/update/NvdApiDataSource.java

Lines changed: 84 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,15 @@
3333
import java.net.URL;
3434
import java.text.MessageFormat;
3535
import java.time.Duration;
36+
import java.time.LocalDate;
3637
import java.time.ZoneId;
38+
import java.time.ZoneOffset;
3739
import java.time.ZonedDateTime;
3840
import java.util.ArrayList;
3941
import java.util.Collection;
4042
import java.util.HashMap;
4143
import java.util.HashSet;
44+
import java.util.LinkedHashMap;
4245
import java.util.List;
4346
import java.util.Map;
4447
import java.util.Optional;
@@ -52,6 +55,8 @@
5255
import java.util.zip.GZIPOutputStream;
5356

5457
import org.jetbrains.annotations.NotNull;
58+
import org.jspecify.annotations.NonNull;
59+
import org.jspecify.annotations.Nullable;
5560
import org.owasp.dependencycheck.Engine;
5661
import org.owasp.dependencycheck.data.nvdcve.CveDB;
5762
import org.owasp.dependencycheck.data.nvdcve.DatabaseException;
@@ -63,6 +68,7 @@
6368
import org.owasp.dependencycheck.utils.DownloadFailedException;
6469
import org.owasp.dependencycheck.utils.Downloader;
6570
import org.owasp.dependencycheck.utils.InvalidSettingException;
71+
import org.owasp.dependencycheck.utils.Pair;
6672
import org.owasp.dependencycheck.utils.ResourceNotFoundException;
6773
import org.owasp.dependencycheck.utils.Settings;
6874
import org.owasp.dependencycheck.utils.TooManyRequestsException;
@@ -132,7 +138,7 @@ private boolean processDatafeed(String nvdDataFeedUrl) throws UpdateException {
132138
final Properties cacheProperties = getRemoteDataFeedCacheProperties(urlData);
133139
urlData = urlData.withPattern(p -> p.orElse(cacheProperties.getProperty("prefix", FeedUrl.DEFAULT_FILE_PATTERN_PREFIX) + FeedUrl.DEFAULT_FILE_PATTERN_SUFFIX));
134140

135-
final ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC"));
141+
final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
136142
final Map<String, String> updateable = getUpdatesNeeded(urlData, cacheProperties, now);
137143
if (!updateable.isEmpty()) {
138144
final int max = settings.getInt(Settings.KEYS.MAX_DOWNLOAD_THREAD_POOL_SIZE, 1);
@@ -525,15 +531,13 @@ protected final Map<String, String> getUpdatesNeeded(FeedUrl feedUrl, Properties
525531
LOGGER.debug("starting getUpdatesNeeded() ...");
526532
final Map<String, String> updates = new HashMap<>();
527533
if (dbProperties != null && !dbProperties.isEmpty()) {
528-
final int startYear = settings.getInt(Settings.KEYS.NVD_API_DATAFEED_START_YEAR, 2002);
529-
// for establishing the current year use the timezone where the new year starts first
530-
// as from that moment on CNAs might start assigning CVEs with the new year depending
531-
// on the CNA's timezone
532-
final int endYear = now.withZoneSameInstant(ZoneId.of("UTC+14:00")).getYear();
534+
Pair<Integer, Integer> yearRange = FeedUrl.toYearRange(settings, now);
535+
int startYear = yearRange.getLeft();
536+
int endYear = yearRange.getRight();
533537
boolean needsFullUpdate = false;
534538
for (int y = startYear; y <= endYear; y++) {
535539
final ZonedDateTime val = dbProperties.getTimestamp(DatabaseProperties.NVD_CACHE_LAST_MODIFIED + "." + y);
536-
if (val == null) {
540+
if (val == null && FeedUrl.isMandatoryFeedYear(now, y)) {
537541
needsFullUpdate = true;
538542
break;
539543
}
@@ -611,27 +615,13 @@ private Properties generateRemoteDataFeedCachePropertiesFromMetadata(FeedUrl dat
611615
);
612616

613617
final Properties properties = new Properties();
614-
try {
615-
String content = Downloader.getInstance().fetchContent(metaFeedUrl.toFormattedUrl("modified"), UTF_8);
616-
final Properties props = new Properties();
617-
props.load(new StringReader(content));
618-
ZonedDateTime lmd = DatabaseProperties.getIsoTimestamp(props, "lastModifiedDate");
619-
DatabaseProperties.setTimestamp(properties, "lastModifiedDate.modified", lmd);
620-
DatabaseProperties.setTimestamp(properties, "lastModifiedDate", lmd);
621-
final int startYear = settings.getInt(Settings.KEYS.NVD_API_DATAFEED_START_YEAR, 2002);
622-
final ZonedDateTime now = ZonedDateTime.now(ZoneId.of("UTC"));
623-
final int endYear = now.withZoneSameInstant(ZoneId.of("UTC+14:00")).getYear();
624-
for (int y = startYear; y <= endYear; y++) {
625-
content = Downloader.getInstance().fetchContent(metaFeedUrl.toFormattedUrl(y), UTF_8);
626-
props.clear();
627-
props.load(new StringReader(content));
628-
lmd = DatabaseProperties.getIsoTimestamp(props, "lastModifiedDate");
629-
DatabaseProperties.setTimestamp(properties, "lastModifiedDate." + y, lmd);
630-
}
631-
return properties;
632-
} catch (URISyntaxException | TooManyRequestsException | ResourceNotFoundException | IOException ex) {
633-
throw new UpdateException("Unable to download the data feed META files", ex);
634-
}
618+
ZonedDateTime lmd = metaFeedUrl.getLastModifiedFor("modified");
619+
DatabaseProperties.setTimestamp(properties, "lastModifiedDate.modified", lmd);
620+
DatabaseProperties.setTimestamp(properties, "lastModifiedDate", lmd);
621+
622+
metaFeedUrl.getLastModifiedDatePropertiesByYear(this.settings, ZonedDateTime.now(ZoneOffset.UTC))
623+
.forEach((k, v) -> DatabaseProperties.setTimestamp(properties, k, v));
624+
return properties;
635625
}
636626

637627
protected static class FeedUrl {
@@ -649,6 +639,15 @@ protected static class FeedUrl {
649639
*/
650640
static final String DEFAULT_FILE_PATTERN = DEFAULT_FILE_PATTERN_PREFIX + DEFAULT_FILE_PATTERN_SUFFIX;
651641

642+
/**
643+
* The timezone where the new year starts first.
644+
*/
645+
static final ZoneId ZONE_GLOBAL_EARLIEST = ZoneId.of("UTC+14:00");
646+
/**
647+
* The timezone where the new year starts last.
648+
*/
649+
static final ZoneId ZONE_GLOBAL_LATEST = ZoneId.of("UTC-12:00");
650+
652651
/**
653652
* The base URL to download resources from.
654653
*/
@@ -680,10 +679,6 @@ public FeedUrl withPattern(Function<Optional<String>, String> patternTransformer
680679
return new URI(toFormattedUrlString(formatArg)).toURL();
681680
}
682681

683-
@NotNull URL toFormattedUrl(int formatArg) throws MalformedURLException, URISyntaxException {
684-
return toFormattedUrl(String.valueOf(formatArg));
685-
}
686-
687682
@SuppressWarnings("SameParameterValue")
688683
@NotNull URL toSuffixedUrl(String suffix) throws MalformedURLException, URISyntaxException {
689684
return new URI(url + suffix).toURL();
@@ -692,7 +687,7 @@ public FeedUrl withPattern(Function<Optional<String>, String> patternTransformer
692687
/**
693688
* @param url A NVD data feed URL which may be just a base URL such as https://my-nvd-cache/nvd_cache or
694689
* may include a formatted URL ending with .json.gz such as https://nvd.nist.gov/feeds/json/cve/2.0/nvdcve-2.0-{0}.json.gz
695-
* @return A constructed URLData object
690+
* @return A constructed FeedUrl object
696691
*/
697692
@SuppressWarnings("JavadocLinkAsPlainText")
698693
protected static FeedUrl extractFromUrlOptionalPattern(String url) {
@@ -710,5 +705,61 @@ protected static FeedUrl extractFromUrlOptionalPattern(String url) {
710705
}
711706
return new FeedUrl(baseUrl, pattern);
712707
}
708+
709+
private static @NonNull Pair<Integer, Integer> toYearRange(Settings settings, ZonedDateTime now) {
710+
// for establishing the current year use the timezone where the new year starts first
711+
// as from that moment on CNAs might start assigning CVEs with the new year depending
712+
// on the CNA's timezone
713+
final int startYear = settings.getInt(Settings.KEYS.NVD_API_DATAFEED_START_YEAR, 2002);
714+
final int endYear = now.withZoneSameInstant(ZONE_GLOBAL_EARLIEST).getYear();
715+
return new Pair<>(startYear, endYear);
716+
}
717+
718+
private @Nullable ZonedDateTime getLastModifiedFor(String fileVersion) throws UpdateException {
719+
try {
720+
String content = Downloader.getInstance().fetchContent(toFormattedUrl(fileVersion), UTF_8);
721+
Properties props = new Properties();
722+
props.load(new StringReader(content));
723+
return DatabaseProperties.getIsoTimestamp(props, "lastModifiedDate");
724+
} catch (URISyntaxException | TooManyRequestsException | ResourceNotFoundException | IOException ex) {
725+
throw new UpdateException("Unable to download the data feed .meta file for " + fileVersion, ex);
726+
}
727+
}
728+
729+
Map<String, ZonedDateTime> getLastModifiedDatePropertiesByYear(Settings settings, ZonedDateTime now) throws UpdateException {
730+
Pair<Integer, Integer> yearRange = toYearRange(settings, now);
731+
int startYear = yearRange.getLeft();
732+
int endYear = yearRange.getRight();
733+
734+
Map<String, ZonedDateTime> lastModifiedDateProperties = new LinkedHashMap<>();
735+
for (int y = startYear; y <= endYear; y++) {
736+
try {
737+
lastModifiedDateProperties.put("lastModifiedDate." + y, getLastModifiedFor(String.valueOf(y)));
738+
} catch (UpdateException e) {
739+
if (isMandatoryFeedYear(now, y)) {
740+
throw e;
741+
}
742+
LOGGER.debug("Ignoring data feed metadata retrieval failure for {}, it is still January 1st in some TZ; so feed files may not yet be generated. Error was {}", y, e.toString());
743+
}
744+
}
745+
return lastModifiedDateProperties;
746+
}
747+
748+
/**
749+
* @param now The current time in any timezone
750+
* @param targetYear Target year's feed data to retrieve
751+
* @return Whether or not the targetYear is considered a mandatory feed file to retrieve given the target year and current time.
752+
*/
753+
static boolean isMandatoryFeedYear(ZonedDateTime now, int targetYear) {
754+
return isNotTargetYearInAnyTZ(now, targetYear) || isAfterJanuary1InEveryTZ(now, targetYear);
755+
}
756+
757+
private static boolean isNotTargetYearInAnyTZ(ZonedDateTime now, int targetYear) {
758+
return targetYear != now.withZoneSameInstant(ZONE_GLOBAL_EARLIEST).getYear();
759+
}
760+
761+
private static boolean isAfterJanuary1InEveryTZ(ZonedDateTime now, int targetYear) {
762+
return now.isAfter(LocalDate.of(targetYear, 1, 2).atStartOfDay().atZone(ZONE_GLOBAL_LATEST));
763+
}
713764
}
714765
}

core/src/test/java/org/owasp/dependencycheck/data/update/NvdApiDataSourceTest.java

Lines changed: 137 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,42 @@
1717
*/
1818
package org.owasp.dependencycheck.data.update;
1919

20+
import org.hamcrest.Matchers;
21+
import org.jspecify.annotations.NonNull;
2022
import org.junit.jupiter.api.Nested;
2123
import org.junit.jupiter.api.Test;
24+
import org.mockito.MockedStatic;
25+
import org.owasp.dependencycheck.data.update.exception.UpdateException;
26+
import org.owasp.dependencycheck.utils.DownloadFailedException;
27+
import org.owasp.dependencycheck.utils.Downloader;
28+
import org.owasp.dependencycheck.utils.Settings;
2229

2330
import java.net.URI;
31+
import java.time.ZoneOffset;
32+
import java.time.ZonedDateTime;
33+
import java.util.List;
34+
import java.util.Map;
2435
import java.util.NoSuchElementException;
2536

37+
import static org.hamcrest.MatcherAssert.assertThat;
38+
import static org.hamcrest.Matchers.contains;
39+
import static org.hamcrest.Matchers.everyItem;
2640
import static org.junit.jupiter.api.Assertions.assertEquals;
41+
import static org.junit.jupiter.api.Assertions.assertFalse;
2742
import static org.junit.jupiter.api.Assertions.assertThrows;
43+
import static org.junit.jupiter.api.Assertions.assertTrue;
44+
import static org.mockito.ArgumentMatchers.any;
45+
import static org.mockito.Mockito.mock;
46+
import static org.mockito.Mockito.mockStatic;
47+
import static org.mockito.Mockito.when;
2848
import static org.owasp.dependencycheck.data.update.NvdApiDataSource.FeedUrl.DEFAULT_FILE_PATTERN;
2949
import static org.owasp.dependencycheck.data.update.NvdApiDataSource.FeedUrl.extractFromUrlOptionalPattern;
50+
import static org.owasp.dependencycheck.data.update.NvdApiDataSource.FeedUrl.isMandatoryFeedYear;
3051

31-
/**
32-
*
33-
* @author Jeremy Long
34-
*/
3552
class NvdApiDataSourceTest {
3653

3754
@Nested
38-
class FeedUrl {
55+
class FeedUrlParsing {
3956

4057
@Test
4158
void shouldExtractUrlWithPattern() throws Exception {
@@ -47,12 +64,12 @@ void shouldExtractUrlWithPattern() throws Exception {
4764
assertEquals(URI.create(expectedUrl).toURL(), result.toFormattedUrl("2045"));
4865
assertEquals(URI.create("https://internal.server/nist/some-file.txt").toURL(), result.toSuffixedUrl("some-file.txt"));
4966

50-
assertEquals(expectedUrl, result.toFormattedUrlString(2045));
51-
assertEquals(URI.create(expectedUrl).toURL(), result.toFormattedUrl(2045));
67+
assertEquals(expectedUrl, result.toFormattedUrlString("2045"));
68+
assertEquals(URI.create(expectedUrl).toURL(), result.toFormattedUrl("2045"));
5269
}
5370

5471
@Test
55-
void shouldAllowTransformingFilePattern() throws Exception {
72+
void shouldAllowTransformingFilePattern() {
5673
NvdApiDataSource.FeedUrl result = extractFromUrlOptionalPattern("https://internal.server/nist/nvdcve-{0}.json.gz")
5774
.withPattern(p -> p.orElseThrow().replace(".json.gz", ".something"));
5875
assertEquals("https://internal.server/nist/nvdcve-ok.something", result.toFormattedUrlString("ok"));
@@ -80,7 +97,7 @@ void shouldExtractUrlWithoutPattern() throws Exception {
8097
}
8198

8299
@Test
83-
void extractUrlWithoutPatternShouldAddTrailingSlashes() throws Exception {
100+
void extractUrlWithoutPatternShouldAddTrailingSlashes() {
84101
String nvdDataFeedUrl = "https://internal.server/nist";
85102
String expectedUrl = "https://internal.server/nist/nvdcve-2045.json.gz";
86103

@@ -90,4 +107,115 @@ void extractUrlWithoutPatternShouldAddTrailingSlashes() throws Exception {
90107
assertEquals(expectedUrl, result.toFormattedUrlString("2045"));
91108
}
92109
}
110+
111+
@Nested
112+
class FeedUrlMandatoryYears {
113+
114+
@Test
115+
void shouldConsiderYearsMandatoryWhenNotCurrentYearAtEarliestTZ() {
116+
ZonedDateTime janFirst2004AtEarliest = ZonedDateTime.of(2004, 1, 1, 0, 0, 0, 0, NvdApiDataSource.FeedUrl.ZONE_GLOBAL_EARLIEST);
117+
assertTrue(isMandatoryFeedYear(janFirst2004AtEarliest, 2002));
118+
assertTrue(isMandatoryFeedYear(janFirst2004AtEarliest, 2003));
119+
assertFalse(isMandatoryFeedYear(janFirst2004AtEarliest, 2004));
120+
}
121+
122+
@Test
123+
void shouldConsiderYearsMandatoryWhenNotCurrentYearAtLatestTZ() {
124+
ZonedDateTime janFirst2004AtLatest = ZonedDateTime.of(2004, 1, 1, 0, 0, 0, 0, NvdApiDataSource.FeedUrl.ZONE_GLOBAL_LATEST);
125+
assertTrue(isMandatoryFeedYear(janFirst2004AtLatest, 2002));
126+
assertTrue(isMandatoryFeedYear(janFirst2004AtLatest, 2003));
127+
assertFalse(isMandatoryFeedYear(janFirst2004AtLatest, 2004));
128+
}
129+
130+
@Test
131+
void shouldConsiderYearsMandatoryWhenNoLongerJan1Anywhere() {
132+
// It's still Jan 1 somewhere...
133+
ZonedDateTime janSecond2004AtEarliest = ZonedDateTime.of(2004, 1, 2, 0, 0, 0, 0, NvdApiDataSource.FeedUrl.ZONE_GLOBAL_EARLIEST);
134+
assertFalse(isMandatoryFeedYear(janSecond2004AtEarliest, 2004));
135+
136+
// Until it's no longer Jan 1 anywhere
137+
ZonedDateTime janSecond2004AtLatest = ZonedDateTime.of(2004, 1, 2, 0, 0, 0, 1, NvdApiDataSource.FeedUrl.ZONE_GLOBAL_LATEST);
138+
assertTrue(isMandatoryFeedYear(janSecond2004AtLatest, 2004));
139+
}
140+
}
141+
142+
@Nested
143+
class FeedUrlMetadataRetrieval {
144+
145+
@Test
146+
void shouldRetrieveMetadataByYear() throws Exception {
147+
try (MockedStatic<Downloader> downloaderClass = mockStatic(Downloader.class)) {
148+
Downloader downloader = mock(Downloader.class);
149+
when(downloader.fetchContent(any(), any())).thenReturn("lastModifiedDate=2013-01-01T12:00:00Z");
150+
downloaderClass.when(Downloader::getInstance).thenReturn(downloader);
151+
152+
assertThat(retrieveUntil(ZonedDateTime.of(2003, 12, 1, 0, 0, 0, 0, ZoneOffset.UTC)).keySet(),
153+
contains("lastModifiedDate.2002", "lastModifiedDate.2003"));
154+
}
155+
}
156+
157+
@Test
158+
void shouldRetrieveMetadataForNextYearOnJan1AtEarliestTZ() throws Exception {
159+
try (MockedStatic<Downloader> downloaderClass = mockStatic(Downloader.class)) {
160+
Downloader downloader = mock(Downloader.class);
161+
when(downloader.fetchContent(any(), any())).thenReturn("lastModifiedDate=2013-01-01T12:00:00Z");
162+
downloaderClass.when(Downloader::getInstance).thenReturn(downloader);
163+
164+
ZonedDateTime jan1Earliest = ZonedDateTime.of(2004, 1, 1, 0, 0, 0, 0, NvdApiDataSource.FeedUrl.ZONE_GLOBAL_EARLIEST);
165+
assertThat(retrieveUntil(jan1Earliest.minusSeconds(1)).keySet(),
166+
contains("lastModifiedDate.2002", "lastModifiedDate.2003"));
167+
168+
assertThat(retrieveUntil(jan1Earliest).keySet(),
169+
contains("lastModifiedDate.2002", "lastModifiedDate.2003", "lastModifiedDate.2004"));
170+
171+
assertThat(retrieveUntil(ZonedDateTime.of(2004, 1, 1, 0, 0, 0, 0, NvdApiDataSource.FeedUrl.ZONE_GLOBAL_LATEST)).keySet(),
172+
contains("lastModifiedDate.2002", "lastModifiedDate.2003", "lastModifiedDate.2004"));
173+
}
174+
}
175+
176+
@Test
177+
void shouldNormallyRethrowDownloadErrorsEvenIfJan1OnEndYear() throws Exception {
178+
try (MockedStatic<Downloader> downloaderClass = mockStatic(Downloader.class)) {
179+
Downloader downloader = mock(Downloader.class);
180+
when(downloader.fetchContent(any(), any())).thenThrow(new DownloadFailedException("failed to download"));
181+
downloaderClass.when(Downloader::getInstance).thenReturn(downloader);
182+
183+
assertThrows(UpdateException.class, () -> retrieveUntil(ZonedDateTime.of(2003, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC)));
184+
}
185+
}
186+
187+
@Test
188+
void shouldIgnoreDownloadFailureForFinalYearIfStillJan1() throws Exception {
189+
List<ZonedDateTime> untilDates = List.of(
190+
ZonedDateTime.of(2004, 1, 1, 0, 0, 0, 0, NvdApiDataSource.FeedUrl.ZONE_GLOBAL_EARLIEST),
191+
ZonedDateTime.of(2004, 1, 2, 0, 0, 0, 0, NvdApiDataSource.FeedUrl.ZONE_GLOBAL_LATEST)
192+
.minusSeconds(1)
193+
);
194+
195+
for (ZonedDateTime until : untilDates) {
196+
try (MockedStatic<Downloader> downloaderClass = mockStatic(Downloader.class)) {
197+
Downloader downloader = mock(Downloader.class);
198+
when(downloader.fetchContent(any(), any()))
199+
.thenReturn("lastModifiedDate=2013-01-01T12:00:00Z")
200+
.thenReturn("lastModifiedDate=2013-01-01T12:00:00Z")
201+
.thenThrow(new DownloadFailedException("failed to download 3rd file"));
202+
203+
downloaderClass.when(Downloader::getInstance).thenReturn(downloader);
204+
205+
assertThat(retrieveUntil(until).keySet(),
206+
contains("lastModifiedDate.2002", "lastModifiedDate.2003"));
207+
}
208+
}
209+
}
210+
211+
private @NonNull Map<String, ZonedDateTime> retrieveUntil(ZonedDateTime dec12th) throws UpdateException {
212+
Map<String, ZonedDateTime> lastModifieds;
213+
NvdApiDataSource.FeedUrl feedUrl = extractFromUrlOptionalPattern("https://internal.server/nist/nvdcve-{0}.json.gz");
214+
215+
lastModifieds = feedUrl.getLastModifiedDatePropertiesByYear(new Settings(), dec12th);
216+
217+
assertThat(lastModifieds.values(), everyItem(Matchers.equalTo(ZonedDateTime.of(2013, 1, 1, 12, 0, 0, 0, ZoneOffset.UTC))));
218+
return lastModifieds;
219+
}
220+
}
93221
}

0 commit comments

Comments
 (0)