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
9 changes: 0 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,10 @@ PORT=3000
OpenSearchCveIndex=cve-index-local
OpenSearchAllowUnknownSslCerts=false
OpenSearchDomainEndpoint=
SearchLogPerformanceTimings=false
SearchPostFilterCacheTtlMs=60000
SearchPostFilterCacheMaxEntries=100
SearchPostFilterColdSearchConcurrency=10
SearchPostFilterColdSearchMaxQueueDepth=500
SearchPostFilterColdSearchMaxWaitMs=10000
SearchOpenSearchRequestTimeoutMs=20000
SearchRequestDeadlineMs=30000
SearchReadinessTimeoutMs=5000
RequestLogSuccessSampleRate=0.1
RequestLogHealthChecks=false
WebSearchEnabled=true
SearchRateLimitEnabled=false
SearchRateLimitWindowMs=60000
SearchRateLimitMaxRequests=0
NPM_CA_CERT=
66 changes: 5 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,12 @@ Rename `.env.example` to `.env` and replace or check the following values.
- `OpenSearchCveIndex` - existing CVE index name in your OpenSearch deployment
- `OpenSearchAllowUnknownSslCerts` - set to `true` only for local development against self-signed certificates
- `OpenSearchDomainEndpoint` - absolute URL to an existing OpenSearch deployment. This API does not create or manage the OpenSearch index or mapping.
- `SearchLogPerformanceTimings` - optional diagnostic flag; set to `true` temporarily to log redacted `/search` timing breakdowns
- `SearchPostFilterCacheTtlMs` - optional CPE post-filter cache TTL override in milliseconds, default `60000`
- `SearchPostFilterCacheMaxEntries` - optional per-process CPE post-filter cache entry limit, default `100`
- `SearchPostFilterColdSearchConcurrency` - optional per-process concurrent cold CPE candidate search limit, default `10`
- `SearchPostFilterColdSearchMaxQueueDepth` - optional per-process waiting queue depth for cold CPE candidate searches, default `500`
- `SearchPostFilterColdSearchMaxWaitMs` - optional maximum cold CPE candidate queue wait, default `10000`
- `SearchOpenSearchRequestTimeoutMs` - optional timeout for each OpenSearch request, default `20000`
- `SearchRequestDeadlineMs` - optional overall `/search` processing deadline, default `30000`
- `SearchReadinessTimeoutMs` - optional timeout for the OpenSearch index readiness check, default `5000`
- `RequestLogSuccessSampleRate` - optional fraction of successful non-health-check access logs to retain, default `0.1`
- `RequestLogHealthChecks` - optional flag to retain successful health-check access logs, default `false`
- `WebSearchEnabled` - optional deprecated `/webSearch` feature flag, default `true`
- `SearchRateLimitEnabled` - optional per-process `/search` rate-limit flag, default `false`
- `SearchRateLimitWindowMs` - optional rate-limit window in milliseconds, default `60000`
- `SearchRateLimitMaxRequests` - required positive per-process request limit when rate limiting is enabled
- `NPM_CA_CERT` - optional absolute host path to a corporate or self-signed CA certificate bundle used only during local Docker Compose builds

