Skip to content
Closed
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
19 changes: 19 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5

- package-ecosystem: docker
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
48 changes: 48 additions & 0 deletions .github/workflows/opensearch-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: OpenSearch Contract

on:
workflow_dispatch:
inputs:
environment:
description: Protected GitHub environment containing the OpenSearch contract-test secrets
required: true
type: environment

permissions:
contents: read

concurrency:
group: search-api-opensearch-contract-${{ inputs.environment }}
cancel-in-progress: false

jobs:
contract:
name: Read-only OpenSearch Contract
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: ${{ inputs.environment }}

env:
NODE_ENV: production
NODE_CONFIG_ENV: prod

steps:
- name: Check out repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.14.1
cache: npm

- name: Install production dependencies
run: npm ci --omit=dev

- name: Run read-only contract checks
run: npm run test:opensearch-contract
env:
OpenSearchDomainEndpoint: ${{ secrets.OPENSEARCH_DOMAIN_ENDPOINT }}
OpenSearchCveIndex: ${{ secrets.OPENSEARCH_CVE_INDEX }}
OpenSearchAllowUnknownSslCerts: ${{ vars.OPENSEARCH_ALLOW_UNKNOWN_SSL_CERTS || 'false' }}
50 changes: 49 additions & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,36 @@ name: Search API Quality

on:
pull_request:
push:
branches:
- dev
- main
merge_group:

permissions:
contents: read

concurrency:
group: search-api-quality-${{ github.event.pull_request.number }}
group: search-api-quality-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
dependency-review:
name: Dependency Review
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Check out repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1

- name: Review dependency changes
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
fail-on-severity: high
fail-on-scopes: runtime, development

quality:
name: Search API Quality
runs-on: ubuntu-latest
Expand Down Expand Up @@ -44,6 +65,33 @@ jobs:
- name: Build production image
run: docker build --tag cve-search-api:pr .

- name: Report all high and critical image vulnerabilities
continue-on-error: true
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: image
image-ref: cve-search-api:pr
format: table
exit-code: 0
ignore-unfixed: false
severity: CRITICAL,HIGH
scanners: vuln
trivyignores: /dev/null
version: v0.69.3

- name: Enforce fixable high and critical image vulnerabilities
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: image
image-ref: cve-search-api:pr
format: table
exit-code: 1
ignore-unfixed: true
severity: CRITICAL,HIGH
scanners: vuln
trivyignores: .trivyignore
version: v0.69.3

- name: Start production image
run: |
docker run --detach \
Expand Down
3 changes: 3 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# package=adm-zip owner=cve-core fixed=0.6.0 review-by=2026-10-31
# cve-core transitively pins adm-zip below 0.6.0. Remediation belongs in cve-core.
CVE-2026-39244
7 changes: 2 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
# syntax=docker/dockerfile:1

ARG NODE_VERSION=24.14.1
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot

FROM node:${NODE_VERSION} AS deps
FROM node:24.14.1@sha256:80fc934952c8f1b2b4d39907af7211f8a9fff1a4c2cf673fb49099292c251cec AS deps

ENV NODE_ENV=production \
NODE_CONFIG_ENV=prod
Expand All @@ -17,7 +14,7 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev \
&& npm cache clean --force

FROM ${DISTROLESS_NODE_IMAGE} AS runtime
FROM gcr.io/distroless/nodejs24-debian13:nonroot@sha256:af85d11ce7ef10172855a6e3649e3e8125b1b9e3ca41849ec2918036f05cb212 AS runtime

ENV NODE_ENV=production \
NODE_CONFIG_ENV=prod
Expand Down
7 changes: 2 additions & 5 deletions Dockerfile.local
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
# syntax=docker/dockerfile:1

ARG NODE_VERSION=24.14.1
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot

FROM node:${NODE_VERSION} AS deps
FROM node:24.14.1@sha256:80fc934952c8f1b2b4d39907af7211f8a9fff1a4c2cf673fb49099292c251cec AS deps

ENV NODE_ENV=production \
NODE_CONFIG_ENV=prod
Expand All @@ -22,7 +19,7 @@ RUN --mount=type=secret,id=npm_ca,required=false \
fi \
&& npm cache clean --force

