Skip to content
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Copy `.env.example` to an untracked `.env` and replace or check the following va
- `WebSearchEnabled` - optional `/webSearch` availability flag, default `true`
- `NPM_CA_CERT` - optional absolute host path to a corporate or self-signed CA certificate bundle used only during local Docker Compose builds

The three timeout settings (`SearchOpenSearchRequestTimeoutMs`, `SearchRequestDeadlineMs`, and `SearchReadinessTimeoutMs`) accept integer milliseconds from `1` through `2147483647`. Invalid or out-of-range settings fall back to the defaults listed above; they do not disable timeouts. This upper bound prevents Node.js timer overflow from turning a large configured duration into a 1 ms timeout.

```bash
cp .env.example .env
```
Expand Down Expand Up @@ -86,8 +88,12 @@ The Search API uses `PORT`, defaulting to `http://localhost:3000`. The tracked P

An AWS load balancer can use `/api/ready-check` to stop routing search traffic to an unready task while the container orchestrator continues using `/api/health-check` for process liveness. Coordinate that probe selection with the infrastructure team before changing deployed health-check behavior.

When the configured index is an alias spanning multiple indices, every reported mapped type of each required field must be compatible and searchable. CVE ID sorting also requires aggregation support. One compatible type cannot conceal a conflicting type in another index; readiness returns HTTP 503 rather than advertising that configuration as ready.

## API Input Validation

JSON request bodies for both search endpoints are limited to 100 KiB (102,400 bytes, measured after decompression). Larger bodies return HTTP 413 `REQUEST_BODY_TOO_LARGE`. Parser errors return sanitized JSON rather than HTML or stack traces: malformed JSON returns HTTP 400 `MALFORMED_JSON`, other unreadable bodies return HTTP 400 `INVALID_REQUEST`, and unsupported encoding returns HTTP 415 (`SEARCH_CONTENT_TYPE` for `/search`, `REQUEST_ENCODING_UNSUPPORTED` for `/webSearch`). Unexpected server errors return a generic `INTERNAL_SERVER_ERROR` response without exception details, including outside production mode.

