diff --git a/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/README.md b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/README.md new file mode 100644 index 0000000..e1fb6db --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/README.md @@ -0,0 +1,205 @@ +# AI Blocked Download Explainer + +## Overview + +This worker listens to the `BEFORE_DOWNLOAD` event in JFrog Artifactory. It blocks the download +of any artifact whose existing JFrog Xray scan shows more critical security issues than allowed, +and asks **Claude** (Anthropic's API) to turn the blocking CVEs into a short, actionable +explanation, such as which version to upgrade to. That explanation is returned to the client in +the worker's `message`, and optionally posted to Slack, so the developer whose build just failed +knows why and what to do next, without opening the Xray UI. + +It extends the `BEFORE_DOWNLOAD/restrict-download-on-xray-critical-issues` sample, which blocks +with a static message. It's the blocking counterpart to the +`AFTER_DOWNLOAD/ai-download-risk-advisor` sample, which explains a download after the fact in Slack. + +## Design: rules decide, Claude explains + +`BEFORE_DOWNLOAD` runs on the download path, so the client waits for the worker. Two rules +follow from that: + +- **Claude never decides.** Whether a download is blocked is a fixed threshold on the Xray + critical issue count. Claude only writes the explanation, and the facts (artifact, count, + limit) in the message never come from the model. +- **Claude is only called when a download is blocked.** Allowed downloads never wait on an LLM. + On the blocked path, the call uses a fast model (`claude-haiku-4-5-20251001`) and a small + `max_tokens`. If it fails, the download is still blocked, with the static message. +- **No client-side timeout.** The Workers runtime rejects axios' `timeout` option, has no + `clearTimeout`, and fails an execution that leaves a pending promise behind (as a + `Promise.race` timer would). The Claude call is therefore bounded only by the platform's + execution time limit. Keep the model fast and `claudeMaxTokens` small. + +## Functionality + +1. **Trigger:** runs before a download is served by Artifactory. +2. **Xray lookup:** checks Xray is reachable, then looks up the artifact's existing scan result + (critical issue count) via the Xray API. It doesn't trigger a new scan. If Xray is + unavailable or has no result for the artifact, the download proceeds with a warning. +3. **Decision:** if the critical issue count is at or below `maxCriticalIssues`, the download + proceeds and Claude isn't called. +4. **Cost guard:** `HEAD` and checksum requests are blocked with the static message, without a + Claude call or a Slack notification. Package managers send many of these, and only the real + download needs an explanation. +5. **Details:** fetches the critical issues (up to 30) from the Xray artifact summary API: the CVE and + the issue summary, which states the affected version range (e.g. "jackson-databind before + 2.9.10"), plus the artifact's component id. If this fails, Claude explains from the counts alone. +6. **AI explanation:** sends those details to Claude, asking for at most two plain-text + sentences. The answer is flattened to a single line and appended to the block message. +7. **Fallback:** if the `AnthropicApiKey` secret is missing, or the Claude call fails, the + download is still blocked with the static message. +8. **Slack notification:** if the `Slack_URL` secret is set, the block message (with or without + the AI explanation) is posted to Slack with the blocked user or token. Artifactory returns the + message in the body of an HTTP 409 error, and many clients only print the status code, so + Slack is where the explanation is reliably seen. + +## Required secrets + +| Secret Name | Description | Required | +| ----------------- | ------------------------------------------------------------------- | -------- | +| `AnthropicApiKey` | Anthropic API key used to call Claude (`https://api.anthropic.com`) | No: without it, blocked downloads get the static message | +| `Slack_URL` | Incoming webhook URL for the Slack channel to notify of blocked downloads | No: without it, nothing is posted to Slack | + +```sh +jf worker add-secret AnthropicApiKey +jf worker add-secret Slack_URL +``` + +## Configuration + +These are declared as Worker **properties** in `manifest.json` (`context.properties.get(...)` in +`worker.ts`), so they can be tuned per deployment without touching the code. Each also has a +code-level default, used if the property is removed or unset: + +| Property | Default | Purpose | +| ------------------- | --------------------------------------- | ---------------------------------------------------------------- | +| `maxCriticalIssues` | `0` | Block when the artifact has more critical issues than this | +| `claudeModel` | `claude-haiku-4-5-20251001` | Fast model, since the client is waiting | +| `claudeMaxTokens` | `200` | The explanation is two sentences, keep this small | +| `claudeApiUrl` | `https://api.anthropic.com/v1/messages` | Anthropic Messages API endpoint; override to point at a proxy | +| `claudeApiVersion` | `2023-06-01` | Anthropic API version header | + +The worker is filtered to the repositories listed in `manifest.json` under +`filterCriteria.artifactFilterCriteria.repoKeys`. Update that list to match the repositories +you want covered. + +## Claude walkthrough (example) + +Say a developer downloads `libs-release-local/com/fasterxml/jackson/core/jackson-databind/2.9.8/jackson-databind-2.9.8.jar`, +which Xray already scanned with 14 critical security issues. + +The worker sends Claude a prompt along these lines (issue summaries are cut to 300 characters): + +``` +A developer's download of an artifact from JFrog Artifactory was just blocked by a security +policy. Their package manager will print your answer as the error message. In at most 2 short +sentences and plain text (no markdown, no line breaks), tell them the main reason and the most +useful next step. Only recommend a specific version if it is above every affected version range +listed below (a range "through X" includes X); otherwise tell them to upgrade to the latest +release. Do not invent CVEs or versions that are not listed below. + +Artifact: jackson-databind-2.9.8.jar +Policy: at most 0 critical security issues allowed +Critical security issues found: 14 +Details: +- Component: com.fasterxml.jackson.core:jackson-databind:2.9.8 +- CVE-2019-14379: SubTypeValidator.java in FasterXML jackson-databind before 2.9.9.2 mishandles default typing when ehcache is used (...), leading to remote code execution. +- CVE-2019-16335: A Polymorphic Typing issue was discovered in FasterXML jackson-databind before 2.9.10. It is related to com.zaxxer.hikari.HikariDataSource. (...) +- CVE-2019-14540: A Polymorphic Typing issue was discovered in FasterXML jackson-databind before 2.9.10. It is related to com.zaxxer.hikari.HikariConfig. +- ... +- CVE-2020-9546: FasterXML jackson-databind 2.x before 2.9.10.4 mishandles the interaction between serialization gadgets and typing, (...) +- ... +``` + +The worker then returns (Claude's part generated by `claude-haiku-4-5`; the wording varies between calls): + +```json +{ + "status": "DOWNLOAD_STOP", + "message": "DOWNLOAD STOPPED: libs-release-local/com/fasterxml/jackson/core/jackson-databind/2.9.8/jackson-databind-2.9.8.jar has 14 critical security issues (limit 0). Your jackson-databind version 2.9.8 has 14 critical vulnerabilities related to unsafe deserialization that could allow remote code execution. Upgrade to jackson-databind 2.9.10.4 or later to resolve all identified issues.", + "headers": {} +} +``` + +The first sentence of `message` is always produced by the worker itself; only the part after it +comes from Claude. + +The client receives it as an HTTP 409 error: + +```json +{ + "errors" : [ { + "status" : 409, + "message" : "BEFORE_DOWNLOAD Worker Event Error: DOWNLOAD STOPPED: ... Upgrade to jackson-databind 2.9.10.4 or later to resolve all identified issues." + } ] +} +``` + +`curl` prints this body; many clients, including `jf rt download`, only print the 409 status. +That's why the same message is also posted to Slack when `Slack_URL` is set. + +## Running this sample end-to-end + +### Prerequisites + +- A JFrog Platform instance with **Artifactory** and **Xray** enabled, and an artifact Xray has + already scanned with at least one critical security issue. +- [JFrog CLI](https://jfrog.com/getcli/) installed and configured against that instance. +- An [Anthropic API key](https://console.anthropic.com/) with access to the Messages API. + +### Steps + +1. Configure the JFrog CLI server if you haven't already: + ```sh + jf config add + ``` +2. Edit `manifest.json`'s `filterCriteria.artifactFilterCriteria.repoKeys` to point at the repo + containing your test artifact, and set `"enabled"` to `true` (samples ship disabled). +3. From this directory, add the secrets and deploy the worker: + ```sh + jf worker add-secret AnthropicApiKey + jf worker add-secret Slack_URL + jf worker deploy + ``` +4. Download the test artifact, e.g. `curl -u : /artifactory//`. + The download fails with HTTP 409, the response body contains the explanation, and the same + message is posted to Slack. +5. Check the Workers execution log (Platform UI → Administration → Workers → this worker → Logs), + or `jf worker exec-history ai-blocked-download-explainer`. +6. To see the fallback, remove the `AnthropicApiKey` secret and download again: the download is + still blocked, with the static message only. + +Run the unit tests with: + +```sh +npm install +npm test +``` + +## Response messages + +- `"Artifact has critical security issues (limit ): proceed with the download."` (`DOWNLOAD_PROCEED`) +- `"DOWNLOAD STOPPED: / has critical security issues (limit ). "` (`DOWNLOAD_STOP`) +- `"DOWNLOAD STOPPED: / has critical security issues (limit )."` (`DOWNLOAD_STOP`, no key, Claude failure, `HEAD` or checksum request) +- `"Could not check for Xray scans because Xray is not available. Download will proceed with warning."` (`DOWNLOAD_WARN`) +- `"Could not find an Xray scan result matching this artifact. Download will proceed with warning."` (`DOWNLOAD_WARN`) +- `"Error during scan check. Download will proceed with warning."` (`DOWNLOAD_WARN`) + +## Error scenarios + +- **Xray unavailable or no scan result:** the download proceeds with a warning, as in + `restrict-download-on-xray-critical-issues`. Change these `warn(...)` calls to `stop(...)` if + your policy is to fail closed. +- **Xray summary API failure:** logged; Claude explains from the issue count only. +- **Claude API failure** (bad key, rate limit, overload): logged; the download is still blocked + with the static message. +- **Slack webhook failure:** logged as a warning; the download is still blocked with the same + message. + +## Dependencies + +This worker relies on: +- `PlatformContext` (`context.clients.platformHttp`, `context.clients.axios`, `context.secrets`, + `context.properties`) from the `jfrog-workers` package. +- Xray REST APIs: `GET /xray/api/v1/artifacts` and `POST /xray/api/v1/summary/artifact`. +- Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages), called directly over + HTTPS via `context.clients.axios`; no Anthropic SDK dependency needed. diff --git a/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/manifest.json b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/manifest.json new file mode 100644 index 0000000..7c66544 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "ai-blocked-download-explainer", + "description": "This worker is triggered by the `BEFORE_DOWNLOAD` event of Artifactory. It blocks downloads of artifacts whose Xray scan shows more critical security issues than allowed, and asks Claude (Anthropic API) to turn the blocking CVEs into a short, actionable explanation returned to the client and optionally posted to Slack.", + "filterCriteria": { + "artifactFilterCriteria": { + "repoKeys": [ + "example-repo-local" + ] + } + }, + "secrets": {}, + "properties": { + "maxCriticalIssues": "0", + "claudeModel": "claude-haiku-4-5-20251001", + "claudeMaxTokens": "200", + "claudeApiUrl": "https://api.anthropic.com/v1/messages", + "claudeApiVersion": "2023-06-01" + }, + "sourceCodePath": "./worker.ts", + "action": "BEFORE_DOWNLOAD", + "enabled": false, + "debug": true +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/package.json b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/package.json new file mode 100644 index 0000000..597f7dc --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/package.json @@ -0,0 +1,43 @@ +{ + "name": "ai-blocked-download-explainer", + "description": "Run a script on BEFORE_DOWNLOAD", + "version": "1.0.0", + "scripts": { + "deploy": "jf worker deploy", + "undeploy": "jf worker rm \"ai-blocked-download-explainer\"", + "test": "jest" + }, + "license": "ISC", + "devDependencies": { + "jfrog-workers": "^0.19.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", + "typescript": "^5.4.0" + }, + "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 + } +} \ No newline at end of file diff --git a/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/sample-payload.json b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/sample-payload.json new file mode 100644 index 0000000..ab1ec5a --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/sample-payload.json @@ -0,0 +1,21 @@ +{ + "metadata": { + "repoPath": { + "key": "example-repo-local", + "path": "com/acme/payments/2.3.0/payments-2.3.0.jar", + "id": "example-repo-local:com/acme/payments/2.3.0/payments-2.3.0.jar", + "isRoot": false, + "isFolder": false + }, + "name": "payments-2.3.0.jar", + "headOnly": false, + "checksum": false, + "repoType": 1 + }, + "headers": {}, + "userContext": { + "id": "alice", + "isToken": false, + "realm": "internal" + } +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/tsconfig.json b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/tsconfig.json new file mode 100755 index 0000000..8005cfb --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/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/ai-blocked-download-explainer/types.ts b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/types.ts new file mode 100755 index 0000000..e913f11 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/types.ts @@ -0,0 +1,109 @@ + +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; + /** Response headers to add/override, if any */ + headers: { [key: string]: Header }; +} + +export enum DownloadStatus { + DOWNLOAD_UNSPECIFIED = 0, + DOWNLOAD_PROCEED = 1, + DOWNLOAD_STOP = 2, + DOWNLOAD_WARN = 3, + UNRECOGNIZED = -1, +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/worker.spec.ts b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/worker.spec.ts new file mode 100644 index 0000000..d57bd43 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/worker.spec.ts @@ -0,0 +1,215 @@ +import { PlatformContext, PlatformClients, PlatformHttpClient, PlatformSecrets, PlatformProperties } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { BeforeDownloadRequest, DownloadStatus } from './types'; +import runWorker from './worker'; + +const PING_OK = { status: 200, data: { status: 'pong' } }; +const CLAUDE_URL = 'https://api.anthropic.com/v1/messages'; +const SLACK_URL = 'https://hooks.slack.test/services/xyz'; +const BLOCKED = 'DOWNLOAD STOPPED: example-repo-local/com/acme/payments-2.3.0.jar has 2 critical security issues (limit 0).'; + +function scanWith(critical: number) { + return { + status: 200, + data: { + data: [{ + name: 'payments-2.3.0.jar', + repo_full_path: 'example-repo-local/com/acme/payments-2.3.0.jar', + sec_issues: { critical, high: 0, medium: 0, low: 0, total: critical }, + violations: 0 + }], + offset: 0 + } + }; +} + +const SUMMARY = { + status: 200, + data: { + artifacts: [{ + general: { component_id: 'com.fasterxml.jackson.core:jackson-databind:2.9.8' }, + issues: [ + { issue_id: 'XRAY-88210', summary: 'A Polymorphic Typing issue was discovered in FasterXML jackson-databind before 2.9.10.', severity: 'Critical', cves: [{ cve: 'CVE-2019-16335' }] }, + { issue_id: 'XRAY-2', summary: 'Low severity issue', severity: 'Low' } + ] + }] + } +}; + +function buildRequest(overrides: any = {}): DeepMocked { + return createMock({ + metadata: { + repoPath: { key: 'example-repo-local', path: 'com/acme/payments-2.3.0.jar' }, + headOnly: false, + checksum: false, + ...overrides + } as any, + userContext: { id: 'alice', isToken: false } as any + }); +} + +function buildContext(critical: number): DeepMocked { + return createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockImplementation((endpoint: string) => + Promise.resolve(endpoint.includes('/system/ping') ? PING_OK : scanWith(critical))), + post: jest.fn().mockResolvedValue(SUMMARY) + }), + axios: createMock({ + post: jest.fn().mockResolvedValue({ status: 200, data: { content: [{ type: 'text', text: 'Upgrade jackson-databind\nto 2.9.10 or later.' }] } }) + }) + }), + secrets: createMock({ + get: jest.fn().mockImplementation((key: string) => key === 'AnthropicApiKey' ? 'sk-ant-test' : undefined) + }), + properties: createMock({ + get: jest.fn().mockReturnValue(undefined) + }) + }); +} + +describe('ai-blocked-download-explainer tests', () => { + it('warns and proceeds when Xray is not available', async () => { + const context = buildContext(2); + context.clients.platformHttp.get = jest.fn().mockRejectedValue(new Error('unreachable')); + + const result = await runWorker(context, buildRequest()); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_WARN); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + }); + + it('warns and proceeds when no matching Xray scan result is found', async () => { + const context = buildContext(2); + context.clients.platformHttp.get = jest.fn().mockImplementation((endpoint: string) => + Promise.resolve(endpoint.includes('/system/ping') ? PING_OK : { status: 200, data: { data: [], offset: 0 } })); + + const result = await runWorker(context, buildRequest()); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_WARN); + }); + + it('proceeds without calling Claude when under the limit', async () => { + const context = buildContext(0); + + const result = await runWorker(context, buildRequest()); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_PROCEED); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + }); + + it('blocks with the Claude explanation appended, on one line', async () => { + const context = buildContext(2); + + await expect(runWorker(context, buildRequest())).resolves.toEqual({ + status: DownloadStatus.DOWNLOAD_STOP, + message: `${BLOCKED} Upgrade jackson-databind to 2.9.10 or later.`, + headers: {} + }); + expect(context.clients.platformHttp.post).toHaveBeenCalledWith('/xray/api/v1/summary/artifact', { paths: ['default/example-repo-local/com/acme/payments-2.3.0.jar'] }); + const [url, body, options] = (context.clients.axios.post as jest.Mock).mock.calls[0]; + expect(url).toBe(CLAUDE_URL); + expect(body.model).toBe('claude-haiku-4-5-20251001'); + expect(body.messages[0].content).toContain('CVE-2019-16335: A Polymorphic Typing issue was discovered in FasterXML jackson-databind before 2.9.10.'); + expect(body.messages[0].content).toContain('Component: com.fasterxml.jackson.core:jackson-databind:2.9.8'); + expect(body.messages[0].content).not.toContain('Low severity issue'); + expect(options).toEqual({ headers: expect.objectContaining({ 'x-api-key': 'sk-ant-test' }) }); // the Workers runtime rejects axios' 'timeout' option + }); + + it('sends every critical issue to Claude, not just the first few', async () => { + const context = buildContext(8); + context.clients.platformHttp.post = jest.fn().mockResolvedValue({ + status: 200, + data: { artifacts: [{ issues: Array.from({ length: 8 }, (_, i) => ({ issue_id: `XRAY-${i}`, summary: `issue ${i}`, severity: 'Critical', cves: [{ cve: `CVE-2019-000${i}` }] })) }] } + }); + + await runWorker(context, buildRequest()); + + expect((context.clients.axios.post as jest.Mock).mock.calls[0][1].messages[0].content).toContain('CVE-2019-0007: issue 7'); + }); + + it('still blocks with the static message when Claude fails', async () => { + const context = buildContext(2); + context.clients.axios.post = jest.fn().mockRejectedValue(new Error('Request failed with status code 529')); + + await expect(runWorker(context, buildRequest())).resolves.toEqual({ status: DownloadStatus.DOWNLOAD_STOP, message: BLOCKED, headers: {} }); + }); + + it('still asks Claude from counts only when the Xray summary fails', async () => { + const context = buildContext(2); + context.clients.platformHttp.post = jest.fn().mockRejectedValue(new Error('500')); + + const result = await runWorker(context, buildRequest()); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_STOP); + expect((context.clients.axios.post as jest.Mock).mock.calls[0][1].messages[0].content).toContain('No further details available.'); + }); + + it('blocks with the static message when AnthropicApiKey is not configured', async () => { + const context = buildContext(2); + context.secrets.get = jest.fn().mockReturnValue(undefined); + + await expect(runWorker(context, buildRequest())).resolves.toEqual({ status: DownloadStatus.DOWNLOAD_STOP, message: BLOCKED, headers: {} }); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + }); + + it('does not call Claude or Slack for HEAD or checksum requests', async () => { + for (const overrides of [{ headOnly: true }, { checksum: true }]) { + const context = buildContext(2); + context.secrets.get = jest.fn().mockImplementation((key: string) => + ({ AnthropicApiKey: 'sk-ant-test', Slack_URL: SLACK_URL } as any)[key]); + + await expect(runWorker(context, buildRequest(overrides))).resolves.toEqual({ status: DownloadStatus.DOWNLOAD_STOP, message: BLOCKED, headers: {} }); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + } + }); + + it('posts the block message with the Claude explanation to Slack', async () => { + const context = buildContext(2); + context.secrets.get = jest.fn().mockImplementation((key: string) => + ({ AnthropicApiKey: 'sk-ant-test', Slack_URL: SLACK_URL } as any)[key]); + + const result = await runWorker(context, buildRequest()); + + expect(result.message).toBe(`${BLOCKED} Upgrade jackson-databind to 2.9.10 or later.`); + expect(context.clients.axios.post).toHaveBeenCalledWith(SLACK_URL, { text: `*Download blocked* for User alice\n${result.message}` }); + }); + + it('posts the static block message to Slack when Claude fails', async () => { + const context = buildContext(2); + context.secrets.get = jest.fn().mockImplementation((key: string) => + ({ AnthropicApiKey: 'sk-ant-test', Slack_URL: SLACK_URL } as any)[key]); + context.clients.axios.post = jest.fn().mockImplementation((url: string) => + url === CLAUDE_URL ? Promise.reject(new Error('Request failed with status code 529')) : Promise.resolve({ status: 200 })); + + await expect(runWorker(context, buildRequest())).resolves.toEqual({ status: DownloadStatus.DOWNLOAD_STOP, message: BLOCKED, headers: {} }); + expect(context.clients.axios.post).toHaveBeenCalledWith(SLACK_URL, { text: `*Download blocked* for User alice\n${BLOCKED}` }); + }); + + it('still blocks when the Slack post fails', async () => { + const context = buildContext(2); + context.secrets.get = jest.fn().mockImplementation((key: string) => key === 'Slack_URL' ? SLACK_URL : undefined); + context.clients.axios.post = jest.fn().mockRejectedValue(new Error('Request failed with status code 404')); + + await expect(runWorker(context, buildRequest())).resolves.toEqual({ status: DownloadStatus.DOWNLOAD_STOP, message: BLOCKED, headers: {} }); + }); + + it('falls back to property defaults when context.properties.get throws', async () => { + const context = buildContext(2); + context.properties.get = jest.fn().mockImplementation(() => { throw new Error('unknown property key'); }); + + const result = await runWorker(context, buildRequest()); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_STOP); + }); + + it('honours the maxCriticalIssues property', async () => { + const context = buildContext(2); + context.properties.get = jest.fn().mockImplementation((key: string) => key === 'maxCriticalIssues' ? '2' : undefined); + + const result = await runWorker(context, buildRequest()); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_PROCEED); + }); +}); diff --git a/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/worker.ts b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/worker.ts new file mode 100644 index 0000000..98562d5 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/ai-blocked-download-explainer/worker.ts @@ -0,0 +1,206 @@ +import { PlatformContext } from 'jfrog-workers'; +import { BeforeDownloadRequest, BeforeDownloadResponse, DownloadStatus } from './types'; + +export default async (context: PlatformContext, data: BeforeDownloadRequest): Promise => { + // Configurable via the worker's "properties" (manifest.json / Platform UI); fall back to these defaults if unset + // or if the platform errors out on an unknown property key instead of returning undefined. + const maxCriticalIssues = parseInt(getProperty(context, 'maxCriticalIssues', '0'), 10); + const claudeModel = getProperty(context, 'claudeModel', 'claude-haiku-4-5-20251001'); // fast model: this call is on the download path + const claudeMaxTokens = parseInt(getProperty(context, 'claudeMaxTokens', '200'), 10); + const claudeApiUrl = getProperty(context, 'claudeApiUrl', 'https://api.anthropic.com/v1/messages'); + const claudeApiVersion = getProperty(context, 'claudeApiVersion', '2023-06-01'); + + if (!data.metadata?.repoPath) { + return warn('Missing repoPath metadata on the download request. Download will proceed with warning.'); + } + + const isXrayAvailable = await checkIfXrayAvailable(context); + if (!isXrayAvailable) { + return warn('Could not check for Xray scans because Xray is not available. Download will proceed with warning.'); + } + + const repoPath = data.metadata.repoPath.path; + const repoKey = data.metadata.repoPath.key; + const artifactName = repoPath.slice(repoPath.lastIndexOf('/') + 1); + const expectedRepoFullPath = `${repoKey}/${repoPath}`; + + let critical: number; + try { + const res = await context.clients.platformHttp.get(`/xray/api/v1/artifacts?repo=${repoKey}&search=${artifactName}&num_of_rows=100`); + const responseData: XrayArtifactsSearchResponse = res.data; + const scanResult = responseData?.data?.find(artifact => artifact.repo_full_path === expectedRepoFullPath); + if (!scanResult) { + return warn('Could not find an Xray scan result matching this artifact. Download will proceed with warning.'); + } + critical = scanResult.sec_issues?.critical || 0; + } catch (error: any) { + console.error(`Xray artifact search failed: ${error.message}`); + return warn('Error during scan check. Download will proceed with warning.'); + } + + // The decision is deterministic: Claude never decides whether a download is blocked, it only explains it. + if (critical <= maxCriticalIssues) { + return { status: DownloadStatus.DOWNLOAD_PROCEED, message: `Artifact has ${critical} critical security issues (limit ${maxCriticalIssues}): proceed with the download.`, headers: {} }; + } + + const blockedMessage = `DOWNLOAD STOPPED: ${expectedRepoFullPath} has ${critical} critical security issues (limit ${maxCriticalIssues}).`; + + // Clients send HEAD and checksum requests too; only spend a Claude call on the actual download. + if (data.metadata.headOnly || data.metadata.checksum) { + return stop(blockedMessage); + } + + let message = blockedMessage; + const anthropicApiKey = context.secrets.get('AnthropicApiKey'); + if (!anthropicApiKey) { + console.warn('AnthropicApiKey secret not configured; returning the static block message.'); + } else { + try { + const issues = await getCriticalIssues(context, repoKey, repoPath); + const explanation = await askClaudeToExplainBlock(context, anthropicApiKey, claudeModel, claudeMaxTokens, claudeApiUrl, claudeApiVersion, artifactName, critical, maxCriticalIssues, issues); + message = `${blockedMessage} ${explanation}`; + } catch (error: any) { + console.error(`Failed to generate AI explanation: ${error.message}`); + } + } + + // Many clients only show the HTTP status of a blocked download, so also send the explanation to Slack. + await notifySlack(context, `${data.userContext?.isToken ? 'Token' : 'User'} ${data.userContext?.id}`, message); + return stop(message); +} + +async function notifySlack(context: PlatformContext, blockedFor: string, message: string): Promise { + const slackWebhookUrl = context.secrets.get('Slack_URL'); + if (!slackWebhookUrl) { + return; + } + try { + await context.clients.axios.post(slackWebhookUrl, { text: `*Download blocked* for ${blockedFor}\n${message}` }); + } catch (error: any) { + console.warn(`Failed to post block notification to Slack: ${error.message}`); + } +} + +function warn(message: string): BeforeDownloadResponse { + return { status: DownloadStatus.DOWNLOAD_WARN, message, headers: {} }; +} + +function stop(message: string): BeforeDownloadResponse { + return { status: DownloadStatus.DOWNLOAD_STOP, message, headers: {} }; +} + +// Some platform versions throw instead of returning undefined when a property key isn't set yet +// (server-side bug, fixed but not deployed everywhere) - treat that the same as "unset". +function getProperty(context: PlatformContext, key: string, defaultValue: string): string { + try { + return context.properties.get(key) || defaultValue; + } catch (error: any) { + console.warn(`Failed to read property '${key}', using default '${defaultValue}': ${error.message}`); + return defaultValue; + } +} + +async function checkIfXrayAvailable(context: PlatformContext): Promise { + try { + const response = await context.clients.platformHttp.get('/xray/api/v1/system/ping'); + return response.data?.status === 'pong'; + } catch (error: any) { + console.log(`Encountered error ${error.message} while checking for Xray readiness.`); + return false; + } +} + +// The artifacts search only returns counts; the summary API gives the CVEs, and each issue summary states the +// affected version range (e.g. "jackson-databind before 2.9.10"), which is what Claude needs to suggest an upgrade. +// Best effort: Claude can still explain from counts alone. +async function getCriticalIssues(context: PlatformContext, repoKey: string, repoPath: string): Promise { + try { + const res = await context.clients.platformHttp.post('/xray/api/v1/summary/artifact', { paths: [`default/${repoKey}/${repoPath}`] }); + const summary: XrayArtifactSummaryResponse = res.data; + const artifact = summary?.artifacts?.[0]; + const issues = (artifact?.issues || []) + .filter(issue => issue.severity === 'Critical') + // All of them: an upgrade advised from a partial list can still be vulnerable. Capped only to bound the prompt. + .slice(0, 30) + .map(issue => { + const cves = (issue.cves || []).map(c => c.cve).filter(Boolean).join(', '); + return `${cves || issue.issue_id}: ${(issue.summary || '').slice(0, 300)}`; + }); + const componentId = artifact?.general?.component_id; + return componentId && issues.length ? [`Component: ${componentId}`, ...issues] : issues; + } catch (error: any) { + console.warn(`Xray artifact summary failed, explaining from counts only: ${error.message}`); + return []; + } +} + +async function askClaudeToExplainBlock( + context: PlatformContext, + anthropicApiKey: string, + claudeModel: string, + claudeMaxTokens: number, + claudeApiUrl: string, + claudeApiVersion: string, + artifactName: string, + critical: number, + maxCriticalIssues: number, + issues: string[] +): Promise { + const prompt = `A developer's download of an artifact from JFrog Artifactory was just blocked by a security policy. Their package manager will print your answer as the error message. In at most 2 short sentences and plain text (no markdown, no line breaks), tell them the main reason and the most useful next step. Only recommend a specific version if it is above every affected version range listed below (a range "through X" includes X); otherwise tell them to upgrade to the latest release. Do not invent CVEs or versions that are not listed below.\n\nArtifact: ${artifactName}\nPolicy: at most ${maxCriticalIssues} critical security issues allowed\nCritical security issues found: ${critical}\n${issues.length ? `Details:\n- ${issues.join('\n- ')}` : 'No further details available.'}`; + + // No client-side timeout: the Workers runtime rejects axios' 'timeout' option and has no clearTimeout, + // and a pending timer promise left by Promise.race fails the execution. Latency is bounded by the fast + // model, the small max_tokens, and the platform's execution time limit. + const res = await context.clients.axios.post(claudeApiUrl, { + model: claudeModel, + max_tokens: claudeMaxTokens, + messages: [{ role: 'user', content: prompt }] + }, { + headers: { + 'x-api-key': anthropicApiKey, + 'anthropic-version': claudeApiVersion, + 'content-type': 'application/json' + } + }); + + const text = res.data?.content?.[0]?.text; + if (!text) { + throw new Error('Claude response did not contain any text content.'); + } + // The message ends up in a client error output: keep it on one line. + return text.replace(/\s+/g, ' ').trim(); +} + +// Declared here rather than in types.ts: 'jf worker deploy' only uploads worker.ts and compiles it +// against the platform's own event types. +interface XrayArtifact { + name: string; + repo_path: string; + repo_full_path: string; + sec_issues: { + critical: number; + high: number; + medium: number; + low: number; + total: number; + }; + violations: number; +} + +interface XrayArtifactsSearchResponse { + data: XrayArtifact[]; + offset: number; +} + +/** Subset of POST /xray/api/v1/summary/artifact used by this worker */ +interface XrayArtifactSummaryResponse { + artifacts: Array<{ + general?: { component_id?: string }; + issues: Array<{ + issue_id: string; + summary: string; + severity: string; + cves?: Array<{ cve?: string }>; + }>; + }>; +}