FROM ${DISTROLESS_NODE_IMAGE} AS runtime
FROM gcr.io/distroless/nodejs24-debian13:nonroot@sha256:af85d11ce7ef10172855a6e3649e3e8125b1b9e3ca41849ec2918036f05cb212 AS runtime

ENV NODE_ENV=production \
NODE_CONFIG_ENV=prod
Expand Down
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Requests that exceed a limit return HTTP 400 before contacting OpenSearch. These

CVSS vector filters accept full or partial vectors containing at least one unique `KEY:VALUE` metric token. An optional vector header must identify a version supported by the selected parameter. Requested metric tokens can appear in any order, but all tokens must occur in the same stored vector string. CVSS and CWE filters search CNA-provided data only; ADP-provided enrichment is not currently included.

Publication and last-modified date filters support one-sided ranges. When both values are supplied, `pubStartDate` must be less than or equal to `pubEndDate`, and `lastModStartDate` must be less than or equal to `lastModEndDate`.
Publication and last-modified date filters support one-sided ranges. Values must use the exact UTC format `yyyy-MM-ddTHH:mm:ss.SSSZ`, including three fractional-second digits and the trailing `Z`. When both values are supplied, `pubStartDate` must be less than or equal to `pubEndDate`, and `lastModStartDate` must be less than or equal to `lastModEndDate`.

### Search Ordering and Pagination

Expand Down Expand Up @@ -285,7 +285,7 @@ While enabled, `POST /webSearch` requires a non-empty JSON `query` string. It al

## Swagger API docs

Swagger API docs are automatically generated on `docker compose` or `npm run dev`, and can be regenerated manually using:
The runtime container and Docker Compose serve the committed `api-docs/openapi.json` artifact. They do not regenerate it during image startup. `npm run dev` regenerates the artifact before starting the development server, and it can be regenerated manually using:

```bash
npm run swagger-autogen
Expand All @@ -310,25 +310,37 @@ npm run coverage
npm run quality
```

Pull requests run the quality gate and independently build the production `Dockerfile`. The container job starts the built image, checks process liveness and the packaged OpenAPI document, and verifies the runtime user and command. It intentionally does not call `/api/ready-check` or `/search`, because pull-request CI does not own or provision an OpenSearch index.
Pull requests, merge-queue validation, and pushes to `dev` or `main` run the quality gate and independently build the production `Dockerfile`. The container job reports all high and critical image vulnerabilities without blocking, then rejects fixed high and critical vulnerabilities that are not covered by an approved exception. It starts the image, checks process liveness and the packaged OpenAPI document, and verifies the runtime user and command. It intentionally does not call `/api/ready-check` or `/search`, because the standard CI workflow does not own or provision an OpenSearch index.

Pull requests also run GitHub dependency review and reject newly introduced high or critical vulnerabilities in runtime or development dependencies. The blocking production image scan excludes only `CVE-2026-39244`, an `adm-zip` advisory inherited from the separately owned `cve-core` dependency. The exception records its package owner, fixed version, and review deadline in `.trivyignore`; repository tests fail after that deadline or if the dependency provenance changes. The non-blocking scan still reports the exception and other unfixed severe findings. New advisories are not covered by the exception.

An opt-in, read-only OpenSearch contract check is available for an approved environment that can reach an existing index:

```bash
npm run test:opensearch-contract
```

Set `OpenSearchDomainEndpoint` and `OpenSearchCveIndex` in the environment or local `.env` before running it. `OpenSearchAllowUnknownSslCerts` remains optional and should be enabled only for local development against a self-signed certificate. The contract check sends a minimal readiness query and one single-hit search, validates the response shape, closes its OpenSearch client, and emits sanitized JSON output. It never creates, updates, maps, or deletes indexes.
Set `OpenSearchDomainEndpoint` and `OpenSearchCveIndex` in the environment or local `.env` before running it. `OpenSearchAllowUnknownSslCerts` remains optional and should be enabled only for local development against a self-signed certificate. The contract check sends a minimal readiness query, validates representative field capabilities, requires a concrete CVE record, and uses that record for a positive CVE-ID search before running the remaining bounded query shapes. It closes its OpenSearch client and emits sanitized JSON output. It never creates, updates, maps, or deletes indexes.

