From 188b8c7fb9dce77c11a4766434bebf9efd092c46 Mon Sep 17 00:00:00 2001 From: hervee Date: Tue, 6 Oct 2026 23:25:01 +0200 Subject: [PATCH] WKS-2954 - Add Copilot allowlist gate BEFORE_DOWNLOAD sample Blocks downloads of artifacts not matching a glob in an allowlist file kept in a GitHub repo, and assigns a review issue to Copilot's coding agent, which proposes the allowlist entry as a PR. Repo, branch, allowlist path ({repoKey} supported), fail mode and the issue-tracking artifact property are worker properties. --- .../copilot-allowlist-gate/README.md | 192 +++++++++++++++++ .../approved-dependencies.example.json | 4 + .../copilot-allowlist-gate/manifest.json | 25 +++ .../copilot-allowlist-gate/package.json | 43 ++++ .../sample-payload.json | 14 ++ .../copilot-allowlist-gate/tsconfig.json | 15 ++ .../copilot-allowlist-gate/types.ts | 107 ++++++++++ .../copilot-allowlist-gate/worker.spec.ts | 180 ++++++++++++++++ .../copilot-allowlist-gate/worker.ts | 196 ++++++++++++++++++ 9 files changed, 776 insertions(+) create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/README.md create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/approved-dependencies.example.json create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/manifest.json create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/package.json create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/sample-payload.json create mode 100755 samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/tsconfig.json create mode 100755 samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/types.ts create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/worker.spec.ts create mode 100644 samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/worker.ts diff --git a/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/README.md b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/README.md new file mode 100644 index 0000000..a91fcf8 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/README.md @@ -0,0 +1,192 @@ +# Copilot Allowlist Gate + +## Overview + +This worker listens to the `BEFORE_DOWNLOAD` event in JFrog Artifactory and only lets a download +through when the artifact matches an entry in an **allowlist file kept in a GitHub repository**. + +When a download is blocked, the worker opens a GitHub issue in that same repository and assigns it +to **GitHub Copilot's coding agent**. Copilot vets the dependency and, if it's acceptable, opens a +PR adding a matching entry to the allowlist. A human reviews and merges the PR, and the next +download goes through. No other approval step is needed: the allowlist in Git is the single source +of truth, and its history is the approval log. + +``` +download ──► worker reads allowlist from GitHub ──► match? ──► PROCEED + │ + no + ▼ + STOP + issue assigned to Copilot ──► Copilot PR ──► human merge +``` + +## Why this design + +A `BEFORE_DOWNLOAD` worker has to answer immediately, while Copilot's coding agent works over +minutes. So the worker never waits for Copilot: it decides from what is already in the allowlist, +and Copilot's work only changes that allowlist for *later* downloads. + +The allowlist location is configured on the **worker** (not on each artifact): an artifact nobody +has approved is precisely one nobody has tagged, so per-artifact configuration can't drive a gate. + +It's the blocking counterpart to the `AFTER_DOWNLOAD/copilot-dependency-advisory` sample, which +only notifies. + +## Functionality + +1. **Trigger:** runs before a download of a stored artifact, for repositories listed in + `manifest.json`'s `filterCriteria`. Folder requests are let through. +2. **Read the allowlist:** fetches `allowlistPath` from `githubRepo` at `githubBranch`. +3. **Match:** compares `/` against each glob. Any match → `DOWNLOAD_PROCEED`. +4. **No match → `DOWNLOAD_STOP`.** Then: + - If the artifact already carries the `issueProperty` property (`copilot.allowlist.issue` by + default), the block message + points to that issue and nothing else happens. + - Otherwise the worker opens a review issue, records its URL in that property on + the artifact, and assigns the issue to Copilot's coding agent (`copilot-swe-agent[bot]`). + +The download stays blocked in every "no match" case, including when GitHub calls fail. + +## Allowlist file + +A JSON array of globs matched against `/` (the same form `jf rt` uses): + +```json +[ + "npm-local/lodash/-/lodash-4.17.*.tgz", + "pypi-remote-cache/**/requests-2.32.*" +] +``` + +| Pattern | Matches | +| ------- | ------- | +| `*` | any characters within one path segment (never `/`) | +| `**` | any characters, across segments | +| `?` | exactly one character within a segment | + +Everything else is literal. A file missing from the repo counts as an empty allowlist (everything +blocked), so Copilot's first PR can create it. See `approved-dependencies.example.json`. + +A broad glob such as `npm-local/lodash/**` approves every future version without review: that's a +decision for whoever reviews Copilot's PR. + +## Required secrets + +| Secret Name | Description | Required | +| ------------- | ----------- | -------- | +| `GitHubToken` | A GitHub **personal access token** (classic, `repo` scope) belonging to a real user with Copilot access. Copilot coding agent usage is billed per-user, so a GitHub App / installation token cannot be used to assign issues to it. | Yes. Without it the worker warns and lets every download through | + +```sh +jf worker add-secret GitHubToken +``` + +## Configuration + +Worker properties, declared in `manifest.json` (`context.properties.get(...)` in `worker.ts`): + +| Property | Default | Purpose | +| ------------------ | ---------------------------- | ------- | +| `githubRepo` | none (required) | `owner/repo` holding the allowlist; review issues are opened there | +| `githubBranch` | `main` | Branch the allowlist is read from, and Copilot's base branch for its PR | +| `allowlistPath` | `approved-dependencies.json` | Path of the allowlist inside the repo. `{repoKey}` is replaced by the artifact's repository key | +| `failMode` | `WARN` | When the allowlist can't be read or is invalid: `WARN` lets the download through with a warning, `STOP` blocks it | +| `issueProperty` | `copilot.allowlist.issue` | Artifact property that stores the review issue URL on a blocked artifact | +| `githubApiUrl` | `https://api.github.com` | Override for GitHub Enterprise Server | +| `githubApiVersion` | `2022-11-28` | Value sent as the `X-GitHub-Api-Version` header | + +Use `allowlistPath: "allowlists/{repoKey}.json"` to give each Artifactory repository its own file +(`allowlists/npm-local.json`, `allowlists/pypi-remote.json`, ...), and pair it with a +`CODEOWNERS` file so each team reviews the PRs for its own list. + +Without `githubRepo` or the `GitHubToken` secret, the worker returns `DOWNLOAD_WARN` (lets the +download through) rather than silently blocking everything. + +Update `filterCriteria.artifactFilterCriteria.repoKeys` to the repositories you want gated. + +## How it works + +1. **Allowlist:** `GET /repos/{githubRepo}/contents/{allowlistPath}?ref={githubBranch}` with + `Accept: application/vnd.github.raw+json`, so the body is the file itself + ([Repository contents API](https://docs.github.com/en/rest/repos/contents)). +2. **Existing issue:** `GET /api/storage/{repoKey}/{path}?properties={issueProperty}` + ([Item Properties API](https://jfrog.com/help/r/jfrog-rest-apis/item-properties)); `404` means none yet. +3. **Review issue:** `POST /repos/{githubRepo}/issues`, asking Copilot to vet the dependency and + open a PR adding a glob to the allowlist, or explain in the issue why it shouldn't be allowed. +4. **Record it:** `PUT /api/storage/{repoKey}/{path}?properties={issueProperty}=`. +5. **Assign to Copilot:** `POST /repos/{githubRepo}/issues/{number}/assignees` with + `{ assignees: ["copilot-swe-agent[bot]"], agent_assignment: { target_repo, base_branch } }`; + see [Using Copilot cloud agent via the API](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api). + +Every download in the filtered repositories makes one GitHub API call (step 1). The allowlist isn't +cached, so mind the PAT's 5,000 requests/hour limit on busy repositories. + +## Running this sample end-to-end + +### Prerequisites + +- A JFrog Platform instance with **Artifactory** enabled. +- [JFrog CLI](https://jfrog.com/getcli/) installed and configured against that instance. +- A GitHub repository for the allowlist, with + [Copilot coding agent](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent) enabled. +- A GitHub personal access token (classic, `repo` scope) for a user who has Copilot access on that repo. + +### Steps + +1. Configure the JFrog CLI server if you haven't already: + ```sh + jf config add + ``` +2. In `manifest.json`, set `githubRepo` (and optionally `githubBranch` / `allowlistPath`) and the + `repoKeys` to gate, and set `"enabled"` to `true` (samples ship disabled). +3. From this directory, deploy the worker and add the secret: + ```sh + jf worker deploy + jf worker add-secret GitHubToken + ``` +4. Download an artifact that isn't in the allowlist (e.g. `jf rt download /`). The + download fails with a message naming the review issue. +5. In GitHub, the issue is assigned to Copilot. Once Copilot opens its PR, review and merge it. +6. Download the same artifact again: it now goes through. +7. Check the outcomes in the Workers execution log (Platform UI → Administration → Workers → this + worker → Logs) or with `jf worker exec-history copilot-allowlist-gate`. + +## Response messages + +| Status | Message | +| ------ | ------- | +| `PROCEED` | `/ is allowlisted.` | +| `PROCEED` | `Folder request; allowlist not checked.` | +| `STOP` | ` is not in allowlist /; opened and assigned it to Copilot.` | +| `STOP` | ` is not in allowlist /; opened , but failed to assign it to Copilot.` | +| `STOP` | ` is not in allowlist /; review pending in ` | +| `STOP` | ` is not in allowlist /; failed to open a review issue: ` | +| `STOP` | ` is not in allowlist /.` (couldn't read `issueProperty`) | +| `failMode` | `Could not read allowlist /: ` | +| `WARN` | `Worker is not configured (missing 'githubRepo' property and/or 'GitHubToken' secret); allowlist not checked.` | +| `WARN` | `Missing repoPath on the download request; allowlist not checked.` | + +## Error scenarios + +- **GitHub unreachable, rate limited, or the allowlist isn't a JSON array of strings:** `failMode` + decides (`WARN` by default). Use `STOP` if a gate outage must never let anything through. +- **Issue creation fails:** the download is still blocked, and no issue property is written, so + the next download tries again. +- **Writing the `issueProperty` property fails:** logged as a warning; the next download of that + artifact opens a duplicate issue. +- **Copilot assignment fails** (coding agent not enabled, token isn't a PAT): the issue exists + for a human to pick up; no duplicate issue is opened on later downloads. +- **Copilot (or a reviewer) rejects the dependency:** close the issue. The artifact stays blocked + and keeps pointing to that issue. Delete the `issueProperty` property to request a new + review. +- **Two concurrent first downloads** of the same blocked artifact can both open an issue (no lock + between the property read and write). + +## Dependencies + +- `PlatformContext` (`context.clients.platformHttp`, `context.clients.axios`, `context.secrets`, + `context.properties`) from the `jfrog-workers` package. +- `BeforeDownloadRequest` / `BeforeDownloadResponse` for the event payload and response shape. +- GitHub's [Repository contents API](https://docs.github.com/en/rest/repos/contents), + [Issues API](https://docs.github.com/en/rest/issues/issues) and + [Copilot coding agent assignment API](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api), + called over HTTPS via `context.clients.axios`. +- Artifactory's [Item Properties API](https://jfrog.com/help/r/jfrog-rest-apis/item-properties). diff --git a/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/approved-dependencies.example.json b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/approved-dependencies.example.json new file mode 100644 index 0000000..10dccf3 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/approved-dependencies.example.json @@ -0,0 +1,4 @@ +[ + "npm-local/lodash/-/lodash-4.17.*.tgz", + "pypi-remote-cache/**/requests-2.32.*" +] diff --git a/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/manifest.json b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/manifest.json new file mode 100644 index 0000000..4b938e1 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "copilot-allowlist-gate", + "description": "This worker is triggered by the `BEFORE_DOWNLOAD` event of Artifactory. It only lets downloads through when the artifact matches a glob in an allowlist file kept in a GitHub repository. Blocked artifacts get a GitHub issue assigned to GitHub Copilot's coding agent, which vets the dependency and opens a PR adding it to the allowlist; merging that PR unblocks the download.", + "sourceCodePath": "./worker.ts", + "action": "BEFORE_DOWNLOAD", + "enabled": false, + "debug": true, + "secrets": {}, + "properties": { + "githubRepo": "myorg/dependency-allowlist", + "githubBranch": "main", + "allowlistPath": "approved-dependencies.json", + "failMode": "WARN", + "issueProperty": "copilot.allowlist.issue", + "githubApiUrl": "https://api.github.com", + "githubApiVersion": "2022-11-28" + }, + "filterCriteria": { + "artifactFilterCriteria": { + "repoKeys": [ + "example-repo-local" + ] + } + } +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/package.json b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/package.json new file mode 100644 index 0000000..0cddb59 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/package.json @@ -0,0 +1,43 @@ +{ + "name": "copilot-allowlist-gate", + "description": "Run a script on BEFORE_DOWNLOAD", + "version": "1.0.0", + "scripts": { + "deploy": "jf worker deploy", + "undeploy": "jf worker rm \"copilot-allowlist-gate\"", + "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 + } +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/sample-payload.json b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/sample-payload.json new file mode 100644 index 0000000..0dba12b --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/sample-payload.json @@ -0,0 +1,14 @@ +{ + "repoPath": { + "key": "npm-local", + "path": "lodash/-/lodash-4.17.21.tgz", + "id": "npm-local:lodash/-/lodash-4.17.21.tgz", + "isRoot": false, + "isFolder": false + }, + "userContext": { + "id": "ci-payments-service", + "isToken": true, + "realm": "" + } +} diff --git a/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/tsconfig.json b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/tsconfig.json new file mode 100755 index 0000000..8005cfb --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/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/copilot-allowlist-gate/types.ts b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/types.ts new file mode 100755 index 0000000..d200f8c --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/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/copilot-allowlist-gate/worker.spec.ts b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/worker.spec.ts new file mode 100644 index 0000000..4725183 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/worker.spec.ts @@ -0,0 +1,180 @@ +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 ISSUE_URL = 'https://github.com/myorg/dependency-allowlist/issues/7'; + +function buildRequest(): DeepMocked { + return createMock({ + repoPath: { + key: 'npm-local', + path: 'lodash/-/lodash-4.17.21.tgz', + id: 'npm-local:lodash/-/lodash-4.17.21.tgz', + isRoot: false, + isFolder: false + }, + metadata: undefined + }); +} + +function buildContext(properties: Record = {}): DeepMocked { + const allProperties: Record = { githubRepo: 'myorg/dependency-allowlist', ...properties }; + return createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockRejectedValue({ status: 404, message: 'Not Found' }), + put: jest.fn().mockResolvedValue({ status: 204, data: {} }) + }), + axios: createMock({ + get: jest.fn().mockResolvedValue({ status: 200, data: '[]' }), + post: jest.fn().mockResolvedValue({ status: 201, data: { number: 7, html_url: ISSUE_URL } }) + }) + }), + secrets: createMock({ + get: jest.fn().mockReturnValue('ghp_test') + }), + properties: createMock({ + get: jest.fn((key: string) => allProperties[key]) + }) + }); +} + +function allowlist(context: DeepMocked, entries: any) { + context.clients.axios.get = jest.fn().mockResolvedValue({ status: 200, data: JSON.stringify(entries) }); +} + +describe('copilot-allowlist-gate tests', () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = buildContext(); + request = buildRequest(); + }); + + it('warns without calling GitHub when the worker is not configured', async () => { + context = buildContext({ githubRepo: '' }); + + const result = await runWorker(context, request); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_WARN); + expect(context.clients.axios.get).not.toHaveBeenCalled(); + }); + + it('reads the allowlist from the configured repo, branch and path', async () => { + context = buildContext({ githubBranch: 'policies', allowlistPath: 'allowlists/{repoKey}.json' }); + allowlist(context, ['npm-local/lodash/**']); + + await runWorker(context, request); + + expect(context.clients.axios.get).toHaveBeenCalledWith( + 'https://api.github.com/repos/myorg/dependency-allowlist/contents/allowlists/npm-local.json?ref=policies', + expect.anything() + ); + }); + + it.each([ + ['exact path', 'npm-local/lodash/-/lodash-4.17.21.tgz'], + ['single-segment *', 'npm-local/lodash/-/lodash-4.17.*.tgz'], + ['multi-segment **', 'npm-local/lodash/**'], + ['? wildcard', 'npm-local/lodash/-/lodash-4.17.2?.tgz'] + ])('proceeds when the artifact matches an allowlist entry (%s)', async (_, pattern) => { + allowlist(context, ['npm-local/other/**', pattern]); + + const result = await runWorker(context, request); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_PROCEED); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + }); + + it.each([ + ['* does not cross folders', 'npm-local/*.tgz'], + ['other repo key', 'npm-remote/lodash/**'], + ['dots are literal', 'npm-local/lodash/-/lodash-4x17x21.tgz'] + ])('blocks when no entry matches (%s)', async (_, pattern) => { + allowlist(context, [pattern]); + + const result = await runWorker(context, request); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_STOP); + }); + + it('blocks, opens a review issue, records it on the artifact and assigns it to Copilot', async () => { + const result = await runWorker(context, request); + + expect(result).toEqual({ + status: DownloadStatus.DOWNLOAD_STOP, + message: `npm-local/lodash/-/lodash-4.17.21.tgz is not in allowlist myorg/dependency-allowlist/approved-dependencies.json; opened ${ISSUE_URL} and assigned it to Copilot.` + }); + expect(context.clients.platformHttp.put).toHaveBeenCalledWith( + `/artifactory/api/storage/npm-local/lodash/-/lodash-4.17.21.tgz?properties=copilot.allowlist.issue=${encodeURIComponent(ISSUE_URL)}` + ); + expect(context.clients.axios.post).toHaveBeenLastCalledWith( + 'https://api.github.com/repos/myorg/dependency-allowlist/issues/7/assignees', + { assignees: ['copilot-swe-agent[bot]'], agent_assignment: { target_repo: 'myorg/dependency-allowlist', base_branch: 'main' } }, + expect.anything() + ); + }); + + it('treats a missing allowlist file as empty', async () => { + context.clients.axios.get = jest.fn().mockRejectedValue({ status: 404, message: 'Not Found' }); + + const result = await runWorker(context, request); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_STOP); + expect(context.clients.axios.post).toHaveBeenCalled(); + }); + + it('points to the existing issue instead of opening a new one', async () => { + context.clients.platformHttp.get = jest.fn().mockResolvedValue({ status: 200, data: { properties: { 'copilot.allowlist.issue': [ISSUE_URL] } } }); + + const result = await runWorker(context, request); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_STOP); + expect(result.message).toContain(`review pending in ${ISSUE_URL}`); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + }); + + it('uses the configured issueProperty to dedupe and record the review issue', async () => { + context = buildContext({ issueProperty: 'security.review.issue' }); + + await runWorker(context, request); + + expect(context.clients.platformHttp.get).toHaveBeenCalledWith( + '/artifactory/api/storage/npm-local/lodash/-/lodash-4.17.21.tgz?properties=security.review.issue' + ); + expect(context.clients.platformHttp.put).toHaveBeenCalledWith( + `/artifactory/api/storage/npm-local/lodash/-/lodash-4.17.21.tgz?properties=security.review.issue=${encodeURIComponent(ISSUE_URL)}` + ); + }); + + it('still blocks when the Copilot assignment fails', async () => { + context.clients.axios.post = jest.fn() + .mockResolvedValueOnce({ status: 201, data: { number: 7, html_url: ISSUE_URL } }) + .mockRejectedValueOnce({ status: 422, message: 'Copilot not enabled' }); + + const result = await runWorker(context, request); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_STOP); + expect(result.message).toContain('failed to assign it to Copilot'); + }); + + it('applies failMode when the allowlist cannot be read', async () => { + context.clients.axios.get = jest.fn().mockRejectedValue({ status: 503, message: 'Service Unavailable' }); + expect((await runWorker(context, request)).status).toBe(DownloadStatus.DOWNLOAD_WARN); + + context = buildContext({ failMode: 'STOP' }); + context.clients.axios.get = jest.fn().mockRejectedValue({ status: 503, message: 'Service Unavailable' }); + expect((await runWorker(context, request)).status).toBe(DownloadStatus.DOWNLOAD_STOP); + }); + + it('applies failMode when the allowlist is not an array of strings', async () => { + allowlist(context, { allow: ['npm-local/**'] }); + + const result = await runWorker(context, request); + + expect(result.status).toBe(DownloadStatus.DOWNLOAD_WARN); + expect(result.message).toContain('allowlist must be a JSON array of strings'); + }); +}); diff --git a/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/worker.ts b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/worker.ts new file mode 100644 index 0000000..52f5af5 --- /dev/null +++ b/samples/artifactory/BEFORE_DOWNLOAD/copilot-allowlist-gate/worker.ts @@ -0,0 +1,196 @@ +import { PlatformContext } from 'jfrog-workers'; +import { BeforeDownloadRequest, BeforeDownloadResponse, DownloadStatus, RepoPath } 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. + const githubRepo = getProperty(context, 'githubRepo', ''); + const githubBranch = getProperty(context, 'githubBranch', 'main'); + const githubApiUrl = getProperty(context, 'githubApiUrl', 'https://api.github.com'); + const githubApiVersion = getProperty(context, 'githubApiVersion', '2022-11-28'); + // Artifact property written onto a blocked artifact once a review issue has been opened for it, so later + // downloads point at the same issue instead of opening a new one each time. + const issueProperty = getProperty(context, 'issueProperty', 'copilot.allowlist.issue'); + // What to do when the allowlist can't be read (GitHub down, rate limited, invalid file): WARN lets the + // download through with a warning, STOP blocks it. + const failStatus = getProperty(context, 'failMode', 'WARN').toUpperCase() === 'STOP' ? DownloadStatus.DOWNLOAD_STOP : DownloadStatus.DOWNLOAD_WARN; + + const repoPath = data.repoPath ?? data.metadata?.repoPath; + if (!repoPath) { + return { status: DownloadStatus.DOWNLOAD_WARN, message: 'Missing repoPath on the download request; allowlist not checked.' }; + } + if (repoPath.isFolder) { + return { status: DownloadStatus.DOWNLOAD_PROCEED, message: 'Folder request; allowlist not checked.' }; + } + + const githubToken = context.secrets.get('GitHubToken'); + if (!githubRepo || !githubToken) { + return { status: DownloadStatus.DOWNLOAD_WARN, message: "Worker is not configured (missing 'githubRepo' property and/or 'GitHubToken' secret); allowlist not checked." }; + } + + const github: GithubTarget = { apiUrl: githubApiUrl, apiVersion: githubApiVersion, token: githubToken, repo: githubRepo, branch: githubBranch }; + const allowlistPath = getProperty(context, 'allowlistPath', 'approved-dependencies.json').replace('{repoKey}', repoPath.key); + const artifact = `${repoPath.key}/${repoPath.path}`; + + let allowlist: string[]; + try { + allowlist = await fetchAllowlist(context, github, allowlistPath); + } catch (error: any) { + console.error(`Failed to read allowlist ${githubRepo}/${allowlistPath}@${githubBranch}: ${error.message}`); + return { status: failStatus, message: `Could not read allowlist ${githubRepo}/${allowlistPath}: ${error.message}` }; + } + + if (allowlist.some((pattern) => globToRegExp(pattern).test(artifact))) { + return { status: DownloadStatus.DOWNLOAD_PROCEED, message: `${artifact} is allowlisted.` }; + } + + // Not allowlisted: the download is blocked whatever happens below; the rest only decides which + // review issue the message points the user to. + const blocked = `${artifact} is not in allowlist ${githubRepo}/${allowlistPath}`; + + let existingIssue: string | undefined; + try { + existingIssue = await getIssueProperty(context, repoPath, issueProperty); + } catch (error: any) { + console.error(`Failed to read '${issueProperty}' on ${artifact}: ${error.message}`); + return { status: DownloadStatus.DOWNLOAD_STOP, message: `${blocked}.` }; + } + if (existingIssue) { + return { status: DownloadStatus.DOWNLOAD_STOP, message: `${blocked}; review pending in ${existingIssue}` }; + } + + let issue: { number: number, url: string }; + try { + issue = await createReviewIssue(context, github, allowlistPath, artifact); + } catch (error: any) { + console.error(`Failed to open a review issue: ${error.message}`); + return { status: DownloadStatus.DOWNLOAD_STOP, message: `${blocked}; failed to open a review issue: ${error.message}` }; + } + + try { + await setIssueProperty(context, repoPath, issueProperty, issue.url); + } catch (error: any) { + console.warn(`Failed to record '${issueProperty}' on ${artifact}: ${error.message}`); + } + + try { + await assignToCopilot(context, github, issue.number); + return { status: DownloadStatus.DOWNLOAD_STOP, message: `${blocked}; opened ${issue.url} and assigned it to Copilot.` }; + } catch (error: any) { + console.warn(`Failed to assign issue #${issue.number} to Copilot: ${error.message}`); + return { status: DownloadStatus.DOWNLOAD_STOP, message: `${blocked}; opened ${issue.url}, but failed to assign it to Copilot.` }; + } +} + +type GithubTarget = { apiUrl: string, apiVersion: string, token: string, repo: string, branch: string }; + +// 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; + } +} + +// Reads the allowlist - a JSON array of globs - straight from the repo via the Contents API, using the raw +// media type so the body is the file itself rather than base64: https://docs.github.com/en/rest/repos/contents +// A missing file is an empty allowlist (everything blocked), so the first Copilot PR can create it. +// ponytail: one GitHub call per download, no cache. Mind the 5000 req/h PAT limit on busy repos; cache the +// file in context.state keyed by ETag if that becomes a problem. +async function fetchAllowlist(context: PlatformContext, github: GithubTarget, allowlistPath: string): Promise { + let body: any; + try { + const res = await context.clients.axios.get(`${github.apiUrl}/repos/${github.repo}/contents/${allowlistPath}?ref=${encodeURIComponent(github.branch)}`, { + headers: { ...githubHeaders(github), 'Accept': 'application/vnd.github.raw+json' } + }); + body = res.data; + } catch (error: any) { + if ((error.status ?? error.response?.status) === 404) { + return []; + } + throw error; + } + + const allowlist = typeof body === 'string' ? JSON.parse(body) : body; + if (!Array.isArray(allowlist) || !allowlist.every((entry) => typeof entry === 'string')) { + throw new Error('allowlist must be a JSON array of strings'); + } + return allowlist; +} + +// Matches "/": "**" spans folders, "*" stays within one path segment, "?" is one character. +function globToRegExp(glob: string): RegExp { + const source = glob + .split('**') + .map((part) => part + .split('*') + .map((sub) => sub.split('?').map(escapeRegExp).join('[^/]')) + .join('[^/]*')) + .join('.*'); + return new RegExp(`^${source}$`); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.+^${}()|[\]\\]/g, '\\$&'); +} + +// Item Properties API: https://jfrog.com/help/r/jfrog-rest-apis/item-properties +// Artifactory returns 404 when the requested property isn't set - that just means no issue yet. +async function getIssueProperty(context: PlatformContext, repoPath: RepoPath, issueProperty: string): Promise { + try { + const res = await context.clients.platformHttp.get(`/artifactory/api/storage/${repoPath.key}/${repoPath.path}?properties=${issueProperty}`); + return res.data?.properties?.[issueProperty]?.[0]; + } catch (error: any) { + if (error.status === 404) { + return undefined; + } + throw error; + } +} + +async function setIssueProperty(context: PlatformContext, repoPath: RepoPath, issueProperty: string, issueUrl: string): Promise { + await context.clients.platformHttp.put(`/artifactory/api/storage/${repoPath.key}/${repoPath.path}?properties=${issueProperty}=${encodeURIComponent(issueUrl)}`); +} + +async function createReviewIssue(context: PlatformContext, github: GithubTarget, allowlistPath: string, artifact: string): Promise<{ number: number, url: string }> { + const res = await context.clients.axios.post(`${github.apiUrl}/repos/${github.repo}/issues`, { + title: `Allowlist review: ${artifact}`, + body: `A download of \`${artifact}\` from Artifactory was blocked because it doesn't match any entry in \`${allowlistPath}\`.\n\n` + + `Please vet this dependency (license, maintenance, known vulnerabilities). If it is acceptable, open a PR against \`${github.branch}\` ` + + `that adds a glob matching it to \`${allowlistPath}\` (a JSON array of strings; \`*\` matches within a path segment, \`**\` across segments), ` + + `and summarize your findings in the PR description. If it is not acceptable, explain why in this issue and don't open a PR.` + }, { + headers: githubHeaders(github) + }); + + const { number, html_url } = res.data ?? {}; + if (!number || !html_url) { + throw new Error('GitHub response did not contain an issue number and URL.'); + } + return { number, url: html_url }; +} + +// Assigning an issue to Copilot's coding agent is a dedicated endpoint, not a field on issue +// creation - see https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api. +// Requires a personal access token (Copilot usage is billed per-user); a GitHub App token won't work. +async function assignToCopilot(context: PlatformContext, github: GithubTarget, issueNumber: number): Promise { + await context.clients.axios.post(`${github.apiUrl}/repos/${github.repo}/issues/${issueNumber}/assignees`, { + assignees: ['copilot-swe-agent[bot]'], + agent_assignment: { + target_repo: github.repo, + base_branch: github.branch + } + }, { + headers: githubHeaders(github) + }); +} + +function githubHeaders(github: GithubTarget): Record { + return { + 'Authorization': `Bearer ${github.token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': github.apiVersion + }; +}