Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ JSON request bodies for both search endpoints are limited to 100 KiB (102,400 by

Successful `/search` responses use an API-owned response structure rather than exposing search-backend response objects. The top-level fields are `apiVersion`, `status`, `warnings`, `pagination`, and `records`, in that order. `records` contains complete CVE Records directly, without search-backend hit wrappers or infrastructure metadata. `pagination` contains `page`, `pageSize`, `resultsReturned`, `totalResults`, `totalPages`, and `totalIsExact`. Non-fatal conditions are reported in `warnings` before the potentially long `records` array.

The API omits the top-level `opensearch_ops` ingestion metadata from each `/search` record, including ingestion timestamps and signatures, while preserving all other record fields and containers. This targeted removal does not validate or repair records against the CVE JSON schema and does not modify the indexed data.

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.

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`.
Expand Down
2 changes: 1 addition & 1 deletion api-docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@
"CveRecord": {
"type": "object",
"additionalProperties": true,
"description": "A complete CVE Record without search-backend transport metadata."
"description": "A complete CVE Record without search-backend transport metadata. The top-level opensearch_ops ingestion metadata is omitted; all other record fields and containers are preserved. This targeted removal does not validate or repair the record against the CVE JSON schema."
},
"SearchResponse": {
"type": "object",
Expand Down
13 changes: 8 additions & 5 deletions controllers/searchController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -660,12 +660,14 @@ describe('Search controller SEARCH handler', () => {
}
}
};
const publicRecord = {
cveMetadata: { cveId: 'CVE-2024-0001' },
containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] }
};
const metadata = { ingest_timestamp: '2026-06-30T17:40:18.109339170Z' };
const fullRecord = {
_id: 'CVE-2024-0001',
_source: {
cveMetadata: { cveId: 'CVE-2024-0001' },
containers: { cna: { title: 'complete record' }, adp: [{ title: 'enrichment' }] }
}
_source: Object.freeze({ ...publicRecord, opensearch_ops: metadata })
};
mockSearchRaw.mockResolvedValue(rawResponse(2, [matching, splitAcrossEntries]));
mockApiSearch.mockResolvedValue(advancedResponse(1, [fullRecord]));
Expand All @@ -691,7 +693,8 @@ describe('Search controller SEARCH handler', () => {
]);
expect(res.json.mock.calls[0][0].status).toBe('ok');
expect(res.json.mock.calls[0][0].pagination.totalResults).toBe(1);
expect(res.json.mock.calls[0][0].records).toStrictEqual([fullRecord._source]);
expect(res.json.mock.calls[0][0].records).toStrictEqual([publicRecord]);
expect(fullRecord._source.opensearch_ops).toStrictEqual(metadata);
});

test('SEARCH semantically filters date-bounded KEV candidates within the selected scope', async () => {
Expand Down
38 changes: 21 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"dotenv": "^6.1.0",
"express": "^4.22.2",
"express-validator": "^7.2.1",
"morgan": "~1.12.0",
"morgan": "~1.12.1",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
Expand All @@ -32,9 +32,9 @@
"swagger-autogen": "^2.23.7"
},
"overrides": {
"adm-zip": "0.6.0",
"axios": "1.18.0",
"brace-expansion": "5.0.9",
"adm-zip": "0.6.1",
"axios": "1.20.0",
"brace-expansion": "5.0.12",
"js-yaml": "4.3.2",
"lodash": "4.18.1",
"lodash.unset": "4.18.0",
Expand Down
5 changes: 4 additions & 1 deletion routes/search.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@ describe('search route integration', () => {
cveId: 'CVE-2024-0001'
}
};
const metadata = { _source_version: '0.1', ingest_timestamp: '2026-06-30T17:40:18.109339170Z' };
const source = Object.freeze({ ...record, opensearch_ops: metadata });
mockSearchOpenSearch.mockResolvedValue(
advancedResponse(1, [
{
_index: 'private-index-name',
_id: 'CVE-2024-0001',
_score: null,
_source: record
_source: source
}
]).data
);
Expand Down Expand Up @@ -122,6 +124,7 @@ describe('search route integration', () => {
},
records: [record]
});
expect(source.opensearch_ops).toStrictEqual(metadata);
});

test('POST /search builds a scope-aware literal reference URL substring filter', async () => {
Expand Down
3 changes: 2 additions & 1 deletion routes/swagger.js
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,8 @@ const doc = {
CveRecord: {
type: 'object',
additionalProperties: true,
description: 'A complete CVE Record without search-backend transport metadata.'
description:
'A complete CVE Record without search-backend transport metadata. The top-level opensearch_ops ingestion metadata is omitted; all other record fields and containers are preserved. This targeted removal does not validate or repair the record against the CVE JSON schema.'
},
SearchResponse: {
type: 'object',
Expand Down
5 changes: 4 additions & 1 deletion utils/searchResponseBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ function getRequestWarnings(params = {}) {

function buildSearchResponse({ totalResults, hits, pagingOptions, totalIsExact = true, warnings = [] }) {
const pageSize = pagingOptions.resultsPerPage;
const records = hits.map((hit) => hit._source);
const records = hits.map((hit) => {
const { opensearch_ops: _ingestionMetadata, ...record } = hit._source;
return record;
});
const page = totalResults === 0 ? 0 : Math.floor(pagingOptions.from / pageSize) + 1;
const totalPages = totalResults === 0 ? 0 : Math.ceil(totalResults / pageSize);

Expand Down
29 changes: 29 additions & 0 deletions utils/searchResponseBuilder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,35 @@ describe('search response builder', () => {
});
});

test.each(['PUBLISHED', 'REJECTED'])('omits ingestion metadata without mutating a %s record', (state) => {
const record = {
dataType: 'CVE_RECORD',
dataVersion: '5.1',
cveMetadata: { cveId: 'CVE-2024-0001', state },
containers: {
cna: { descriptions: [{ lang: 'en', value: 'CNA content' }] },
adp: [{ descriptions: [{ lang: 'en', value: 'ADP content' }] }]
},
futureRecordField: { value: 'preserved without a schema allowlist' }
};
const metadata = {
_source_version: '0.1',
ingest_timestamp: '2026-06-30T17:40:18.109339170Z',
ingested_cve_signature: 'CVE-2024-0001|2026-06-30T17:40:18.109Z|signature'
};
const source = Object.freeze({ ...record, opensearch_ops: metadata });
const response = searchResponseBuilder.buildSearchResponse({
totalResults: 1,
hits: [{ _source: source }],
pagingOptions: { from: 0, resultsPerPage: 20 }
});

expect(response.records).toStrictEqual([record]);
expect(response.records[0]).not.toHaveProperty('opensearch_ops');
expect(response.records[0]).not.toBe(source);
expect(source).toStrictEqual({ ...record, opensearch_ops: metadata });
});

test('preserves warnings before records and represents an empty result page', () => {
const warnings = [searchResponseBuilder.semanticCandidateLimitWarning()];
const response = searchResponseBuilder.buildSearchResponse({
Expand Down
Loading