```bash
Expand Down Expand Up @@ -275,68 +266,21 @@ An importable Postman collection for manual `/search` contract and filter testin

### Operational Logging

Every request receives an `X-Request-ID` response header. A caller-provided `X-Request-ID` is preserved when it contains only letters, numbers, `.`, `_`, `:`, or `-` and is no longer than 128 characters; otherwise the API generates a UUID. The same value appears as `request-id` in access logs and `requestId` in structured `/search` events.
Every request receives an `X-Request-ID` response header. A caller-provided `X-Request-ID` is preserved when it contains only letters, numbers, `.`, `_`, `:`, or `-` and is no longer than 128 characters; otherwise the API generates a UUID. The same value appears as `request-id` in access logs.

Access logs omit query strings and request bodies. By default, the API retains 10% of successful non-health-check access logs, suppresses successful `/api/health-check` and `/api/ready-check` access logs, and retains every HTTP 4xx or 5xx access log. Set `RequestLogSuccessSampleRate` from `0` through `1` to change successful-request sampling. Set `RequestLogHealthChecks=true` to retain successful health-check logs.

The API always emits a sanitized JSON `search.performance` event for `/search` queue saturation, queue wait timeout, search timeout, client disconnect, OpenSearch connection errors, and upstream search errors. These events include the request ID, outcome, HTTP status, strategy, cache state, total elapsed time, cancellation reason, and queue state when applicable. Search terms, CPE values, request bodies, credentials, and upstream error details are never included.

Set `SearchLogPerformanceTimings=true` on the API deployment when diagnosing slow `/search` requests. Successful requests then emit the same structured event with additional redacted timing details, including candidate counts, filtered counts, returned count, OpenSearch operation types, maximum observed active OpenSearch requests in the process, OpenSearch elapsed time, OpenSearch `took`, post-filter elapsed time, cold-search queue wait time, page fetch elapsed or wait time, and total handler elapsed time. Leave it disabled during normal operation unless the deployment logging volume is acceptable.

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.

### Application Rate Limiting

Application rate limiting is disabled by default and applies only to `POST /search`. It does not apply to `/webSearch`, health checks, Swagger, OpenAPI, or static assets.

The fallback limiter uses one in-memory counter per Node.js process. Set `SearchRateLimitEnabled=true`, provide a positive `SearchRateLimitMaxRequests`, and optionally change `SearchRateLimitWindowMs` to enable it. Invalid enabled configuration prevents the application from starting. Rejected requests return HTTP 429 with `SEARCH_RATE_LIMITED`, `Retry-After`, `RateLimit`, and `RateLimit-Policy` headers, and emit a sanitized `search.performance` event with the `rate_limited` outcome.

This is a process-wide safety limit, not a precise distributed quota. Each ECS task has an independent counter, so total permitted traffic scales with the number of tasks and load-balancer routing. It deliberately does not use caller IP addresses or forwarded headers until the ALB proxy chain and Express `trust proxy` setting are confirmed. AWS WAF or another infrastructure-level distributed limit remains the preferred public-facing control.

### Semantic Post-Filtering

CVSS vector filters, SSVC filters, and date-bounded positive `knownExploited` searches use semantic post-filtering because OpenSearch ordinary-object arrays cannot guarantee that multiple conditions matched the same vector or structured metric entry. These searches retrieve up to 10,000 lightweight candidates, apply same-entry checks in Node.js, and fetch the requested page as complete CVE records. Every token supplied to one CVSS vector filter must occur in the same eligible vector. 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. CVSS, SSVC, and KEV candidates do not use the temporary CPE cache or cold-search queue. A `knownExploited` filter without date boundaries uses a direct OpenSearch query and retains ordinary exact-total behavior.

### CPE Post-Filter Cache

Searches that use `cpeName`, `virtualMatchString`, or `virtualMatchString` with version range parameters use a semantic CPE post-filter. The API first asks OpenSearch for up to 10,000 lightweight candidate records, filters those candidates in Node.js, then fetches the requested page of full CVE records by CVE ID.

The post-filter cache stores only the filtered CVE ID list and candidate count for that expensive candidate query. It does not cache full CVE records. On a cache hit, the API skips the 10,000-candidate OpenSearch query and semantic post-filtering, slices the cached CVE IDs for the requested page, and fetches that page of full CVE records by ID.

The cache is intentionally small and short-lived:

- TTL: 60 seconds
- Max entries: 100
- Scope: one Node.js process
- Stored value: filtered CVE IDs and candidate count
- In-flight behavior: concurrent identical cold searches share one reference-counted candidate query per process
- Cold-search concurrency: cold CPE candidate queries are limited to 10 per process before reaching OpenSearch
- Cold-search queue depth: up to 500 unique cold searches can wait per process behind the concurrency limit
- Cold-search queue wait: up to 10 seconds before a waiting search returns HTTP 429
- OpenSearch request timeout: 20 seconds for each operation
- Overall `/search` deadline: 30 seconds
- Eviction: oldest entries are removed when the cache exceeds its max size

When the cold-search queue is full, new unique CPE post-filter searches fail closed with HTTP 429 and a `SEARCH_QUEUE_FULL` response. Identical requests for a search that is already in flight still wait on the shared in-flight result and do not consume additional queue slots.

When a cold search waits in the queue for more than 10 seconds, it fails closed with HTTP 429 and a `SEARCH_QUEUE_TIMEOUT` response. OpenSearch operations use one attempt and return a sanitized HTTP 504 `SEARCH_TIMEOUT` response when they exceed 20 seconds. The same response is returned when a `/search` request exceeds the 30-second overall processing deadline, and the active OpenSearch request is aborted.

Healthy dev timing logs showed the impact for repeated identical `virtualMatchString` range searches:
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 token supplied to one CVSS vector filter must occur in the same eligible vector.

| Metric | Cold Cache | Warm Cache |
| ----------------------------- | ---------: | -----------------------: |
| Avg total handler time | 1709.34 ms | 11.72 ms |
| Median total handler time | 1701.51 ms | 7.68 ms |
| p95 total handler time | 2010.52 ms | 50.66 ms |
| Avg candidate OpenSearch time | 1435.57 ms | skipped |
| Avg post-filter time | 169.21 ms | skipped |
| Avg page fetch time | 104.25 ms | 11.53 ms |
| Avg candidates scanned | 10000 | cached from prior result |
| Avg filtered CVEs | 4 | 4 |
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.

This helps most when multiple users submit the same expensive CPE search within the TTL. The first request is a cold cache miss; later identical requests on the same running task are warm cache hits. If identical cold requests arrive concurrently before the first one finishes, the later requests wait on the same candidate query instead of starting duplicate 10,000-candidate OpenSearch requests. Disconnecting one waiter does not cancel work still needed by another waiter; the shared operation is aborted when its final waiter disconnects or times out. Each active request still fetches its requested page of full CVE records after the shared candidate result is available.
The API does not cache, coalesce, queue, or rate-limit searches in process. Each request executes its own required OpenSearch operations. Public rate enforcement, admission control, shared caching, and performance telemetry must be supplied by the deployment infrastructure when those capabilities are enabled.

The cache does not share entries across ECS tasks. With multiple tasks, each task warms independently depending on load balancer routing. Steady repeated traffic can warm every active task, but unique one-off searches still pay the cold-cache cost. The request lifecycle and in-flight waiter registry are separate from this result cache, so the cache can later be replaced with Redis or ElastiCache without changing cancellation behavior. The per-process TTL, entry limit, cold-search concurrency, queue depth, queue wait, OpenSearch timeout, and overall request deadline can be tuned with `SearchPostFilterCacheTtlMs`, `SearchPostFilterCacheMaxEntries`, `SearchPostFilterColdSearchConcurrency`, `SearchPostFilterColdSearchMaxQueueDepth`, `SearchPostFilterColdSearchMaxWaitMs`, `SearchOpenSearchRequestTimeoutMs`, and `SearchRequestDeadlineMs`.
OpenSearch operations use one attempt and return a sanitized HTTP 504 `SEARCH_TIMEOUT` response when they exceed the 20-second default timeout. The same response is returned when a `/search` request exceeds the 30-second default overall deadline, and the active OpenSearch request is aborted.

Docker runtime smoke checks can be run after building the image. The default `Dockerfile` avoids BuildKit `RUN --mount` syntax for AWS builders that do not support it:

Expand Down
23 changes: 0 additions & 23 deletions api-docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,29 +44,6 @@
"415": {
"description": "A non-empty request body did not use the application/json media type with UTF-8 encoding"
},
"429": {
"description": "Application rate limit exceeded, or the cold CPE search queue is full or exceeded its maximum wait",
"headers": {
"Retry-After": {
"description": "Seconds until a rate-limited request can be retried",
"schema": {
"type": "integer"
}
},
"RateLimit": {
"description": "Current application rate-limit state when application limiting is enabled",
"schema": {
"type": "string"
}
},
"RateLimit-Policy": {
"description": "Application rate-limit policy when application limiting is enabled",
"schema": {
"type": "string"
}
}
}
},
"503": {
"description": "OpenSearch is unavailable",
"headers": {
Expand Down
2 changes: 1 addition & 1 deletion api-docs/openapi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ describe('OpenAPI contract', () => {
expect(cpeName.description).toContain('virtualMatchString');
expect(cpeName.example).toBe('cpe:2.3:a:freetype:freetype:2.8.1:*:*:*:*:*:*:*');
expect(openapi.paths['/search'].post.responses['404'].description).toBe('Invalid cpeName value');
expect(openapi.paths['/search'].post.responses['429'].description).toContain('rate limit');
expect(openapi.paths['/search'].post.responses['429']).toBeUndefined();
});

test('documents fail-closed request and dependency responses for POST /search', () => {
Expand Down
13 changes: 0 additions & 13 deletions config/custom-environment-variables.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@
"index": "OpenSearchCveIndex",
// allows local development using containers that do not have SSL certs
"allowUnknownSslCerts": "OpenSearchAllowUnknownSslCerts",
"logPerformanceTimings": "SearchLogPerformanceTimings",
"postFilterCacheTtlMs": "SearchPostFilterCacheTtlMs",
"postFilterCacheMaxEntries": "SearchPostFilterCacheMaxEntries",
"postFilterColdSearchConcurrency": "SearchPostFilterColdSearchConcurrency",
"postFilterColdSearchMaxQueueDepth": "SearchPostFilterColdSearchMaxQueueDepth",
"postFilterColdSearchMaxWaitMs": "SearchPostFilterColdSearchMaxWaitMs",
"openSearchRequestTimeoutMs": "SearchOpenSearchRequestTimeoutMs",
"requestDeadlineMs": "SearchRequestDeadlineMs",
"readinessTimeoutMs": "SearchReadinessTimeoutMs"
Expand All @@ -28,13 +22,6 @@
"features": {
"webSearchEnabled": "WebSearchEnabled"
},
"rateLimiting": {
"search": {
"enabled": "SearchRateLimitEnabled",
"windowMs": "SearchRateLimitWindowMs",
"maxRequests": "SearchRateLimitMaxRequests"
}
},
// constants for testing node-config
// these values are only used to test node-config in AppConfig.test.int.ts
// DO NOT USE THIS FOR ANYTHING ELSE
Expand Down
21 changes: 0 additions & 21 deletions config/default.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,6 @@
// The only time this should be allowed to be true is when developing or testing
// using containers that do not have SSL certs
"allowUnknownSslCerts": "FALSE",
// set to TRUE temporarily when diagnosing slow /search requests
"logPerformanceTimings": "FALSE",
// CPE post-filter candidate result cache. These values are per Node.js process.
"postFilterCacheTtlMs": "60000",
"postFilterCacheMaxEntries": "100",
// Limits concurrent cold CPE candidate OpenSearch requests per Node.js process.
"postFilterColdSearchConcurrency": "10",
// Limits queued cold CPE candidate searches waiting behind the concurrency cap per Node.js process.
"postFilterColdSearchMaxQueueDepth": "500",
// Limits how long a cold CPE candidate search can wait in the per-process queue.
"postFilterColdSearchMaxWaitMs": "10000",
// Limits each request from the API process to OpenSearch.
"openSearchRequestTimeoutMs": "20000",
// Limits total processing time for a /search request.
Expand All @@ -43,16 +32,6 @@
"features": {
// Keep enabled until existing consumers have migrated to POST /search.
"webSearchEnabled": "TRUE"
},
// Application-level fallback protection. Infrastructure-level enforcement remains preferred.
"rateLimiting": {
"search": {
// Disabled until a per-task threshold is approved.
"enabled": "FALSE",
"windowMs": "60000",
// A positive value is required when enabled.
"maxRequests": "0"
}
}
}
}
Loading
Loading