Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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"
]
}
107 changes: 107 additions & 0 deletions samples/artifactory/BEFORE_DOWNLOAD/evict-stale-nvd-cache/types.ts
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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<PlatformContext> {
const platformHttp = createMock<PlatformHttpClient>({
get: jest.fn().mockResolvedValue({ status: storageStatus, data: storageData }),
delete: jest.fn().mockResolvedValue({ status: 204 }),
});
return createMock<PlatformContext>({
clients: createMock<PlatformClients>({
axios: { get: jest.fn().mockResolvedValue({ data: axiosData }) } as any,
platformHttp,
}),
});
}

function makeRequest(overrides: Partial<BeforeDownloadRequest> = {}): 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<PlatformContext>({
clients: createMock<PlatformClients>({
axios: { get: jest.fn().mockResolvedValue({ data: NVD_META_NEWER }) } as any,
platformHttp: createMock<PlatformHttpClient>({
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<PlatformContext>({
clients: createMock<PlatformClients>({
axios: { get: jest.fn().mockRejectedValue(new Error('network error')) } as any,
platformHttp: createMock<PlatformHttpClient>(),
}),
});
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);
});
});
Loading