The contract check is not part of public pull-request CI. The `OpenSearch Contract` GitHub Actions workflow runs it only through a manual dispatch against a selected protected GitHub environment. Configure that environment with:

- `OPENSEARCH_DOMAIN_ENDPOINT` secret: the approved OpenSearch endpoint, including deployment-managed credentials when required
- `OPENSEARCH_CVE_INDEX` secret: the existing CVE index name
- `OPENSEARCH_ALLOW_UNKNOWN_SSL_CERTS` variable: optional and normally `false`; use `true` only for an approved non-production endpoint with a self-signed certificate

The contract check is not part of public pull-request CI. A future automated invocation should use a protected GitHub environment or self-hosted runner with network access to an approved test index. Connection values and credentials must remain in environment secrets rather than source files or workflow text.
The workflow can run only from the trusted `dev` or `main` branch. The selected GitHub environment should additionally require reviewers and restrict deployment branches. OpenSearch secrets are exposed only to the final contract execution step, after checkout, Node setup, and dependency installation have completed.

The selected runner must have network access to the endpoint. Change the workflow to an approved self-hosted runner before use when the index is not reachable from GitHub-hosted runners. The workflow performs readiness, field-capability, positive source-projection, exact-total, CVE ID keyword-subfield, keyword, publication-date, CVSS, and CPE candidate checks. Every search is read-only and returns at most one record. Connection values and credentials remain in environment secrets rather than source files or workflow text.

The validation tiers have separate purposes:

- Jest and `npm run quality` verify application behavior, API contracts, and regression coverage with controlled test doubles.
- Pull-request container validation verifies production-image packaging, non-root execution, startup, liveness, and packaged API documentation.
- The optional OpenSearch contract check verifies that the installed client can communicate with an externally managed index using the response contract expected by this application.
- The optional OpenSearch contract check verifies that the installed client can communicate with a non-empty externally managed index whose representative searchable fields satisfy the application contract.
- Load and performance testing remain in the separate automation performance-testing repository.

Dependabot checks npm packages, Docker base-image digests, and GitHub Actions weekly. Both Dockerfiles pin the Node build image and Distroless runtime image to immutable multi-architecture digests; Dependabot updates those references through reviewable pull requests.

### 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.
Expand Down Expand Up @@ -423,4 +435,4 @@ When building locally on a network that requires a corporate or self-signed CA b
docker build -f Dockerfile.local --secret id=npm_ca,src=/path/to/ca.crt -t cve-search-api:runtime-smoke .
```

The final runtime image uses Google Distroless Node, so it does not include a shell, package manager, or command lookup tools.
The final runtime image uses Google Distroless Node, so it does not include a shell, package manager, or command lookup tools. Both build stages are pinned by multi-architecture digest and are updated through Dependabot rather than mutable registry tags.
10 changes: 7 additions & 3 deletions api-docs/openapi.json

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

13 changes: 13 additions & 0 deletions api-docs/openapi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ describe('OpenAPI contract', () => {
expect(schema.properties.cvssV3Metrics.description).toContain('at least one unique KEY:VALUE token');
expect(schema.properties.pubStartDate.description).toContain('less than or equal');
expect(schema.properties.lastModEndDate.description).toContain('less than or equal');
['lastModStartDate', 'lastModEndDate', 'pubStartDate', 'pubEndDate'].forEach((field) => {
expect(schema.properties[field].format).toBe('date-time');
expect(schema.properties[field].pattern).toBe(constants.DATE_TIME_PATTERN);
});
expect(Object.keys(mediaType.examples)).toStrictEqual([
'allParameters',
'keywordSearch',
Expand Down Expand Up @@ -180,6 +184,15 @@ describe('OpenAPI contract', () => {
expect(openapi.paths['/search'].post.responses['429'].description).toContain('waiter limit');
});

test('describes POST /search as a paginated result operation', () => {
const operation = openapi.paths['/search'].post;

expect(operation.summary).toContain('a page of CVE records');
expect(operation.description).toContain('one page of matching CVE records');
expect(openapi.info.description).toContain('paginated CVE records');
expect(operation.summary).not.toContain('all matching CVE records');
});

test('documents fail-closed request and dependency responses for POST /search', () => {
const responses = openapi.paths['/search'].post.responses;

Expand Down
Loading
Loading