diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 95eff65..551ee9b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 24.14.1 + node-version: 24.22.1 cache: npm - name: Install dependencies diff --git a/Dockerfile b/Dockerfile index 8914334..577595d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea AS deps ENV NODE_ENV=production @@ -16,7 +13,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:cb606325b7ec4af55462cb0b424a53b452a5b4a894ffe841fcf1048d216fb9f8 AS runtime ENV NODE_ENV=production diff --git a/Dockerfile.local b/Dockerfile.local index 65345fb..fe32040 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -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.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea AS deps ENV NODE_ENV=production @@ -21,7 +18,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:cb606325b7ec4af55462cb0b424a53b452a5b4a894ffe841fcf1048d216fb9f8 AS runtime ENV NODE_ENV=production diff --git a/README.md b/README.md index d8361ab..e3bbd4a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - [Operational Logging](#operational-logging) - [Semantic Post-Filtering](#semantic-post-filtering) - [Docker Runtime Checks](#docker-runtime-checks) + - [Updating Pinned Base Images](#updating-pinned-base-images) ## The CVE Search API Project @@ -77,7 +78,7 @@ The compose file runs only the Search API with `NODE_ENV=production` and `NODE_C ## Deploying with npm -Use Node.js 24 for the repository's current runtime and development tooling. CI and the Docker dependency-build stage use Node.js 24.14.1; the final runtime image uses Distroless Node.js 24. +Use Node.js 24.22.1 for the repository's current runtime and development tooling. CI and the Docker dependency-build stage use this patch version; the final runtime uses a verified Distroless image containing Node.js 24.22.1. Both Dockerfiles pin their builder and runtime images by digest so separate builds use the same base-image contents for a given architecture. The API depends directly on the official OpenSearch JavaScript client. `utils/searchProvider.js` owns its configuration and reuses one client per process for both endpoints and readiness checks. Query construction remains separate: `utils/searchQueryBuilder.js` implements `/search`, and `utils/webSearchQueryBuilder.js` implements `/webSearch`. @@ -390,3 +391,18 @@ docker build -f Dockerfile.local --secret id=npm_ca,src=/path/to/ca.crt -t cve-s ``` The final runtime image uses Google Distroless Node, so it does not include a shell, package manager, or command lookup tools. + +### Updating Pinned Base Images + +`Dockerfile` and `Dockerfile.local` use identical, digest-pinned Node.js builder and Distroless runtime images. The digests identify multi-architecture image indexes supporting AMD64 and ARM64. Each platform selects its own fixed image from that index; pinning does not make the images for different architectures identical. + +The builder tag includes its Node.js patch version, but the Distroless tag names only the major version. Verify the runtime's actual patch version rather than inferring it from the builder tag. A digest fixes the entire base image, including its operating-system packages. Rebuilding, even with `--pull`, does not advance a pinned digest or automatically install newer security patches. + +When updating the base images: + +1. Select supported, patched builder and Distroless images and inspect their registry manifests with `docker buildx imagetools inspect `. Use image-index digests that retain the required deployment and development architectures. +2. Run the selected images with `docker run --rm --network none @sha256: --version` to confirm their Node.js patch versions. Verify each architecture used in deployment or development; matching major-version tags alone are insufficient. +3. Update both `FROM` references in both Dockerfiles together. If the Node.js patch version changes, also update `.github/workflows/quality.yml`, the version assertions in `config/runtime-config.test.js`, and this README. +4. Run `npm run quality`, build both Dockerfiles, and confirm the completed images report the intended version with `docker run --rm --network none --version`. Perform the runtime health checks above and scan the production image before deployment. + +Deployments must rebuild and roll out the updated application images to receive the change. Restarting containers from an older application image does not update Node.js. Base-image pinning controls the base-image inputs; it does not guarantee that independently built application images have identical digests. diff --git a/api-docs/openapi.json b/api-docs/openapi.json index c67d91d..407f6bf 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.2", "info": { - "version": "1.0.2-beta", + "version": "1.0.3-beta", "title": "CVE Search API", "description": "CVE Search API to retrieve all matching CVE records after applying request inputs as filters.\n\nResponsible Use Guide: [https://cveproject.github.io/search-api-responsible-use](https://cveproject.github.io/search-api-responsible-use \"https://cveproject.github.io/search-api-responsible-use\")", "contact": { diff --git a/config/runtime-config.test.js b/config/runtime-config.test.js index c94173e..703e4f5 100644 --- a/config/runtime-config.test.js +++ b/config/runtime-config.test.js @@ -6,12 +6,28 @@ function readProjectFile(...segments) { } describe('runtime container and compose configuration', () => { + test.each(['Dockerfile', 'Dockerfile.local'])('%s pins both base images by digest', (filePath) => { + const dockerfile = readProjectFile(filePath); + + expect(dockerfile).toMatch(/^FROM node:24\.22\.1-trixie-slim@sha256:[a-f0-9]{64} AS deps$/m); + expect(dockerfile).toMatch( + /^FROM gcr\.io\/distroless\/nodejs24-debian13:nonroot@sha256:[a-f0-9]{64} AS runtime$/m + ); + expect(dockerfile.match(/^FROM .+$/gm)).toHaveLength(2); + }); + + test('local and production builds use the same base images and CI uses the builder patch version', () => { + const dockerfile = readProjectFile('Dockerfile'); + const localDockerfile = readProjectFile('Dockerfile.local'); + const workflow = readProjectFile('.github/workflows/quality.yml'); + + expect(localDockerfile.match(/^FROM .+$/gm)).toEqual(dockerfile.match(/^FROM .+$/gm)); + expect(workflow).toContain('node-version: 24.22.1'); + }); + test('Dockerfile keeps AWS-compatible production runtime hardening invariants', () => { const dockerfile = readProjectFile('Dockerfile'); - expect(dockerfile).toContain('ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot'); - expect(dockerfile).toContain('FROM node:${NODE_VERSION} AS deps'); - expect(dockerfile).toContain('FROM ${DISTROLESS_NODE_IMAGE} AS runtime'); expect(dockerfile).toMatch(/^RUN npm ci --omit=dev \\/m); expect(dockerfile).toMatch(/^COPY --chown=65532:65532 config \.\/config\//m); expect(dockerfile).toMatch(/^COPY --chown=65532:65532 controllers \.\/controllers\//m); @@ -23,7 +39,6 @@ describe('runtime container and compose configuration', () => { expect(dockerfile).toMatch(/^USER 65532:65532$/m); expect(dockerfile).toContain('CMD ["server.js"]'); expect(dockerfile).not.toContain('COPY . .'); - expect(dockerfile).not.toContain('node:${NODE_VERSION}-bookworm-slim'); expect(dockerfile).not.toContain('apt-get'); expect(dockerfile).not.toContain('docker-entrypoint.sh'); expect(dockerfile).not.toContain('--mount=type=secret'); @@ -33,9 +48,6 @@ describe('runtime container and compose configuration', () => { test('local Dockerfile keeps npm CA secret mount for local builds', () => { const dockerfile = readProjectFile('Dockerfile.local'); - expect(dockerfile).toContain('ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs24-debian13:nonroot'); - expect(dockerfile).toContain('FROM node:${NODE_VERSION} AS deps'); - expect(dockerfile).toContain('FROM ${DISTROLESS_NODE_IMAGE} AS runtime'); expect(dockerfile).toMatch(/^RUN --mount=type=secret,id=npm_ca,required=false/m); expect(dockerfile).toContain('if [ -s /run/secrets/npm_ca ]; then'); expect(dockerfile).toContain('NPM_CONFIG_CAFILE=/run/secrets/npm_ca npm ci --omit=dev'); @@ -48,7 +60,6 @@ describe('runtime container and compose configuration', () => { expect(dockerfile).toMatch(/^USER 65532:65532$/m); expect(dockerfile).toContain('CMD ["server.js"]'); expect(dockerfile).not.toContain('COPY . .'); - expect(dockerfile).not.toContain('node:${NODE_VERSION}-bookworm-slim'); expect(dockerfile).not.toContain('apt-get'); expect(dockerfile).not.toContain('docker-entrypoint.sh'); }); diff --git a/package-lock.json b/package-lock.json index 1414d2e..65a3a35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cve-search-api", - "version": "1.0.2-beta", + "version": "1.0.3-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cve-search-api", - "version": "1.0.2-beta", + "version": "1.0.3-beta", "dependencies": { "@opensearch-project/opensearch": "2.13.0", "config": "4.4.1", @@ -1683,9 +1683,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1696,7 +1696,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" diff --git a/package.json b/package.json index 2a55f46..1a4b7b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cve-search-api", - "version": "1.0.2-beta", + "version": "1.0.3-beta", "scripts": { "dev": "node routes/swagger.js && nodemon server.js", "coverage": "jest --coverage", diff --git a/server.test.js b/server.test.js index 7b0bcac..9972c00 100644 --- a/server.test.js +++ b/server.test.js @@ -1,4 +1,5 @@ const http = require('http'); +const { deflateSync, gzipSync } = require('zlib'); const { request } = require('./test/http'); const mockValidateParams = jest.fn((req, res, next) => next()); @@ -146,6 +147,76 @@ describe('server application bootstrap', () => { }); }); + test.each([ + ['/search', 'gzip', gzipSync], + ['/search', 'deflate', deflateSync], + ['/webSearch', 'gzip', gzipSync], + ['/webSearch', 'deflate', deflateSync] + ])('parses valid compressed JSON at %s with %s encoding', async (path, encoding, compress) => { + const body = path === '/search' ? { keywordSearch: 'apache' } : { query: 'apache' }; + const response = await request(createApp(), { + method: 'POST', + path, + rawBody: compress(JSON.stringify(body)), + headers: { 'Content-Type': 'application/json', 'Content-Encoding': encoding } + }); + + expect(response.status).toBe(200); + expect(path === '/search' ? response.body.params : response.body.body).toEqual(expect.objectContaining(body)); + }); + + test.each(['/search', '/webSearch'])( + 'does not crash when trailing bytes arrive after decoding a compressed body at %s', + async (path) => { + const app = createApp(); + const body = path === '/search' ? { keywordSearch: 'apache' } : { query: 'apache' }; + const first = Buffer.concat([gzipSync(JSON.stringify(body)), Buffer.from([0])]); + const trailing = Buffer.alloc(100 * 1024, 0x58); + let client; + + const controller = path === '/search' ? mockSearch : mockWebSearch; + controller.mockImplementationOnce((req, res) => { + req.once('end', () => res.json(req.body)); + // Send the remaining bytes only after the parser hands off to the route. + client.end(trailing); + }); + + const response = await new Promise((resolve, reject) => { + const server = app.listen(0, '127.0.0.1', () => { + client = http.request( + { + hostname: '127.0.0.1', + port: server.address().port, + method: 'POST', + path, + agent: false, + headers: { + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + 'Content-Length': first.length + trailing.length + } + }, + (res) => { + let text = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (text += chunk)); + res.on('end', () => { + server.close(() => resolve({ status: res.statusCode, text })); + }); + } + ); + client.on('error', (err) => server.close(() => reject(err))); + client.setTimeout(2000, () => client.destroy(new Error('Compressed request did not finish.'))); + client.write(first); + }); + }); + + expect(response.status).toBe(200); + expect(JSON.parse(response.text)).toStrictEqual(body); + expect((await request(app, { path: '/api/health-check' })).status).toBe(200); + } + ); + test('createApp returns a sanitized error for malformed JSON', async () => { const response = await request(createApp(), { method: 'POST', diff --git a/test/http.js b/test/http.js index 716afe9..44a0386 100644 --- a/test/http.js +++ b/test/http.js @@ -15,8 +15,12 @@ function hasHeader(headers, name) { function request(app, { method = 'GET', path, body, rawBody, headers = {} }) { return new Promise((resolve, reject) => { const server = app.listen(0, () => { - const payload = - rawBody !== undefined ? String(rawBody) : body !== undefined ? JSON.stringify(body) : undefined; + let payload; + if (rawBody !== undefined) { + payload = Buffer.isBuffer(rawBody) ? rawBody : String(rawBody); + } else if (body !== undefined) { + payload = JSON.stringify(body); + } const requestHeaders = { ...headers }; if (payload !== undefined) { if (rawBody === undefined && !hasHeader(requestHeaders, 'content-type')) {