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
2 changes: 1 addition & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea AS deps

ENV NODE_ENV=production

Expand All @@ -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

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.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea AS deps

ENV NODE_ENV=production

Expand All @@ -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

Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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 <image>`. Use image-index digests that retain the required deployment and development architectures.
2. Run the selected images with `docker run --rm --network none <image>@sha256:<digest> --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 <built-image> --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.
2 changes: 1 addition & 1 deletion api-docs/openapi.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
27 changes: 19 additions & 8 deletions config/runtime-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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');
});
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
71 changes: 71 additions & 0 deletions server.test.js
Original file line number Diff line number Diff line change
@@ -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());
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 6 additions & 2 deletions test/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
Loading