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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ curl -X POST "http://localhost:3000/search" \

### Virtual Match String Version Ranges

Use `versionStart`, `versionStartType`, `versionEnd`, and `versionEndType` with `virtualMatchString` to search CPE Match String ranges. Each provided version bound must include its matching type, and the `virtualMatchString` must be a partial CPE 2.3 match string without a version component. When both bounds are provided, `versionStart` must be less than `versionEnd`; equal values are valid only when both bounds are `including`.
Use `versionStart`, `versionStartType`, `versionEnd`, and `versionEndType` with `virtualMatchString` to search CPE Match String ranges. Each provided version bound must include its matching type, and the `virtualMatchString` must be a partial CPE 2.3 match string without a version component. Negative numeric values and the standalone CPE logical `NA` value (`-`) are not accepted as ordered range boundaries. When both bounds are provided, `versionStart` must be less than `versionEnd`; equal values are valid only when both bounds are `including`.

```bash
curl -X POST "http://localhost:3000/search" \
Expand Down Expand Up @@ -276,6 +276,11 @@ Each `/search` request owns a lifecycle cancellation signal. When its overall de

### Semantic Post-Filtering

Two separate 10,000-record controls apply to `/search`:

- Every requested page must fall within the first 10,000 results. Direct OpenSearch filters still report an exact total that can exceed 10,000, but pages beyond that window are rejected.
- Filters that require semantic post-filtering inspect at most 10,000 OpenSearch candidates. Their reported total and pagination describe only matches found within that candidate set.

CVSS vector filters, SSVC filters, date-bounded positive `knownExploited` searches, `cpeName`, and `virtualMatchString` searches use semantic post-filtering where OpenSearch cannot guarantee the required same-entry or version-comparison behavior. These searches retrieve up to 10,000 lightweight candidates, apply semantic checks in Node.js, paginate the matching CVE IDs, and fetch the requested page as complete CVE records. Every slash-delimited token supplied to one CVSS vector filter must match a complete token in the same eligible vector; a base token such as `AV:N` does not match a modified token such as `MAV:N`.

The reported total and pagination are complete only within that bounded candidate set; qualifying records beyond the first 10,000 OpenSearch candidates may not be represented. This preserves the existing bounded post-filter behavior until PIT-based pagination is implemented. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior.
Expand Down
20 changes: 11 additions & 9 deletions api-docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"CVE Record"
],
"summary": "Retrieves all matching CVE records after applying the JSON request body as filters",
"description": " <h2>Expected Behavior</h2> <p>Retrieves all matching CVE records after applying the JSON request body as filters</p>",
"description": " <h2>Expected Behavior</h2> <p>Retrieves all matching CVE records after applying the JSON request body as filters</p> <h2>Result Counts and Pagination</h2> <p>Direct searches report the exact number of matching records, including totals greater than 10,000, but requested pages must remain within the first 10,000 results.</p> <p>CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. These searches evaluate at most 10,000 OpenSearch candidates, so their reported totals and pages include only matches found within that candidate set.</p>",
"operationId": "search",
"responses": {
"200": {
Expand Down Expand Up @@ -436,7 +436,8 @@
"properties": {
"value": {
"type": "integer",
"minimum": 0
"minimum": 0,
"description": "For direct searches, the exact number of matching records, which may be greater than 10,000. For semantic post-filtered searches, the number of matches found within the bounded 10,000-candidate set."
}
}
},
Expand Down Expand Up @@ -477,7 +478,8 @@
},
"totalPages": {
"type": "integer",
"minimum": 0
"minimum": 0,
"description": "Pages derived from the reported total and requested page size. Semantic post-filtered searches derive this value from matches found within the bounded 10,000-candidate set."
},
"status": {
"type": "string",
Expand Down Expand Up @@ -550,7 +552,7 @@
},
"SearchRequest": {
"type": "object",
"description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end.",
"description": "Optional filters and pagination controls for a CVE record search. Enum-valued fields are accepted case-insensitively and normalized to the documented canonical values. When both boundaries of a date range are supplied, the start must be before or equal to the end. Direct searches report exact totals even when they exceed 10,000, although requested pages must remain inside that window. CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. They evaluate at most 10,000 OpenSearch candidates, so reported totals and pages include only matches found within that candidate set.",
"additionalProperties": false,
"properties": {
"scope": {
Expand Down Expand Up @@ -744,20 +746,20 @@
"minimum": 1,
"maximum": 500,
"default": 20,
"description": "Specifies the maximum number of CVE records returned in one response. The requested page must remain within the first 10000 results."
"description": "Specifies the maximum number of CVE records returned in one response. For every search, the requested page must remain within the first 10,000 results. This pagination window does not cap the exact total reported by a direct search."
},
"pageNumber": {
"type": "integer",
"format": "int32",
"minimum": 1,
"default": 1,
"description": "Specifies the page to return based on resultsPerPage. The requested page must remain within the first 10000 results."
"description": "Specifies the page to return based on resultsPerPage. For every search, the requested page must remain within the first 10,000 results. This pagination window does not cap the exact total reported by a direct search."
},
"versionStart": {
"type": "string",
"maxLength": 256,
"example": "2.2",
"description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component."
"description": "Sets the starting version bound for a CPE Match String range search. versionStartType and virtualMatchString are required when versionStart is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component."
},
"versionStartType": {
"type": "string",
Expand All @@ -771,7 +773,7 @@
"type": "string",
"maxLength": 256,
"example": "2.6",
"description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component."
"description": "Sets the ending version bound for a CPE Match String range search. versionEndType and virtualMatchString are required when versionEnd is provided. Negative numeric values and the standalone CPE logical NA value (-) are not accepted as ordered range boundaries. When both bounds are provided, versionStart must be less than versionEnd; equal values require both bounds to be including. Numeric version segments in ranges ignore leading zeros; exact CPE Match Criteria preserve leading zeros. Version qualifiers use Maven-style ordering, with prerelease qualifiers before an unqualified release and sp or unknown qualifiers after it. virtualMatchString must not include a version component."
},
"versionEndType": {
"type": "string",
Expand Down Expand Up @@ -861,4 +863,4 @@
}
}
}
}
}
12 changes: 12 additions & 0 deletions api-docs/openapi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ describe('OpenAPI contract', () => {
expect(mediaType.schema).toStrictEqual({ $ref: '#/components/schemas/SearchRequest' });
expect(schema.additionalProperties).toBe(false);
expect(schema.description).toContain('the start must be before or equal to the end');
expect(schema.description).toContain('Direct searches report exact totals');
expect(schema.description).toContain('semantic post-filtering');
expect(schema.description).toContain('10,000 OpenSearch candidates');
expect(Object.keys(schema.properties)).toStrictEqual(constants.SEARCH_QUERY_PARAMS);
Object.values(schema.properties).forEach((property) => {
expect(property.description).toEqual(expect.any(String));
Expand Down Expand Up @@ -109,14 +112,18 @@ describe('OpenAPI contract', () => {
expect(schema.properties.versionStartType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES);
expect(schema.properties.versionEndType.enum).toStrictEqual(constants.CPE_VERSION_RANGE_TYPES);
expect(schema.properties.resultsPerPage.maximum).toBe(constants.MAX_RESULTS_LIMIT);
expect(schema.properties.resultsPerPage.description).toContain('does not cap the exact total');
expect(schema.properties.pageNumber.description).toContain('first 10,000 results');
expect(schema.properties.cveId).toBeUndefined();
expect(schema.properties.sourceIdentifier).toBeUndefined();
expect(schema.properties.versionStart.description).toContain('versionStartType');
expect(schema.properties.versionStart.description).toContain('less than versionEnd');
expect(schema.properties.versionStart.description).toContain('ranges ignore leading zeros');
expect(schema.properties.versionStart.description).toContain('Maven-style ordering');
expect(schema.properties.versionStart.description).toContain('Negative numeric values');
expect(schema.properties.versionEnd.description).toContain('versionEndType');
expect(schema.properties.versionEnd.description).toContain('exact CPE Match Criteria preserve leading zeros');
expect(schema.properties.versionEnd.description).toContain('standalone CPE logical NA value');
expect(schema.properties.versionEnd.description).toContain(
'prerelease qualifiers before an unqualified release'
);
Expand Down Expand Up @@ -197,7 +204,12 @@ describe('OpenAPI contract', () => {
]);
expect(searchResponse.properties.pageSize.description).toContain('requested for this page');
expect(searchResponse.properties.resultsReturned.description).toContain('actually returned');
expect(searchResponse.properties.totalPages.description).toContain('10,000-candidate set');
expect(searchResponse.properties.data.properties.hits.$ref).toBe('#/components/schemas/SearchHits');
expect(schemas.SearchHits.properties.total.properties.value.description).toContain(
'exact number of matching records'
);
expect(schemas.SearchHits.properties.total.properties.value.description).toContain('10,000-candidate set');
expect(schemas.SearchHits.properties.hits.items.$ref).toBe('#/components/schemas/SearchHit');
expect(schemas.ApiError.required).toStrictEqual(['error', 'message']);
expect(schemas.ApiErrorResponse.oneOf).toHaveLength(2);
Expand Down
9 changes: 9 additions & 0 deletions controllers/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ function isSingleNonEmptyString(value) {
return typeof value === 'string' && value.trim() !== '';
}

function isUnsupportedVersionRangeBoundary(value) {
const normalized = String(value ?? '').trim();
return normalized === '-' || /^-\d/.test(normalized);
}

function isBoolean(value) {
return value === true || value === false;
}
Expand Down Expand Up @@ -252,6 +257,10 @@ function validateParams(req, res, next) {
if (hasParam(params, param) && !isSingleNonEmptyString(params[param])) {
return res.status(400).json(error.versionRangeValueError(param));
}

if (hasParam(params, param) && isUnsupportedVersionRangeBoundary(params[param])) {
return res.status(400).json(error.versionRangeBoundaryError(param));
}
}

for (const param of versionRangeTypeParams) {
Expand Down
17 changes: 17 additions & 0 deletions controllers/middleware.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,23 @@ describe('middleware validation function', () => {
expectValidationError('VERSION_RANGE_VALUE', `The ${param} parameter must be a non-empty string.`);
});

test.each([
['versionStart', '-1'],
['versionEnd', '-1'],
['versionStart', '-']
])('ValidateParams rejects unsupported ordered version boundary %s=%s.', (param, value) => {
validate({
virtualMatchString: 'cpe:2.3:o:linux:linux_kernel',
[param]: value,
[`${param}Type`]: 'including'
});

expectValidationError(
'VERSION_RANGE_BOUNDARY',
`The ${param} parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.`
);
});

test.each([
['versionStartType', 'inclusive'],
['versionEndType', ['excluding']]
Expand Down
3 changes: 3 additions & 0 deletions routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ router.post(
#swagger.description = "
<h2>Expected Behavior</h2>
<p>Retrieves all matching CVE records after applying the JSON request body as filters</p>
<h2>Result Counts and Pagination</h2>
<p>Direct searches report the exact number of matching records, including totals greater than 10,000, but requested pages must remain within the first 10,000 results.</p>
<p>CVSS vector filters, SSVC filters, date-bounded positive knownExploited searches, cpeName, and virtualMatchString use semantic post-filtering. These searches evaluate at most 10,000 OpenSearch candidates, so their reported totals and pages include only matches found within that candidate set.</p>
#swagger.requestBody = {
required: false,
content: {
Expand Down
20 changes: 20 additions & 0 deletions routes/search.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,26 @@ describe('search route integration', () => {
expect(mockSearchOpenSearch).not.toHaveBeenCalled();
});

test('POST /search rejects a negative numeric version bound before invoking OpenSearch', async () => {
const response = await request(createApp(), {
method: 'POST',
path: '/search',
body: {
virtualMatchString: 'cpe:2.3:o:linux:linux_kernel',
versionEnd: '-1',
versionEndType: 'including'
}
});

expect(response.status).toBe(400);
expect(response.body).toStrictEqual({
error: 'VERSION_RANGE_BOUNDARY',
message:
'The versionEnd parameter must be an ordered version value; negative numeric versions and the CPE logical NA value (-) are not supported.'
});
expect(mockSearchOpenSearch).not.toHaveBeenCalled();
});

test('POST /webSearch normalizes body fields and formats BasicSearchManager responses', async () => {
mockBasicSearch.mockResolvedValue(basicResponse({ total: 1, hits: [{ _id: 'CVE-2024-0002' }] }));

Expand Down
Loading
Loading