`POST /search` accepts an optional JSON object request body and validates supported fields, boolean parameters, pagination values, CVSS severity values, required parameter combinations, CPE Name requirements, and date-time formats. Enum-valued fields are accepted case-insensitively and normalized to their documented canonical values. A non-empty body must use the `application/json` media type with UTF-8 encoding; unsupported media types or encodings return HTTP 415, and malformed JSON returns HTTP 400. If OpenSearch is unavailable, the endpoint returns HTTP 503 with a `Retry-After` header.

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.
Expand Down Expand Up @@ -230,7 +236,7 @@ curl -X POST "http://localhost:3000/search" \
-d '{"virtualMatchString":"cpe:2.3:o:linux:linux_kernel","versionStart":"2.2","versionStartType":"including","versionEnd":"2.6","versionEndType":"excluding"}'
```

Version ranges use overlap semantics. A stored wildcard-version CPE Match Criteria entry such as `cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*` with `versionEndExcluding=6.9` matches requested ranges that overlap versions before `6.9`, and does not match a requested range that starts at `6.9`.
Version ranges use overlap semantics. A stored wildcard-version CPE Match Criteria entry such as `cpe:2.3:o:linux:linux_kernel:*:*:*:*:*:*:*:*` with `versionEndExcluding=6.9` matches requested ranges that overlap versions before `6.9`, and does not match a requested range that starts at `6.9`. Stored intervals with a start after their end, or equal boundaries where either boundary is exclusive, are empty and do not match range searches.

CPE version comparisons used by both `cpeName` applicability and `virtualMatchString` version ranges use Maven-style qualifier precedence: `alpha`/`a` < `beta`/`b` < `milestone`/`m` < `rc`/`cr` < `snapshot` < an unqualified release/`ga`/`final`/`release` < `sp` < unknown qualifiers. Unknown qualifiers, including `hf`, sort after known qualifiers in case-insensitive lexical order. This means a range beginning at `1.0.0-rc1` includes `1.0.0`, while a range ending before `1.0.0` can include prerelease versions.

Expand Down Expand Up @@ -302,6 +308,8 @@ Access logs omit query strings and request bodies. By default, the API retains 1

Each `/search` request owns a lifecycle cancellation signal. When its overall deadline expires, the API aborts the active OpenSearch transport request before returning HTTP 504. When the caller disconnects, the API aborts work that is needed only by that caller and does not attempt to write a response. This prevents timed-out or abandoned standard searches from continuing until the transport timeout.

Deadline checks also compare elapsed monotonic time, so they do not depend on the timeout callback running first. Semantic filtering checks cancellation between candidates and after the final candidate, and yields to the event loop every 100 candidates. An expired search cannot start its page fetch or return a successful empty result merely because filtering delayed the timer. Cancellation is cooperative; it does not interrupt an individual synchronous operation in progress.

## Semantic Post-Filtering

Two separate 10,000-record controls apply to `/search`:
Expand Down
52 changes: 51 additions & 1 deletion api-docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@
}
}
},
"413": {
"description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"415": {
"description": "A non-empty request body did not use the application/json media type with UTF-8 encoding",
"content": {
Expand All @@ -62,6 +72,16 @@
}
}
},
"500": {
"description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"502": {
"description": "The search backend returned an invalid or unexpected response",
"content": {
Expand Down Expand Up @@ -277,7 +297,7 @@
}
},
"400": {
"description": "Malformed JSON or invalid raw-query request body, sort, or pagination",
"description": "Malformed JSON, unreadable request body, or invalid raw-query request body, sort, or pagination",
"content": {
"application/json": {
"schema": {
Expand All @@ -296,6 +316,36 @@
}
}
},
"413": {
"description": "JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"415": {
"description": "Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"502": {
"description": "The search backend returned an error or invalid response",
"content": {
Expand Down
9 changes: 9 additions & 0 deletions api-docs/openapi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,15 @@ describe('OpenAPI contract', () => {
});
});

test.each(['/search', '/webSearch'])('documents sanitized parser and server errors for %s', (path) => {
const responses = openapi.paths[path].post.responses;
for (const status of ['413', '415', '500']) {
expect(responses[status].content['application/json'].schema.$ref).toBe('#/components/schemas/ApiError');
}
expect(responses['413'].description).toContain('REQUEST_BODY_TOO_LARGE');
expect(responses['500'].description).toContain('INTERNAL_SERVER_ERROR');
});

test('documents reusable success and error response schemas', () => {
const schemas = openapi.components.schemas;
const searchResponse = schemas.SearchResponse;
Expand Down
4 changes: 3 additions & 1 deletion controllers/searchController.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ async function buildPostFilterCandidateResult(candidateBody, postFilters, signal
const candidateResp = {
data: candidateData
};
const { candidateCount, filteredHits } = searchPostFilters.filterSearchHits(candidateResp, postFilters);
const { candidateCount, filteredHits } = await searchPostFilters.filterSearchHits(candidateResp, postFilters, {
signal
});

return {
candidateCount,
Expand Down
29 changes: 28 additions & 1 deletion controllers/searchController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jest.mock('../utils/searchProvider', () => ({
const controller = require('./searchController.js');
const constants = require('../utils/constants');
const searchPostFilters = require('../utils/searchPostFilters');
const cpe = require('../utils/cpe');

function createResponse() {
const res = {};
Expand Down Expand Up @@ -1113,7 +1114,7 @@ describe('Search controller SEARCH handler', () => {
{
regexp: {
'containers.cna.cpeApplicability.nodes.cpeMatch.criteria.keyword': {
value: 'cpe:2\\.3:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*:[^:]*',
value: 'cpe:2\\.3:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*:([^:\\\\]|\\\\.)*',
case_insensitive: true
}
}
Expand Down Expand Up @@ -1234,6 +1235,32 @@ describe('Search controller SEARCH handler', () => {
});
});

test.each([true, false])(
'SEARCH rejects elapsed deadlines after synchronous filtering with match=%s',
async (matches) => {
captureTimers();
const clock = jest.spyOn(process.hrtime, 'bigint').mockReturnValue(0n);
mockGetSearchRequestDeadlineMs.mockReturnValue(10);
mockSearchRaw.mockResolvedValue(
rawResponse(1, [
cpeHit('CVE-2024-0001', {
criteria: 'cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*'
})
])
);
jest.spyOn(cpe, 'hitAppliesToCpeName').mockImplementation(() => {
clock.mockReturnValue(10000000n);
return matches;
});

await controller.SEARCH({ searchParams: { cpeName: 'cpe:2.3:a:vendor:product:1.0' } }, res, next);

expect(res.status).toHaveBeenCalledWith(504);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'SEARCH_TIMEOUT' }));
expect(mockApiSearch).not.toHaveBeenCalled();
}
);

test('SEARCH stops without writing a response when the client disconnects', async () => {
const req = new EventEmitter();
req.requestId = 'client-disconnect-request';
Expand Down
42 changes: 41 additions & 1 deletion routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,22 @@ content: {
}
},
}
#swagger.responses[413] = {
description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)',
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ApiError" }
}
}
}
#swagger.responses[500] = {
description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)',
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ApiError" }
}
}
}
#swagger.responses[415] = {
description: 'A non-empty request body did not use the application/json media type with UTF-8 encoding',
content: {
Expand Down Expand Up @@ -530,7 +546,7 @@ headers: {
}
}
#swagger.responses[400] = {
description: 'Malformed JSON or invalid raw-query request body, sort, or pagination',
description: 'Malformed JSON, unreadable request body, or invalid raw-query request body, sort, or pagination',
content: {
"application/json": {
schema: {
Expand All @@ -549,6 +565,30 @@ content: {
}
},
}
#swagger.responses[413] = {
description: 'JSON request body exceeds the 100 KiB parser limit (REQUEST_BODY_TOO_LARGE)',
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ApiError" }
}
}
}
#swagger.responses[415] = {
description: 'Unsupported request body encoding (REQUEST_ENCODING_UNSUPPORTED)',
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ApiError" }
}
}
}
#swagger.responses[500] = {
description: 'An unexpected server error; internal details are not returned (INTERNAL_SERVER_ERROR)',
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ApiError" }
}
}
}
#swagger.responses[410] = {
description: 'The endpoint is disabled by service configuration',
content: {
Expand Down
18 changes: 15 additions & 3 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,26 @@ function createRequestLogger() {
}

function handleRequestError(err, req, res, next) {
if (res.headersSent) return next(err);

if (err?.type === 'entity.parse.failed') {
return res.status(400).json(error.malformedJsonError());
}

if (err?.status === 415 && req.path === '/search') {
return res.status(415).json(error.searchContentTypeError());
const errorStatus = err?.status ?? err?.statusCode;
const status = Number.isInteger(errorStatus) && errorStatus >= 400 && errorStatus <= 599 ? errorStatus : 500;

if (err?.type === 'entity.too.large' || status === 413) {
return res.status(413).json(error.requestBodyTooLargeError());
}

if (status === 415) {
return res
.status(415)
.json(req.path === '/search' ? error.searchContentTypeError() : error.requestEncodingError());
}

next(err);
return res.status(status).json(status < 500 ? error.invalidRequestError() : error.internalServerError());
}

function createApp() {
Expand Down Expand Up @@ -90,6 +101,7 @@ module.exports = {
createRequestLogger,
getRedactedRequestPath: requestLogging.getRedactedRequestPath,
getRuntimeConfig,
handleRequestError,
startServer,
swaggerSetupOptions
};
Loading
Loading