Skip to content

Commit ff274f8

Browse files
committed
Warn accurately about requested CVE IDs absent from the index
1 parent e32185d commit ff274f8

13 files changed

Lines changed: 495 additions & 5 deletions

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@ The API omits the top-level `opensearch_ops` ingestion metadata from each `/sear
133133

134134
CVE Records do not exist for years before 1999. A syntactically valid pre-1999 value in `cveIds` is therefore a valid search that contributes no matches rather than a request error. The response includes a `CVE_IDS_NOT_RETURNED` warning listing those IDs. When pre-1999 and supported-year IDs are requested together, matching supported-year records are still returned.
135135

136+
For other requested IDs not already present in the returned page, the API performs at most one additional, ID-only lookup against the configured index. This lookup ignores other filters and pagination, fetches no full records, and handles duplicate documents without omitting another requested ID. `CVE_IDS_NOT_FOUND` lists IDs absent from the searchable index at lookup time; it does not mean those IDs do not exist globally. Existing IDs excluded by another filter or a different page do not receive this warning. Requests without `cveIds`, requests containing only pre-1999 IDs, and requests whose supported-year IDs all appear in the returned page need no additional lookup.
137+
138+
The lookup adds search work and can add latency. Index refresh delays or changes between the main search and lookup can affect visibility; the two operations do not share a snapshot. If lookup verification fails, `CVE_ID_AVAILABILITY_UNVERIFIED` identifies the unchecked IDs while preserving an otherwise successful search, without reporting those IDs as missing. The overall search deadline and client cancellation still apply to the entire request.
139+
136140
Use the optional `scope` field to control which parts of each record participate in matching. Values are case-insensitive: `FULL` (the default) searches base record fields, CNA data, and every ADP provider; `CNA_ONLY` searches base record fields and CNA data; and `ADP_ONLY` searches base record fields and every ADP provider. Scope changes matching only, so successful responses always contain the complete CVE record. CPE applicability filters use CNA data and return HTTP 400 when combined with `ADP_ONLY`.
137141

138142
Date filters accept `YYYY-MM-DD` or timestamps with seconds and an explicit timezone (`Z` or `+/-HH:MM`), with optional fractional seconds of 1 to 3 digits. For example, `2024-01-01`, `2024-01-01T00:00:00Z`, and `2024-01-01T01:00:00+01:00` are accepted. Values are normalized to UTC. A date-only start means `00:00:00.000Z`; a date-only end means `23:59:59.999Z` on that day. Explicit timestamps retain their exact instant, including end boundaries. Impossible dates, timestamps without a timezone, leap seconds, and precision finer than milliseconds return HTTP 400. When both boundaries for publication, modification, SSVC timestamp, or KEV date-added filters are supplied, the start must be before or equal to the end after normalization. All boundaries are inclusive.

‎api-docs/openapi.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@
685685
"type": "string",
686686
"maxLength": 4096,
687687
"example": "CVE-2024-1234,CVE-2024-5678",
688-
"description": "Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before 1999; those IDs produce no matches and are identified by a non-fatal response warning without preventing other requested IDs from matching. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall."
688+
"description": "Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before 1999; those IDs produce no matches and are identified by a non-fatal response warning (CVE_IDS_NOT_RETURNED) without preventing other requested IDs from matching. Other IDs absent from the returned page are verified through at most one additional ID-only lookup independent of scope, other filters, and pagination. CVE_IDS_NOT_FOUND lists IDs absent from the searchable configured index at lookup time, not necessarily nonexistent globally. Existing IDs excluded by filters or pagination do not receive this warning. Index refresh delays and changes between operations can affect visibility. CVE_ID_AVAILABILITY_UNVERIFIED warns when verification fails without discarding successful search results; overall deadlines and cancellation still apply. Limited to 100 CVE IDs, 64 characters per ID, and 4096 characters overall."
689689
},
690690
"noRejected": {
691691
"oneOf": [

‎api-docs/openapi.test.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ describe('OpenAPI contract', () => {
9494
expect(schema.properties.isVulnerable.oneOf).toStrictEqual(schema.properties.noRejected.oneOf);
9595
expect(schema.properties.scope.enum).toStrictEqual(['FULL', 'CNA_ONLY', 'ADP_ONLY']);
9696
expect(schema.properties.scope.default).toBe('FULL');
97+
expect(schema.properties.cveIds.description).toContain('CVE_IDS_NOT_FOUND');
98+
expect(schema.properties.cveIds.description).toContain('CVE_ID_AVAILABILITY_UNVERIFIED');
99+
expect(schema.properties.cveIds.description).toContain('independent of scope, other filters, and pagination');
97100
expect(schema.properties.scope.description).toContain('does not remove data');
98101
expect(schema.properties.ssvcExploitation.enum).toStrictEqual(['NONE', 'POC', 'ACTIVE']);
99102
expect(schema.properties.ssvcAutomatable.enum).toStrictEqual(['YES', 'NO']);

‎controllers/searchController.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const searchProvider = require('../utils/searchProvider');
55
const searchQueryBuilder = require('../utils/searchQueryBuilder');
66
const searchPostFilters = require('../utils/searchPostFilters');
77
const searchResponseBuilder = require('../utils/searchResponseBuilder');
8+
const searchCveIdWarnings = require('../utils/searchCveIdWarnings');
89
const webSearchQueryBuilder = require('../utils/webSearchQueryBuilder');
910
const error = new errors.errors();
1011
const openSearchUnavailableErrorNames = new Set(['ConnectionError', 'NoLivingConnectionsError']);
@@ -162,7 +163,19 @@ async function searchHandler(req, res, next) {
162163
);
163164
lifecycle.throwIfAborted();
164165
const pagingOptions = responseOptions ?? searchOptions;
165-
const warnings = [...searchResponseBuilder.getRequestWarnings(params), ...searchResult.warnings];
166+
const availabilityWarnings = await searchCveIdWarnings.getAvailabilityWarnings(
167+
params.cveIds,
168+
searchResult.hits,
169+
{
170+
signal: lifecycle.signal
171+
}
172+
);
173+
lifecycle.throwIfAborted();
174+
const warnings = [
175+
...searchResponseBuilder.getRequestWarnings(params),
176+
...availabilityWarnings,
177+
...searchResult.warnings
178+
];
166179

167180
return res.status(200).json(
168181
searchResponseBuilder.buildSearchResponse({

‎routes/search.integration.test.js‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,73 @@ describe('search route integration', () => {
2828
mockSearchOpenSearch.mockReset();
2929
});
3030

31+
test('returns matching IDs and warns only about IDs absent from the index', async () => {
32+
const record = { cveMetadata: { cveId: 'CVE-2024-0001' } };
33+
mockSearchOpenSearch
34+
.mockResolvedValueOnce(advancedResponse(1, [{ _source: record }]).data)
35+
.mockResolvedValueOnce({
36+
aggregations: { requested_ids: { buckets: { 'CVE-2099-99999999': { doc_count: 0 } } } }
37+
});
38+
const response = await request(createApp(), {
39+
method: 'POST',
40+
path: '/search',
41+
body: { cveIds: 'CVE-1998-0001,cve-2024-0001,CVE-2099-99999999,CVE-2099-99999999', resultsPerPage: 1 }
42+
});
43+
expect(response.status).toBe(200);
44+
expect(response.body.records).toStrictEqual([record]);
45+
expect(response.body.pagination.totalResults).toBe(1);
46+
expect(response.body.warnings).toStrictEqual([
47+
expect.objectContaining({ code: 'CVE_IDS_NOT_RETURNED', cveIds: ['CVE-1998-0001'] }),
48+
expect.objectContaining({ code: 'CVE_IDS_NOT_FOUND', cveIds: ['CVE-2099-99999999'] })
49+
]);
50+
expect(Object.keys(response.body).indexOf('warnings')).toBeLessThan(
51+
Object.keys(response.body).indexOf('records')
52+
);
53+
expect(mockSearchOpenSearch).toHaveBeenCalledTimes(2);
54+
});
55+
56+
test.each([
57+
{ resultsPerPage: 1, pageNumber: 2 },
58+
{ keywordSearch: 'nonmatching-text', scope: 'ADP_ONLY' },
59+
{ noRejected: true },
60+
{ virtualMatchString: 'cpe:2.3:o:linux:linux_kernel' }
61+
])('does not report an existing ID as missing when excluded by filters or pagination: %p', async (filters) => {
62+
mockSearchOpenSearch.mockResolvedValueOnce(advancedResponse(0).data).mockResolvedValueOnce({
63+
aggregations: { requested_ids: { buckets: { 'CVE-2024-0001': { doc_count: 1 } } } }
64+
});
65+
const response = await request(createApp(), {
66+
method: 'POST',
67+
path: '/search',
68+
body: { cveIds: 'CVE-2024-0001', ...filters }
69+
});
70+
expect(response.status).toBe(200);
71+
expect(response.body.records).toStrictEqual([]);
72+
expect(response.body.warnings).toStrictEqual([]);
73+
expect(mockSearchOpenSearch.mock.calls[1][0]).toMatchObject({
74+
size: 0,
75+
_source: false,
76+
query: { bool: { filter: [{ terms: { 'cveMetadata.cveId.keyword': ['CVE-2024-0001'] } }] } }
77+
});
78+
});
79+
80+
test('returns a successful search with a sanitized warning when only the ID lookup fails', async () => {
81+
const record = { cveMetadata: { cveId: 'CVE-2024-0002' } };
82+
mockSearchOpenSearch
83+
.mockResolvedValueOnce(advancedResponse(1, [{ _source: record }]).data)
84+
.mockRejectedValueOnce(new Error('private backend credentials'));
85+
const response = await request(createApp(), {
86+
method: 'POST',
87+
path: '/search',
88+
body: { cveIds: 'CVE-2024-0001,CVE-2024-0002' }
89+
});
90+
expect(response.status).toBe(200);
91+
expect(response.body.records).toStrictEqual([record]);
92+
expect(response.body.warnings).toStrictEqual([
93+
expect.objectContaining({ code: 'CVE_ID_AVAILABILITY_UNVERIFIED', cveIds: ['CVE-2024-0001'] })
94+
]);
95+
expect(response.text).not.toContain('private');
96+
});
97+
3198
test('date-only boundaries and offsets reach OpenSearch as inclusive UTC ranges', async () => {
3299
mockSearchOpenSearch.mockResolvedValue(advancedResponse(0).data);
33100
const response = await request(createApp(), {

‎routes/swagger.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ const searchRequestProperties = {
6969
coercibleBooleanSchema()
7070
),
7171
cveIds: searchRequestProperty(
72-
`Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before ${constants.FIRST_CVE_RECORD_YEAR}; those IDs produce no matches and are identified by a non-fatal response warning without preventing other requested IDs from matching. Limited to ${constants.MAX_CVE_IDS} CVE IDs, ${constants.MAX_CVE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cveIds} characters overall.`,
72+
`Returns one or more CVE records identified by a comma-separated list of CVE IDs. CVE Records do not exist for years before ${constants.FIRST_CVE_RECORD_YEAR}; those IDs produce no matches and are identified by a non-fatal response warning (CVE_IDS_NOT_RETURNED) without preventing other requested IDs from matching. Other IDs absent from the returned page are verified through at most one additional ID-only lookup independent of scope, other filters, and pagination. CVE_IDS_NOT_FOUND lists IDs absent from the searchable configured index at lookup time, not necessarily nonexistent globally. Existing IDs excluded by filters or pagination do not receive this warning. Index refresh delays and changes between operations can affect visibility. CVE_ID_AVAILABILITY_UNVERIFIED warns when verification fails without discarding successful search results; overall deadlines and cancellation still apply. Limited to ${constants.MAX_CVE_IDS} CVE IDs, ${constants.MAX_CVE_ID_LENGTH} characters per ID, and ${constants.SEARCH_STRING_LENGTH_LIMITS.cveIds} characters overall.`,
7373
{
7474
type: 'string',
7575
maxLength: constants.SEARCH_STRING_LENGTH_LIMITS.cveIds,

0 commit comments

Comments
 (0)