From b921f3158b8ab77d675ee27e598030872bdce802 Mon Sep 17 00:00:00 2001 From: remib Date: Tue, 30 Jun 2026 17:31:47 +0200 Subject: [PATCH] feat: add evict-stale-nvd-cache BEFORE_DOWNLOAD worker Introduces a worker that evicts stale Artifactory remote-cache entries by comparing the NVD modified feed timestamp against the cached artifact's lastUpdated time, ensuring clients always fetch fresh vulnerability data. Co-Authored-By: Claude Sonnet 4.6 --- .../evict-stale-nvd-cache/README.md | 91 ++++++++++++ .../evict-stale-nvd-cache/manifest.json | 16 +++ .../evict-stale-nvd-cache/package.json | 42 ++++++ .../evict-stale-nvd-cache/tsconfig.json | 15 ++ .../evict-stale-nvd-cache/types.ts | 107 ++++++++++++++ .../evict-stale-nvd-cache/worker.spec.ts | 120 ++++++++++++++++ .../evict-stale-nvd-cache/worker.ts | 134 ++++++++++++++++++ 7 files changed, 525 insertions(+) create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/README.md create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/manifest.json create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/package.json create mode 100755 samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/tsconfig.json create mode 100755 samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/types.ts create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/worker.spec.ts create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/worker.ts diff --git a/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/README.md b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/README.md new file mode 100644 index 0000000..9482557 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/README.md @@ -0,0 +1,91 @@ +Evict Stale NVD Cache +===================== + +This worker is triggered by the `BEFORE_DOWNLOAD` event of Artifactory. It compares the last-modified timestamp of the [NVD modified feed](https://nvd.nist.gov/feeds/json/cve/2.0/nvdcve-2.0-modified.meta) against the cached artifact's `lastUpdated` time in Artifactory. When the NVD feed is newer, the cached entry is evicted so that the next request fetches fresh data from the remote. + +Functionality +------------- +- **NVD freshness check:** Fetches the NVD modified meta file and parses the `lastModifiedDate` field. +- **Cache staleness detection:** Queries Artifactory storage info for the cached artifact and compares timestamps. +- **Automatic eviction:** Deletes the cached artifact via the Artifactory API when the NVD feed is newer. +- **Error handling:** Issues a warning and allows the download to proceed when the NVD check cannot be completed. + +Worker Logic +------------ +1. Resolve the cache repository key (appends `-cache` if not already present). +2. Fetch the NVD modified meta file timestamp and the cached artifact's `lastUpdated` time in parallel. +3. If no cached artifact exists (404), proceed without eviction. +4. If the NVD feed timestamp is newer, delete the cached artifact and proceed — Artifactory will re-fetch from the remote. +5. If the cached artifact is still fresh, proceed without eviction. +6. On any error, return `DOWNLOAD_WARN` so the download still proceeds. + +Payload +------- +The worker operates on the `BEFORE_DOWNLOAD` event payload. It uses `metadata.repoPath` (falling back to `repoPath`) to identify the repository key and artifact path. Only remote repository downloads are evaluated; all others pass through immediately. + +Configuration +------------- +The worker targets remote repositories. Update the `filterCriteria.artifactFilterCriteria.repoKeys` in `manifest.json` to match the remote repositories containing NVD data: + +```json +"filterCriteria": { + "artifactFilterCriteria": { + "repoKeys": ["nvd-remote"] + } +} +``` + +Possible Responses +------------------ + +### Download Proceed — cache is fresh +```json +{ + "status": "DOWNLOAD_PROCEED", + "message": "Cached artifact is still fresh relative to NVD modified feed" +} +``` + +### Download Proceed — cache evicted +```json +{ + "status": "DOWNLOAD_PROCEED", + "message": "Evicted stale cache entry nvd-remote-cache/nvdcve-2.0-modified.json.gz because NVD modified feed is newer" +} +``` + +### Download Proceed — nothing to evict +```json +{ + "status": "DOWNLOAD_PROCEED", + "message": "No cached artifact at nvd-remote-cache/nvdcve-2.0-modified.json.gz; nothing to evict" +} +``` + +### Download Proceed — skipped (non-remote or folder) +```json +{ + "status": "DOWNLOAD_PROCEED", + "message": "Not a remote repo download; skipping NVD cache check" +} +``` + +### Warning Response +```json +{ + "status": "DOWNLOAD_WARN", + "message": "Could not verify NVD freshness; download proceeds with warning" +} +``` + +Error Handling +-------------- +- **NVD meta fetch failure:** Returns `DOWNLOAD_WARN`; download still proceeds. +- **Storage info fetch failure (non-404):** Returns `DOWNLOAD_WARN`; download still proceeds. +- **404 on storage info:** Treated as "nothing cached yet"; proceeds without eviction. + +Recommendations +--------------- +1. **Repo key filter:** Restrict `filterCriteria` to only the remote repos that cache NVD data to avoid unnecessary overhead. +2. **Monitoring:** Review logs for `DOWNLOAD_WARN` responses to detect recurring NVD connectivity issues. +3. **Testing:** Validate in a staging environment before deploying to production. diff --git a/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/manifest.json b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/manifest.json new file mode 100644 index 0000000..8fb23f7 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "evict-stale-nvd-cache", + "description": "This worker is triggered by the `BEFORE_DOWNLOAD` event of Artifactory. It compares the last-modified timestamp of the NVD modified feed against the cached artifact's last-updated time, and evicts the cached entry when the NVD feed is newer, ensuring downstream clients always fetch fresh vulnerability data.", + "filterCriteria": { + "artifactFilterCriteria": { + "repoKeys": [ + "example-repo-remote" + ] + } + }, + "secrets": {}, + "sourceCodePath": "./worker.ts", + "action": "BEFORE_DOWNLOAD", + "enabled": false, + "debug": true +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/package.json b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/package.json new file mode 100644 index 0000000..d9eaa49 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/package.json @@ -0,0 +1,42 @@ +{ + "name": "evict-stale-nvd-cache", + "description": "Run a script on BEFORE_DOWNLOAD", + "version": "1.0.0", + "scripts": { + "deploy": "jf worker deploy", + "undeploy": "jf worker rm \"evict-stale-nvd-cache\"", + "test": "jest" + }, + "license": "ISC", + "devDependencies": { + "jfrog-workers": "^0.8.0", + "@golevelup/ts-jest": "^0.4.0", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "jest-jasmine2": "^29.7.0", + "ts-jest": "^29.1.2" + }, + "jest": { + "moduleFileExtensions": [ + "ts", + "js" + ], + "rootDir": ".", + "testEnvironment": "node", + "clearMocks": true, + "maxConcurrency": 1, + "testRegex": "\\.spec\\.ts$", + "moduleDirectories": [ + "node_modules" + ], + "collectCoverageFrom": [ + "**/*.ts" + ], + "coverageDirectory": "../coverage", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "testRunner": "jest-jasmine2", + "verbose": true + } +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/tsconfig.json b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/tsconfig.json new file mode 100755 index 0000000..8005cfb --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "target": "es2017", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "allowJs": true + }, + "include": [ + "**/*.ts", + "node_modules/@types/**/*.d.ts" + ] +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/types.ts b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/types.ts new file mode 100755 index 0000000..d200f8c --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/types.ts @@ -0,0 +1,107 @@ + +export interface BeforeDownloadRequest { + /** Various immutable download metadata */ + metadata: + | DownloadMetadata + | undefined; + /** The immutable request headers */ + headers: { [key: string]: Header }; + /** The user context which sends the request */ + userContext: + | UserContext + | undefined; + /** The response repoPath */ + repoPath: RepoPath | undefined; +} + +export interface DownloadMetadata { + /** The repoPath object of the request */ + repoPath: + | RepoPath + | undefined; + /** The original repo path in case a virtual repo is involved */ + originalRepoPath: + | RepoPath + | undefined; + /** The file name from path */ + name: string; + /** Is it a head request */ + headOnly: boolean; + /** Is it a checksum request */ + checksum: boolean; + /** Is it a recursive request */ + recursive: boolean; + /** When a modification has occurred */ + modificationTime: number; + /** Is it a directory request */ + directoryRequest: boolean; + /** Is it a metadata request */ + metadata: boolean; + /** Last modification time that occurred */ + lastModified: number; + /** If a modification happened since the last modification time */ + ifModifiedSince: number; + /** The url that points to artifactory */ + servletContextUrl: string; + /** The request URI */ + uri: string; + /** The client address */ + clientAddress: string; + /** The resource path of the requested zip */ + zipResourcePath: string; + /** Is the request a zip resource request */ + zipResourceRequest: boolean; + /** should replace the head request with get */ + replaceHeadRequestWithGet: boolean; + /** Repository type */ + repoType: RepoType; +} + +export interface RepoPath { + /** The repo key */ + key: string; + /** The path itself */ + path: string; + /** The key:path combination */ + id: string; + /** Is the path the root */ + isRoot: boolean; + /** Is the path a folder */ + isFolder: boolean; +} + +export interface Header { + value: string[]; +} + +export interface UserContext { + /** The username or subject */ + id: string; + /** Is the context an accessToken */ + isToken: boolean; + /** The realm of the user */ + realm: string; +} + +export enum RepoType { + REPO_TYPE_UNSPECIFIED = 0, + REPO_TYPE_LOCAL = 1, + REPO_TYPE_REMOTE = 2, + REPO_TYPE_FEDERATED = 3, + UNRECOGNIZED = -1, +} + +export interface BeforeDownloadResponse { + /** The instruction of how to proceed */ + status: DownloadStatus; + /** Message to print to the log, in case of an error it will be printed as a warning */ + message: string; +} + +export enum DownloadStatus { + DOWNLOAD_UNSPECIFIED = 0, + DOWNLOAD_PROCEED = 1, + DOWNLOAD_STOP = 2, + DOWNLOAD_WARN = 3, + UNRECOGNIZED = -1, +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/worker.spec.ts b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/worker.spec.ts new file mode 100644 index 0000000..eb00752 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/worker.spec.ts @@ -0,0 +1,120 @@ +import { PlatformContext, PlatformClients, PlatformHttpClient } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BeforeDownloadRequest, DownloadStatus, RepoType } from './types'; +import runWorker from './worker'; + +const NVD_META_NEWER = + 'lastModifiedDate:2024-06-01T12:00:00.000+00:00\nfileSize:1234\n'; +const NVD_META_OLDER = + 'lastModifiedDate:2024-01-01T00:00:00.000+00:00\nfileSize:1234\n'; + +const ARTIFACT_LAST_UPDATED_RECENT = '2024-05-01T00:00:00.000+0000'; +const ARTIFACT_LAST_UPDATED_OLD = '2024-01-01T00:00:00.000+0000'; + +function makeContext( + axiosData: string, + storageData: object | null, + storageStatus = 200, +): DeepMocked { + const platformHttp = createMock({ + get: jest.fn().mockResolvedValue({ status: storageStatus, data: storageData }), + delete: jest.fn().mockResolvedValue({ status: 204 }), + }); + return createMock({ + clients: createMock({ + axios: { get: jest.fn().mockResolvedValue({ data: axiosData }) } as any, + platformHttp, + }), + }); +} + +function makeRequest(overrides: Partial = {}): BeforeDownloadRequest { + return { + repoPath: undefined, + headers: {}, + userContext: undefined, + metadata: { + repoPath: { key: 'nvd-remote', path: 'nvdcve-2.0-modified.json.gz', id: 'nvd-remote:nvdcve-2.0-modified.json.gz', isRoot: false, isFolder: false }, + repoType: RepoType.REPO_TYPE_REMOTE, + originalRepoPath: undefined, + name: 'nvdcve-2.0-modified.json.gz', + headOnly: false, + checksum: false, + recursive: false, + modificationTime: 0, + directoryRequest: false, + metadata: false, + lastModified: 0, + ifModifiedSince: 0, + servletContextUrl: '', + uri: '', + clientAddress: '', + zipResourcePath: '', + zipResourceRequest: false, + replaceHeadRequestWithGet: false, + }, + ...overrides, + }; +} + +describe('evict-stale-nvd-cache', () => { + it('proceeds without eviction when no cached artifact exists (404)', async () => { + const context = createMock({ + clients: createMock({ + axios: { get: jest.fn().mockResolvedValue({ data: NVD_META_NEWER }) } as any, + platformHttp: createMock({ + get: jest.fn().mockRejectedValue({ status: 404 }), + }), + }), + }); + const result = await runWorker(context, makeRequest()); + expect(result.status).toBe(DownloadStatus.DOWNLOAD_PROCEED); + expect(result.message).toMatch(/nothing to evict/i); + }); + + it('evicts cached artifact when NVD feed is newer', async () => { + const context = makeContext(NVD_META_NEWER, { lastUpdated: ARTIFACT_LAST_UPDATED_OLD }); + const result = await runWorker(context, makeRequest()); + expect(result.status).toBe(DownloadStatus.DOWNLOAD_PROCEED); + expect(result.message).toMatch(/evicted/i); + expect(context.clients.platformHttp.delete).toHaveBeenCalled(); + }); + + it('keeps cached artifact when it is still fresh', async () => { + const context = makeContext(NVD_META_OLDER, { lastUpdated: ARTIFACT_LAST_UPDATED_RECENT }); + const result = await runWorker(context, makeRequest()); + expect(result.status).toBe(DownloadStatus.DOWNLOAD_PROCEED); + expect(result.message).toMatch(/fresh/i); + expect(context.clients.platformHttp.delete).not.toHaveBeenCalled(); + }); + + it('returns DOWNLOAD_WARN when NVD fetch fails', async () => { + const context = createMock({ + clients: createMock({ + axios: { get: jest.fn().mockRejectedValue(new Error('network error')) } as any, + platformHttp: createMock(), + }), + }); + const result = await runWorker(context, makeRequest()); + expect(result.status).toBe(DownloadStatus.DOWNLOAD_WARN); + }); + + it('skips check for non-remote repos', async () => { + const context = makeContext(NVD_META_NEWER, { lastUpdated: ARTIFACT_LAST_UPDATED_OLD }); + const request = makeRequest(); + request.metadata!.repoType = RepoType.REPO_TYPE_LOCAL; + const result = await runWorker(context, request); + expect(result.status).toBe(DownloadStatus.DOWNLOAD_PROCEED); + expect(result.message).toMatch(/not a remote repo/i); + expect(context.clients.platformHttp.delete).not.toHaveBeenCalled(); + }); + + it('skips check for folder requests', async () => { + const context = makeContext(NVD_META_NEWER, { lastUpdated: ARTIFACT_LAST_UPDATED_OLD }); + const request = makeRequest(); + request.metadata!.repoPath!.isFolder = true; + const result = await runWorker(context, request); + expect(result.status).toBe(DownloadStatus.DOWNLOAD_PROCEED); + expect(result.message).toMatch(/not a file download/i); + }); +}); diff --git a/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/worker.ts b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/worker.ts new file mode 100644 index 0000000..75f3cf3 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/worker.ts @@ -0,0 +1,134 @@ +import { PlatformContext } from 'jfrog-workers'; +import { BeforeDownloadRequest, BeforeDownloadResponse, DownloadStatus, RepoType } from './types'; + +const NVD_MODIFIED_META_URL = + 'https://nvd.nist.gov/feeds/json/cve/2.0/nvdcve-2.0-modified.meta'; + +const REMOTE_CACHE_SUFFIX = '-cache'; + +interface StorageInfo { + lastUpdated?: string; +} + +function parseNvdLastModified(metaBody: string): number { + const match = metaBody.match(/^lastModifiedDate:(.+)$/m); + if (!match?.[1]) { + throw new Error('lastModifiedDate not found in NVD meta file'); + } + const parsed = Date.parse(match[1].trim()); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid lastModifiedDate: ${match[1]}`); + } + return parsed; +} + +function resolveCacheRepoKey(repoKey: string): string { + return repoKey.endsWith(REMOTE_CACHE_SUFFIX) + ? repoKey + : `${repoKey}${REMOTE_CACHE_SUFFIX}`; +} + +async function fetchNvdModifiedTimestamp(context: PlatformContext): Promise { + const res = await context.clients.axios.get(NVD_MODIFIED_META_URL, { + validateStatus: (status) => status >= 200 && status < 300, + }); + return parseNvdLastModified(String(res.data)); +} + +async function getCachedArtifactLastUpdated( + context: PlatformContext, + cacheRepoKey: string, + artifactPath: string, +): Promise { + try { + const res = await context.clients.platformHttp.get( + `/artifactory/api/storage/${cacheRepoKey}/${artifactPath}`, + ); + const info = res.data as StorageInfo; + if (!info.lastUpdated) { + return undefined; + } + const parsed = Date.parse(info.lastUpdated); + return Number.isNaN(parsed) ? undefined : parsed; + } catch (error: any) { + // 404 = nothing cached yet; nothing to evict + if (error.status === 404) { + return undefined; + } + throw error; + } +} + +export default async ( + context: PlatformContext, + data: BeforeDownloadRequest, +): Promise => { + const repoPath = data.metadata?.repoPath ?? data.repoPath; + + if (!repoPath?.key || !repoPath.path || repoPath.isFolder) { + return { + status: DownloadStatus.DOWNLOAD_PROCEED, + message: 'Not a file download; skipping NVD cache check', + headers: {}, + }; + } + + if (data.metadata?.repoType !== RepoType.REPO_TYPE_REMOTE) { + return { + status: DownloadStatus.DOWNLOAD_PROCEED, + message: 'Not a remote repo download; skipping NVD cache check', + headers: {}, + }; + } + + const cacheRepoKey = resolveCacheRepoKey(repoPath.key); + const artifactPath = repoPath.path; + + try { + const [nvdLastModified, artifactLastUpdated] = await Promise.all([ + fetchNvdModifiedTimestamp(context), + getCachedArtifactLastUpdated(context, cacheRepoKey, artifactPath), + ]); + console.log(`nvdLastModified = ${nvdLastModified}, artifactLastUpdated = ${artifactLastUpdated}`); + + if (artifactLastUpdated === undefined) { + console.log('nothing to evict'); + return { + status: DownloadStatus.DOWNLOAD_PROCEED, + message: `No cached artifact at ${cacheRepoKey}/${artifactPath}; nothing to evict`, + headers: {}, + }; + } + + if (nvdLastModified > artifactLastUpdated) { + console.log( + `NVD feed newer (${new Date(nvdLastModified).toISOString()}) than cached artifact ` + + `(${new Date(artifactLastUpdated).toISOString()}); evicting ${cacheRepoKey}/${artifactPath}`, + ); + + await context.clients.platformHttp.delete( + `/artifactory/${cacheRepoKey}/${artifactPath}`, + ); + + return { + status: DownloadStatus.DOWNLOAD_PROCEED, + message: `Evicted stale cache entry ${cacheRepoKey}/${artifactPath} because NVD modified feed is newer`, + headers: {}, + }; + } + + console.log('keeping cache'); + return { + status: DownloadStatus.DOWNLOAD_PROCEED, + message: 'Cached artifact is still fresh relative to NVD modified feed', + headers: {}, + }; + } catch (error: any) { + console.error(`NVD cache check failed: ${error.message ?? error}`); + return { + status: DownloadStatus.DOWNLOAD_WARN, + message: 'Could not verify NVD freshness; download proceeds with warning', + headers: {}, + }; + } +};