From a19f4b52440507b840babd6759f11298fc13c5d8 Mon Sep 17 00:00:00 2001 From: hervee Date: Mon, 28 Sep 2026 08:54:00 +0200 Subject: [PATCH] WKS-2791 - Add AI download risk advisor AFTER_DOWNLOAD sample Looks up an artifact's existing Xray scan result on AFTER_DOWNLOAD and, if issues or violations are found, asks Claude to turn the raw counts into a short risk summary posted to Slack. Falls back to raw stats if the AnthropicApiKey secret is missing or the Claude call fails. --- .../ai-download-risk-advisor/README.md | 197 ++++++++++++++++++ .../ai-download-risk-advisor/manifest.json | 23 ++ .../ai-download-risk-advisor/package.json | 43 ++++ .../sample-payload.json | 17 ++ .../ai-download-risk-advisor/tsconfig.json | 15 ++ .../ai-download-risk-advisor/types.ts | 113 ++++++++++ .../ai-download-risk-advisor/worker.spec.ts | 176 ++++++++++++++++ .../ai-download-risk-advisor/worker.ts | 141 +++++++++++++ 8 files changed, 725 insertions(+) create mode 100644 samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/README.md create mode 100644 samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/manifest.json create mode 100644 samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/package.json create mode 100644 samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/sample-payload.json create mode 100644 samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/tsconfig.json create mode 100644 samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/types.ts create mode 100644 samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/worker.spec.ts create mode 100644 samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/worker.ts diff --git a/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/README.md b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/README.md new file mode 100644 index 0000000..c1d0d60 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/README.md @@ -0,0 +1,197 @@ +# AI Download Risk Advisor + +## Overview + +This worker listens to the `AFTER_DOWNLOAD` event in JFrog Artifactory. When an artifact is +downloaded, it looks up that artifact's existing JFrog Xray scan results (security issues and +policy violations) and, if any are found, asks **Claude** (Anthropic's API) to turn the raw +counts into a short, plain-English risk summary and recommendation. The summary is posted to a +Slack channel so whoever owns the download (a developer, a release manager, a security reviewer) +gets a readable heads-up instead of having to interpret raw Xray JSON. + +It's the informational counterpart to the `BEFORE_DOWNLOAD/restrict-download-on-xray-critical-issues` +sample: that one *blocks* a download based on a hard-coded threshold; this one never blocks +anything, it *explains* what was just downloaded, using an LLM to make the explanation useful to a +non-security audience. + +## Why AFTER_DOWNLOAD and not BEFORE_DOWNLOAD + +Calling an LLM API takes anywhere from a few hundred milliseconds to a few seconds. Doing that on +the `BEFORE_DOWNLOAD` path would add real latency to every download. Running it `AFTER_DOWNLOAD` +means the download itself is never slowed down or blocked by an LLM call — the advisory shows up +in Slack a moment later. + +## Functionality + +1. **Trigger:** runs after a download completes in Artifactory. +2. **Xray lookup:** checks Xray is reachable, then looks up the downloaded artifact's existing + scan result (security issues by severity + policy violations) via the Xray API. It does not + trigger a new scan. +3. **Cost guard:** if the artifact has zero known security issues and zero violations, the worker + stops here — no Claude call, no Slack message. There's nothing worth an AI summary for a clean + artifact. +4. **AI summary:** if there's anything to report, the raw counts are sent to Claude with a prompt + asking for a short risk summary and a recommendation. If the `AnthropicApiKey` secret isn't + configured, or the Claude call fails, the worker falls back to posting the raw counts instead + of losing the information. +5. **Slack delivery:** the resulting text (AI summary, or the raw-count fallback) is posted to a + Slack channel via an incoming webhook, if one is configured. + +## Required secrets + +| Secret Name | Description | Required | +| ----------------- | -------------------------------------------------------------------- | -------- | +| `AnthropicApiKey` | Anthropic API key used to call Claude (`https://api.anthropic.com`) | Yes — without it the worker falls back to raw stats, no AI summary | +| `Slack_URL` | Incoming webhook URL for the Slack channel to post advisories to | No — without it the worker only logs the result | + +Add them with the JFrog CLI: + +```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 ever removed or left unset: + +| Property | Default | Purpose | +| ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------- | +| `minIssuesForAiSummary` | `1` | Skip the AI call below this many issues+violations combined | +| `claudeModel` | `claude-sonnet-5` | Swap for `claude-haiku-4-5-20251001` to cut cost | +| `claudeMaxTokens` | `300` | Response is a short summary, 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 | + +Edit the values directly in `manifest.json`'s `"properties"` object and redeploy (`jf worker deploy`). + +Property reads go through a small `getProperty` helper in `worker.ts` that catches any error from +`context.properties.get(...)` (some platform versions throw instead of returning `undefined` for a +property key that isn't set yet) and returns the default in that case too — so a missing or +not-yet-recognized property degrades to the default rather than failing the whole worker. + +The worker is filtered to the repositories listed in `manifest.json` under +`filterCriteria.artifactFilterCriteria.repoKeys` — update that list (or broaden it) to match the +repositories you want covered. + +## How it works + +1. **Download event:** the worker receives the downloaded artifact's repo key, path, and the + user/token that downloaded it. +2. **Xray availability check:** pings `/xray/api/v1/system/ping`. If Xray isn't available, the + worker logs that and stops — there's no scan data to advise on. +3. **Scan lookup:** calls `/xray/api/v1/artifacts?repo=&search=` and matches the + result whose `repo_full_path` equals the downloaded artifact's full path (a name search can + return multiple artifacts sharing that file name, e.g. every tag of a Docker image). +4. **Decide whether to call Claude:** sums `sec_issues` (critical/high/medium/low) and + `violations`. Zero on both means no AI call. +5. **Ask Claude:** calls the Messages API (`POST https://api.anthropic.com/v1/messages`) with a + short prompt describing the artifact and its issue counts, asking for a 2-3 sentence summary + plus a one-line recommendation. +6. **Post to Slack:** sends the resulting text as a Slack message via the configured incoming + webhook. +7. **Log the outcome:** the worker's return `message` always reflects what actually happened, so + it shows up in the Workers execution log even if Slack/Claude are not configured. + +## Claude walkthrough (example) + +Say `libs-release-local/com/acme/payments/2.3.0/payments-2.3.0.jar` is downloaded, and Xray +already scanned it with 2 critical, 1 high security issue and 1 license violation. + +The worker sends Claude a prompt along these lines: + +``` +An artifact was just downloaded from JFrog Artifactory. Summarize the risk for someone who is +not a security expert, in 2-3 sentences, then give one line of recommended action. + +Artifact: payments-2.3.0.jar +Repository: libs-release-local +Security issues: 2 critical, 1 high, 0 medium, 0 low +Policy violations: 1 +``` + +A typical Claude response: + +``` +This artifact has 2 critical and 1 high-severity security issues, plus a policy violation +(likely a license or compliance rule) flagged by Xray. Critical issues mean there are known, +serious vulnerabilities in this version that could be exploited if it reaches production. + +Recommendation: Hold off on deploying this version — check the Xray report for CVE details and +upgrade to a patched version before using it further. +``` + +That text is what gets posted to the configured Slack channel, prefixed with the artifact and +downloader identity so the channel has full context without needing to open Artifactory. + +## Running this sample end-to-end + +### Prerequisites + +- A JFrog Platform instance with **Artifactory** and **Xray** enabled, and at least one artifact + in a repo that Xray has already scanned with at least one security issue or violation (needed + to exercise the AI path — a clean artifact will just short-circuit at step 3 above). +- [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. +- (Optional, to see the Slack delivery) a Slack [incoming webhook URL](https://api.slack.com/messaging/webhooks) + for a test channel. + +### Steps + +1. Configure the JFrog CLI server if you haven't already: + ```sh + jf config add + ``` +2. From this directory, initialize/deploy the worker (or copy this folder's files into a worker + created via `jf worker init` if you're starting fresh): + ```sh + jf worker deploy + ``` +3. Add the secrets: + ```sh + jf worker add-secret AnthropicApiKey + jf worker add-secret Slack_URL + ``` +4. Edit `manifest.json`'s `filterCriteria.artifactFilterCriteria.repoKeys` to point at the repo + containing your already-Xray-scanned test artifact, and re-deploy (`jf worker deploy`) if you + changed it after step 2. +5. Set `manifest.json`'s `"enabled"` to `true` (samples ship disabled by default) and deploy again. +6. Download the test artifact (e.g. `jf rt download /` or via the UI). +7. Check the result: + - Workers execution log (Platform UI → Administration → Workers → this worker → Logs), or + `jf worker exec-history `, should show the `message` the worker returned. + - The Slack channel behind `Slack_URL` should receive the AI-generated risk summary within a + few seconds. +8. To see the fallback paths, temporarily remove/rename the `AnthropicApiKey` secret and download + again — the worker should post the raw issue counts to Slack instead of an AI summary, and say + so in its log message. + +## Response messages + +- `"No security issues or violations found for this artifact; skipped AI risk advisory."` +- `"AnthropicApiKey secret not configured; posted raw Xray stats instead of an AI summary."` +- `"AI risk summary generated and posted to Slack."` +- `"AI risk summary generated; Slack_URL not configured, logged only."` +- `"Failed to generate AI risk summary; posted raw Xray stats to Slack instead."` +- `"Could not check for Xray scans because Xray is not available."` +- `"Could not find an Xray scan result matching this artifact."` + +## Error scenarios + +- **Xray unavailable:** the worker logs this and exits without calling Claude or Slack. +- **No matching scan result:** the artifact's Xray scan hasn't completed yet, or the repo search + didn't return a match for the exact path — the worker logs this and exits. +- **Claude API failure** (bad key, rate limit, network error): the worker logs the error and + falls back to posting the raw Xray counts to Slack, so the advisory still goes out. +- **Slack webhook failure:** logged as a warning; it doesn't affect the worker's own success. + +## Dependencies + +This worker relies on: +- `PlatformContext` (`context.clients.platformHttp`, `context.clients.axios`, `context.secrets`) + from the `jfrog-workers` package. +- `AfterDownloadRequest` / `AfterDownloadResponse` for the event payload and response shape. +- 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/AFTER_DOWNLOAD/ai-download-risk-advisor/manifest.json b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/manifest.json new file mode 100644 index 0000000..1cc38e5 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "ai-download-risk-advisor", + "description": "This worker is triggered by the `AFTER_DOWNLOAD` event of Artifactory. It looks up the downloaded artifact's existing Xray scan result and, if issues or violations are found, asks Claude (Anthropic API) to turn the raw counts into a short risk summary posted to Slack.", + "filterCriteria": { + "artifactFilterCriteria": { + "repoKeys": [ + "example-repo-local" + ] + } + }, + "secrets": {}, + "properties": { + "minIssuesForAiSummary": "1", + "claudeModel": "claude-sonnet-5", + "claudeMaxTokens": "300", + "claudeApiUrl": "https://api.anthropic.com/v1/messages", + "claudeApiVersion": "2023-06-01" + }, + "sourceCodePath": "./worker.ts", + "action": "AFTER_DOWNLOAD", + "enabled": false, + "debug": true +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/package.json b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/package.json new file mode 100644 index 0000000..1d2af51 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/package.json @@ -0,0 +1,43 @@ +{ + "name": "ai-download-risk-advisor", + "description": "Run a script on AFTER_DOWNLOAD", + "version": "1.0.0", + "scripts": { + "deploy": "jf worker deploy", + "undeploy": "jf worker rm \"ai-download-risk-advisor\"", + "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/AFTER_DOWNLOAD/ai-download-risk-advisor/sample-payload.json b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/sample-payload.json new file mode 100644 index 0000000..1ed3573 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/sample-payload.json @@ -0,0 +1,17 @@ +{ + "metadata": { + "repoPath": { + "key": "npm-local", + "path": "npm-local/@jfrog/element-plus/2.14.0-csp.7/element-plus-2.14.0-csp.7.tgz", + "id": "npm-local:@jfrog/element-plus/2.14.0-csp.7/element-plus-2.14.0-csp.7.tgz", + "isRoot": false, + "isFolder": false + }, + "repoType": 1 + }, + "userContext": { + "id": "ai-download-risk-advisor", + "isToken": true, + "realm": "" + } +} \ No newline at end of file diff --git a/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/tsconfig.json b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/tsconfig.json new file mode 100644 index 0000000..8005cfb --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/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/AFTER_DOWNLOAD/ai-download-risk-advisor/types.ts b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/types.ts new file mode 100644 index 0000000..ee8de5d --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/types.ts @@ -0,0 +1,113 @@ + +export interface AfterDownloadRequest { + /** 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; +} + +export interface AfterDownloadResponse { + /** Message to print to the log, in case of an error it will be printed as a warning */ + message: string; +} + +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 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 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; +} + +/** One entry from the Xray `/xray/api/v1/artifacts` search response */ +export 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; +} + +export interface XrayArtifactsSearchResponse { + data: XrayArtifact[]; + offset: number; +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/worker.spec.ts b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/worker.spec.ts new file mode 100644 index 0000000..e89221b --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/worker.spec.ts @@ -0,0 +1,176 @@ +import { PlatformContext, PlatformClients, PlatformHttpClient, PlatformSecrets, PlatformProperties } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AfterDownloadRequest } from './types'; +import runWorker from './worker'; + +const PING_OK = { status: 200, data: { status: 'pong' } }; + +function buildRequest(): DeepMocked { + return createMock({ + metadata: { + repoPath: { key: 'example-repo-local', path: 'com/acme/payments-2.3.0.jar' } + } as any, + userContext: { id: 'alice', isToken: false } as any + }); +} + +function buildContext(): DeepMocked { + return createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue(PING_OK) + }), + axios: createMock({ + post: jest.fn().mockResolvedValue({ status: 200, data: {} }) + }) + }), + secrets: createMock({ + get: jest.fn().mockReturnValue(undefined) + }), + properties: createMock({ + get: jest.fn().mockReturnValue(undefined) + }) + }); +} + +describe('ai-download-risk-advisor tests', () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = buildContext(); + request = buildRequest(); + }); + + it('stops when Xray is not available', async () => { + context.clients.platformHttp.get = jest.fn().mockRejectedValue(new Error('unreachable')); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'Could not check for Xray scans because Xray is not available.' + }); + }); + + it('stops when no matching Xray scan result is found', async () => { + context.clients.platformHttp.get = jest.fn().mockImplementation((endpoint: string) => { + if (endpoint.includes('/system/ping')) return Promise.resolve(PING_OK); + return Promise.resolve({ status: 200, data: { data: [], offset: 0 } }); + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'Could not find an Xray scan result matching this artifact.' + }); + }); + + it('falls back to property defaults when context.properties.get throws', async () => { + context.properties.get = jest.fn().mockImplementation(() => { + throw new Error('unknown property key'); + }); + context.clients.platformHttp.get = jest.fn().mockImplementation((endpoint: string) => { + if (endpoint.includes('/system/ping')) return Promise.resolve(PING_OK); + return Promise.resolve({ + 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: 0, high: 0, medium: 0, low: 0, total: 0 }, + violations: 0 + }], + offset: 0 + } + }); + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'No security issues or violations found for this artifact; skipped AI risk advisory.' + }); + }); + + it('skips the AI call for a clean artifact', async () => { + context.clients.platformHttp.get = jest.fn().mockImplementation((endpoint: string) => { + if (endpoint.includes('/system/ping')) return Promise.resolve(PING_OK); + return Promise.resolve({ + 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: 0, high: 0, medium: 0, low: 0, total: 0 }, + violations: 0 + }], + offset: 0 + } + }); + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'No security issues or violations found for this artifact; skipped AI risk advisory.' + }); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + }); + + it('asks Claude for a summary and posts it to Slack when issues are found', async () => { + context.clients.platformHttp.get = jest.fn().mockImplementation((endpoint: string) => { + if (endpoint.includes('/system/ping')) return Promise.resolve(PING_OK); + return Promise.resolve({ + 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: 2, high: 1, medium: 0, low: 0, total: 3 }, + violations: 1 + }], + offset: 0 + } + }); + }); + context.secrets.get = jest.fn().mockImplementation((key: string) => { + if (key === 'AnthropicApiKey') return 'sk-ant-test'; + if (key === 'Slack_URL') return 'https://hooks.slack.test/services/xyz'; + return undefined; + }); + context.clients.axios.post = jest.fn().mockImplementation((url: string) => { + if (url === 'https://api.anthropic.com/v1/messages') { + return Promise.resolve({ status: 200, data: { content: [{ type: 'text', text: 'Hold off on deploying this version.' }] } }); + } + return Promise.resolve({ status: 200, data: {} }); + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'AI risk summary generated and posted to Slack.' + }); + expect(context.clients.axios.post).toHaveBeenCalledWith( + 'https://api.anthropic.com/v1/messages', + expect.objectContaining({ model: expect.any(String) }), + expect.objectContaining({ headers: expect.objectContaining({ 'x-api-key': 'sk-ant-test' }) }) + ); + expect(context.clients.axios.post).toHaveBeenCalledWith( + 'https://hooks.slack.test/services/xyz', + expect.objectContaining({ text: expect.stringContaining('Hold off on deploying this version.') }) + ); + }); + + it('falls back to raw stats when AnthropicApiKey is not configured', async () => { + context.clients.platformHttp.get = jest.fn().mockImplementation((endpoint: string) => { + if (endpoint.includes('/system/ping')) return Promise.resolve(PING_OK); + return Promise.resolve({ + 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: 1, high: 0, medium: 0, low: 0, total: 1 }, + violations: 0 + }], + offset: 0 + } + }); + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'AnthropicApiKey secret not configured; posted raw Xray stats instead of an AI summary.' + }); + expect(context.clients.axios.post).not.toHaveBeenCalledWith('https://api.anthropic.com/v1/messages', expect.anything(), expect.anything()); + }); +}); diff --git a/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/worker.ts b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/worker.ts new file mode 100644 index 0000000..3142fba --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/ai-download-risk-advisor/worker.ts @@ -0,0 +1,141 @@ +import { PlatformContext } from 'jfrog-workers'; +import { AfterDownloadRequest, AfterDownloadResponse, XrayArtifactsSearchResponse } from './types'; + +export default async (context: PlatformContext, data: AfterDownloadRequest): 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 minIssuesForAiSummary = parseInt(getProperty(context, 'minIssuesForAiSummary', '1'), 10); + const claudeModel = getProperty(context, 'claudeModel', 'claude-sonnet-5'); // e.g. 'claude-haiku-4-5-20251001' to cut cost + const claudeMaxTokens = parseInt(getProperty(context, 'claudeMaxTokens', '300'), 10); // response is a short summary, keep this small + const claudeApiUrl = getProperty(context, 'claudeApiUrl', 'https://api.anthropic.com/v1/messages'); + const claudeApiVersion = getProperty(context, 'claudeApiVersion', '2023-06-01'); + + if (!data.metadata?.repoPath) { + return { message: 'Missing repoPath metadata on the download request.' }; + } + + const isXrayAvailable = await checkIfXrayAvailable(context); + if (!isXrayAvailable) { + return { message: 'Could not check for Xray scans because Xray is not available.' }; + } + + const repoPath = data.metadata.repoPath.path; + const repoKey = data.metadata.repoPath.key; + const artifactName = repoPath.slice(repoPath.lastIndexOf('/') + 1); + const expectedRepoFullPath = `${repoKey}/${repoPath}`; + + let scanResult; + 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; + scanResult = responseData?.data?.find(artifact => artifact.repo_full_path === expectedRepoFullPath); + } catch (error: any) { + console.error(`Xray artifact search failed: ${error.message}`); + } + + if (!scanResult) { + return { message: 'Could not find an Xray scan result matching this artifact.' }; + } + + const critical = scanResult.sec_issues?.critical || 0; + const high = scanResult.sec_issues?.high || 0; + const medium = scanResult.sec_issues?.medium || 0; + const low = scanResult.sec_issues?.low || 0; + const violations = scanResult.violations || 0; + + if (critical + high + medium + low + violations < minIssuesForAiSummary) { + return { message: 'No security issues or violations found for this artifact; skipped AI risk advisory.' }; + } + + const rawStats = `Artifact: ${artifactName}\nRepository: ${repoKey}\nSecurity issues: ${critical} critical, ${high} high, ${medium} medium, ${low} low\nPolicy violations: ${violations}`; + const downloadedBy = `${data.userContext?.isToken ? 'token' : 'user'} ${data.userContext?.id}`; + + const anthropicApiKey = context.secrets.get('AnthropicApiKey'); + let advisoryText: string; + let message: string; + let aiSummarySucceeded = false; + + if (!anthropicApiKey) { + advisoryText = rawStats; + message = 'AnthropicApiKey secret not configured; posted raw Xray stats instead of an AI summary.'; + } else { + try { + advisoryText = await askClaudeForRiskSummary(context, anthropicApiKey, claudeModel, claudeMaxTokens, claudeApiUrl, claudeApiVersion, artifactName, repoKey, { critical, high, medium, low }, violations); + message = 'AI risk summary generated'; + aiSummarySucceeded = true; + } catch (error: any) { + console.error(`Claude API call failed: ${error.message}`); + advisoryText = rawStats; + message = 'Failed to generate AI risk summary; posted raw Xray stats to Slack instead.'; + } + } + + const slackWebhookUrl = context.secrets.get('Slack_URL'); + if (!slackWebhookUrl) { + return { message: aiSummarySucceeded ? `${message}; Slack_URL not configured, logged only.` : message }; + } + + try { + await context.clients.axios.post(slackWebhookUrl, { + text: `*Download risk advisory*\nArtifact \`${expectedRepoFullPath}\` downloaded by ${downloadedBy}:\n${advisoryText}` + }); + return { message: aiSummarySucceeded ? `${message} and posted to Slack.` : message }; + } catch (error: any) { + console.warn(`Failed to post advisory to Slack: ${error.message}`); + return { message }; + } +} + +// 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; + } +} + +async function askClaudeForRiskSummary( + context: PlatformContext, + anthropicApiKey: string, + claudeModel: string, + claudeMaxTokens: number, + claudeApiUrl: string, + claudeApiVersion: string, + artifactName: string, + repoKey: string, + secIssues: { critical: number; high: number; medium: number; low: number }, + violations: number +): Promise { + const prompt = `An artifact was just downloaded from JFrog Artifactory. Summarize the risk for someone who is not a security expert, in 2-3 sentences, then give one line of recommended action.\n\nArtifact: ${artifactName}\nRepository: ${repoKey}\nSecurity issues: ${secIssues.critical} critical, ${secIssues.high} high, ${secIssues.medium} medium, ${secIssues.low} low\nPolicy violations: ${violations}`; + + 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.'); + } + return text; +}