From 6af8c0e883344bdbc67b22e2aed3c1c4466e2675 Mon Sep 17 00:00:00 2001 From: Sagar Kalsaria Date: Wed, 26 Aug 2026 11:42:44 +0530 Subject: [PATCH 1/3] COD-1178: expose app APIs and Hotlist via CLI --- README.md | 24 +++++- cli-api.md | 108 ++++++++++++++++++++++++++ mcp.md | 10 ++- mcpb/manifest.json | 14 ++-- package.json | 4 +- src/commands/api/index.js | 27 +++++++ src/commands/api/request.js | 77 +++++++++++++++++++ src/commands/hotlist/index.js | 58 ++++++++++++++ src/hotlist/client.js | 138 ++++++++++++++++++++++++++++++++++ src/index.js | 8 ++ src/mcp/server.js | 88 ++++++++++++++++++++++ src/utils/fetchApi.js | 77 ++++++++++++++++--- tests/apiRequest.test.js | 56 ++++++++++++++ tests/fetchApi.test.js | 62 +++++++++++++++ tests/hotlist.test.js | 86 +++++++++++++++++++++ 15 files changed, 818 insertions(+), 19 deletions(-) create mode 100644 cli-api.md create mode 100644 src/commands/api/index.js create mode 100644 src/commands/api/request.js create mode 100644 src/commands/hotlist/index.js create mode 100644 src/hotlist/client.js create mode 100644 tests/apiRequest.test.js create mode 100644 tests/fetchApi.test.js create mode 100644 tests/hotlist.test.js diff --git a/README.md b/README.md index eea0af9..58900f8 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,26 @@ Show the current API base URL and its source. codeant get-base-url ``` +#### `hotlist` + +Query the same organization-wide prioritized findings shown in the CodeAnt Hotlist, or fetch one finding by its stable ID. + +```bash +codeant hotlist list --org CodeAnt-AI --service github --severity critical,high +codeant hotlist get 0123456789abcdef0123456789abcdef --org CodeAnt-AI --service github +``` + +#### `api request` + +Call any CodeAnt application API using the saved bearer token. Only relative paths on the configured CodeAnt API host are accepted. + +```bash +codeant api request GET /some/read/endpoint --query '{"page":1}' +codeant api request POST /some/app/endpoint --body '{"org":"CodeAnt-AI"}' +``` + +See [cli-api.md](cli-api.md) for the complete Hotlist, raw API, authentication, self-hosted provider, and agent/MCP manual. + ### Global Options ```bash @@ -240,11 +260,11 @@ node src/index.js secrets --all This package also ships an MCP (Model Context Protocol) server that exposes CodeAnt's scan, review, and PR data as tools to Claude and other MCP clients. The same source tree is packaged as a Desktop Extension (`.mcpb`) for one-click install in Claude Desktop. -See [mcp.md](mcp.md) for the tools listing, install paths (Claude Code CLI, Claude Desktop manual config, MCPB double-click), and bundling/submission instructions. +See [mcp.md](mcp.md) for the tools listing, install paths (Claude Code CLI, Claude Desktop manual config, MCPB double-click), and bundling/submission instructions. See [cli-api.md](cli-api.md) for Hotlist and generic authenticated API usage. ## Privacy Policy -Full policy: **https://codeant.ai/privacy** +Full policy: **https://www.codeant.ai/privacy-policy** Summary of what this CLI / MCP server sends and stores: diff --git a/cli-api.md b/cli-api.md new file mode 100644 index 0000000..ae36081 --- /dev/null +++ b/cli-api.md @@ -0,0 +1,108 @@ +# CodeAnt application APIs from the CLI + +The CLI uses the same authenticated CodeAnt API and organization/provider context as the web app. Sign in once, discover the connections available to that token, then use a first-class command or the generic API request command. + +```bash +codeant login +codeant scans orgs +``` + +`CODEANT_API_TOKEN` and `CODEANT_API_URL` can be used instead of the saved login for agents, CI, and self-hosted installations. + +## Hotlist findings + +Hotlist commands use the same organization-wide snapshot, ranking, filters, stable finding IDs, and cursor pagination as the app. + +```bash +# First page; org/service are auto-selected when unambiguous +codeant hotlist list + +# Highest-priority production findings for one authenticated connection +codeant hotlist list \ + --org CodeAnt-AI \ + --service github \ + --severity critical,high \ + --validation exploit_confirmed \ + --limit 50 + +# Fetch every SCA finding across the organization +codeant hotlist list --org CodeAnt-AI --service github --type SCA --all + +# Continue a page using next_cursor from the previous response +codeant hotlist list --org CodeAnt-AI --service github --cursor '' + +# Fetch exactly one finding using the stable ID shown in the app +codeant hotlist get 0123456789abcdef0123456789abcdef \ + --org CodeAnt-AI \ + --service github +``` + +Supported `hotlist list` filters: + +| Option | Values | +|---|---| +| `--search` | title, repository/account, path, package, CVE, or check ID | +| `--type` | `AI Exploitation`, `SCA`, `SAST`, `Secrets`, `IaC`, `Infrastructure` | +| `--location` | repository full names or cloud accounts | +| `--severity` | `critical`, `high`, `medium`, `low`, `unknown` | +| `--ticket-status` | `created`, `not_created` | +| `--compliance` | framework keys such as `soc2` | +| `--validation` | `exploit_confirmed` | + +Comma-separated values are accepted. The default page size is 30 and the maximum is 100. `--all` follows every cursor. If the first organization snapshot is still being built, the command waits up to 60 seconds; change that with `--max-wait `. + +For self-hosted GitHub, GitLab, Bitbucket, or Azure DevOps, the CLI normally discovers the provider base URL from the authenticated connection. Use `--provider-base-url` only to override it. + +## Any app API + +Use the generic request command when a first-class command does not exist yet: + +```bash +codeant api request GET /some/read/endpoint --query '{"page":1}' + +codeant api request POST /some/app/endpoint \ + --body '{"org":"CodeAnt-AI","service":"github"}' + +codeant api request PATCH /some/app/endpoint \ + --body-file ./request.json \ + --header 'If-Match: revision-123' +``` + +The output is JSON: + +```json +{ + "ok": true, + "status": 200, + "data": {} +} +``` + +Security properties: + +- The path must start with `/` and is always resolved against the configured CodeAnt API host. Absolute and protocol-relative URLs are rejected, so the bearer token cannot be forwarded to another host. +- Authentication is supplied from `CODEANT_API_TOKEN` or the token saved by `codeant login`. +- `Authorization`, `Cookie`, `Host`, and `Content-Length` headers cannot be overridden. +- The backend remains authoritative for account access, organization membership, RBAC, and endpoint authorization. + +The generic command can call write endpoints. Review the method, path, and body before running it. + +## Agent and MCP access + +Run `codeant mcp` or install the CodeAnt MCP bundle. Agents receive dedicated read-only tools: + +- `codeant_hotlist_list` — filter and page through organization-wide findings. +- `codeant_hotlist_get` — fetch one finding by stable ID. +- `codeant_api_get` — authenticated GET access for newly-added read APIs. + +Set `CODEANT_READ_ONLY=0` to opt in to write tools, including `codeant_api_request` for POST/PUT/PATCH/DELETE. Read-only mode is the default. The MCP server never opens a browser during startup; the agent must explicitly call `codeant_login` when no token is configured. + +## Troubleshooting + +| Error | Resolution | +|---|---| +| No matching organization | Run `codeant scans orgs`, then pass its exact `organizationName` and `service`. | +| Multiple organizations match | Pass both `--org` and `--service`. | +| Access denied (403) | Run `codeant logout`, then `codeant login`, or replace `CODEANT_API_TOKEN`. | +| Hotlist is still building | Retry, or increase `--max-wait`. | +| Finding not found | Refresh the app/Hotlist and copy the current stable finding ID and tenant context. | diff --git a/mcp.md b/mcp.md index e559698..109865e 100644 --- a/mcp.md +++ b/mcp.md @@ -16,6 +16,9 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn | `codeant_scans_get` | read | Severity/category summary for one scan. | | `codeant_scans_results` | read | Full findings (SAST, SCA, secrets, IaC, …) for one scan. | | `codeant_scans_dismissed` | read | Dismissed alerts for a repo. | +| `codeant_hotlist_list` | read | Prioritized organization-wide Hotlist findings with stable IDs. | +| `codeant_hotlist_get` | read | One complete Hotlist finding by stable ID. | +| `codeant_api_get` | read | Authenticated GET request to any relative CodeAnt API path. | | `codeant_pr_list` | read | List PRs/MRs across GitHub, GitLab, Bitbucket, Azure DevOps. | | `codeant_pr_get` | read | Detail for a PR/MR. | | `codeant_pr_comments` | read | Comments on a PR, filtered. | @@ -23,9 +26,12 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn | `codeant_review_local` | read | Run a CodeAnt review on local working-copy changes. | | `codeant_scans_start` | **write** | Trigger a new scan. Gated. | | `codeant_pr_resolve` | **write** | Resolve a PR conversation thread. Gated. | +| `codeant_api_request` | **write** | Authenticated POST/PUT/PATCH/DELETE request to a relative CodeAnt API path. Gated. | Write tools are only registered when `CODEANT_READ_ONLY=0`. Default = read-only. +For Hotlist examples, raw API syntax, tenant/provider selection, and response details, see [cli-api.md](cli-api.md). + Every tool carries MCP annotations (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) so the client can decide whether to auto-approve calls. ## Configuration (env vars) @@ -148,7 +154,7 @@ cd dist/mcpb-stage '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'; sleep 1) | node server/index.js ``` -Expect 11 tools in the `tools/list` response (or 13 if `CODEANT_READ_ONLY=0`). +Expect 16 tools in the `tools/list` response (or 19 if `CODEANT_READ_ONLY=0`). ### Bumping the version @@ -173,7 +179,7 @@ CodeAnt's MCP server uses stdio + a packaged bundle, so the submission route is - **Submission URL:** https://claude.com/docs/connectors/building/submission - **Bundle:** upload `dist/codeant.mcpb` - **Required metadata:** already in [mcpb/manifest.json](mcpb/manifest.json) — display name, description, author, homepage, documentation, repository, license, keywords, `privacy_policies`, `tools` static listing, `user_config` schema. -- **Privacy policy.** Linked from both [README.md](README.md#privacy-policy) and the manifest's `privacy_policies` field (`https://codeant.ai/privacy`). +- **Privacy policy.** Linked from both [README.md](README.md#privacy-policy) and the manifest's `privacy_policies` field (`https://www.codeant.ai/privacy-policy`). Reviewer notes worth preparing: diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 1ea5079..5400ec2 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -2,17 +2,17 @@ "manifest_version": "0.3", "name": "codeant", "display_name": "CodeAnt AI", - "version": "0.5.1", + "version": "0.5.3", "description": "Drive CodeAnt AI security scans and code review from Claude — org-wide secret triage, cross-repo SAST/SCA findings, on-demand scans, and local PR review.", - "long_description": "CodeAnt AI inside Claude. Ask things like \"how many critical SAST findings do I have across my org?\", \"show every exposed secret in payments-service\", or \"review my staged changes\" — Claude calls the CodeAnt API directly via this MCP server.\n\nIncludes 11 read-only tools (orgs, repos, scan history, scan metadata, findings, dismissed alerts, PRs, comments, comment search, local review) and 2 opt-in write tools (trigger a scan, resolve a PR conversation) gated behind a setting.\n\nRequires a CodeAnt account. Sign up at https://codeant.ai. To authenticate, call the `codeant_login` tool — it opens the CodeAnt sign-in page in your browser and saves the token automatically.\n\nCollects anonymous usage telemetry via PostHog by default; set CODEANT_TELEMETRY_DISABLED=1 to opt out.", + "long_description": "CodeAnt AI inside Claude. Ask things like \"show my highest-priority Hotlist findings\", \"how many critical SAST findings do I have across my org?\", or \"review my staged changes\" — Claude calls the CodeAnt API directly via this MCP server.\n\nIncludes 16 read-only tools, including organization Hotlist list/get and an authenticated GET escape hatch for new APIs, plus 3 opt-in write tools gated behind a setting.\n\nRequires a CodeAnt account. Sign up at https://codeant.ai. To authenticate, call the `codeant_login` tool — it opens the CodeAnt sign-in page in your browser and saves the token automatically.\n\nCollects anonymous usage telemetry via PostHog by default; set CODEANT_TELEMETRY_DISABLED=1 to opt out.", "author": { "name": "CodeAnt AI", "email": "support@codeant.ai", "url": "https://codeant.ai" }, "homepage": "https://codeant.ai", - "documentation": "https://docs.codeant.ai/cli/claude-code-plugin", - "support": "https://docs.codeant.ai/support", + "documentation": "https://github.com/CodeAnt-AI/codeant-cli#readme", + "support": "https://github.com/CodeAnt-AI/codeant-cli/issues", "repository": { "type": "git", "url": "https://github.com/CodeAnt-AI/codeant-cli" @@ -65,6 +65,9 @@ { "name": "codeant_scans_get", "description": "Get summary metadata for a single scan (no findings)." }, { "name": "codeant_scans_results", "description": "Fetch full findings (SAST, SCA, secrets, IaC, etc.) for a scan." }, { "name": "codeant_scans_dismissed", "description": "List dismissed alerts for a repository." }, + { "name": "codeant_hotlist_list", "description": "List prioritized organization-wide Hotlist findings with stable IDs." }, + { "name": "codeant_hotlist_get", "description": "Fetch one complete Hotlist finding by its stable ID." }, + { "name": "codeant_api_get", "description": "Call any authenticated GET endpoint on the configured CodeAnt API host." }, { "name": "codeant_pr_list", "description": "List pull requests / merge requests across GitHub, GitLab, Bitbucket, Azure DevOps." }, { "name": "codeant_pr_get", "description": "Fetch detailed information for a single PR/MR." }, { "name": "codeant_pr_comments", "description": "List comments on a PR/MR with optional filters." }, @@ -73,7 +76,8 @@ { "name": "codeant_login", "description": "Open app.codeant.ai in the browser and poll until the user completes sign-in; saves the resulting API token." }, { "name": "codeant_logout", "description": "Clear the saved API token and sign out of CodeAnt AI." }, { "name": "codeant_scans_start", "description": "Trigger a new scan run (write — gated behind read_only=false)." }, - { "name": "codeant_pr_resolve", "description": "Resolve a PR conversation thread (write — gated behind read_only=false)." } + { "name": "codeant_pr_resolve", "description": "Resolve a PR conversation thread (write — gated behind read_only=false)." }, + { "name": "codeant_api_request", "description": "Call an authenticated POST, PUT, PATCH, or DELETE API (write — gated behind read_only=false)." } ], "user_config": { "api_token": { diff --git a/package.json b/package.json index c57aa2c..64babe4 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "./scans/scan-history": "./src/scans/getScanHistory.js", "./scans/fetch-results": "./src/scans/fetchScanResults.js", "./scans/fetch-advanced-results": "./src/scans/fetchAdvancedScanResults.js", - "./scans/dismissed-alerts": "./src/scans/fetchDismissedAlerts.js" + "./scans/dismissed-alerts": "./src/scans/fetchDismissedAlerts.js", + "./hotlist": "./src/hotlist/client.js", + "./api": "./src/commands/api/request.js" }, "files": [ "src" diff --git a/src/commands/api/index.js b/src/commands/api/index.js new file mode 100644 index 0000000..247a4e1 --- /dev/null +++ b/src/commands/api/index.js @@ -0,0 +1,27 @@ +import { runApiRequest } from './request.js'; + +function collect(value, previous) { + return [...previous, value]; +} + +export default function registerApiCommands(program, { runCmd }) { + const api = program + .command('api') + .description('Call any authenticated CodeAnt application API'); + + api + .command('request ') + .description('Send an authenticated request to a relative CodeAnt API path') + .option('--query ', 'Query parameters as a JSON object') + .option('--body ', 'JSON request body') + .option('--body-file ', 'Read the JSON request body from a file') + .option('-H, --header
', 'Additional header (repeatable, "Name: value")', collect, []) + .action((method, path, options) => runCmd(() => runApiRequest({ + method, + path, + query: options.query, + body: options.body, + bodyFile: options.bodyFile, + headers: options.header, + }))); +} diff --git a/src/commands/api/request.js b/src/commands/api/request.js new file mode 100644 index 0000000..4d0bbd8 --- /dev/null +++ b/src/commands/api/request.js @@ -0,0 +1,77 @@ +import { readFile } from 'node:fs/promises'; + +import { fetchApiResponse } from '../../utils/fetchApi.js'; + +const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']); +const BLOCKED_HEADERS = new Set(['authorization', 'cookie', 'host', 'content-length']); + +export function normalizeApiPath(path) { + const value = String(path || '').trim(); + if (!value.startsWith('/') || value.startsWith('//')) { + throw new Error('API path must be relative to the configured CodeAnt API URL and start with `/`.'); + } + const resolved = new URL(value, 'https://codeant.invalid'); + if (resolved.origin !== 'https://codeant.invalid') { + throw new Error('API path must stay on the configured CodeAnt API host.'); + } + return `${resolved.pathname}${resolved.search}`; +} + +export function parseJsonObject(value, label) { + const parsed = parseJsonValue(value, label); + if (parsed === undefined) return undefined; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${label} must be a JSON object.`); + } + return parsed; +} + +export function parseJsonValue(value, label) { + if (value === undefined || value === null || value === '') return undefined; + let parsed; + try { + parsed = typeof value === 'string' ? JSON.parse(value) : value; + } catch (error) { + throw new Error(`Invalid ${label} JSON: ${error.message}`); + } + return parsed; +} + +export function parseHeaders(values = []) { + const headers = {}; + for (const entry of values || []) { + const separator = String(entry).indexOf(':'); + if (separator < 1) throw new Error(`Invalid header ${JSON.stringify(entry)}; expected "Name: value".`); + const name = String(entry).slice(0, separator).trim(); + const value = String(entry).slice(separator + 1).trim(); + if (BLOCKED_HEADERS.has(name.toLowerCase())) { + throw new Error(`Header ${name} is managed by CodeAnt CLI and cannot be overridden.`); + } + headers[name] = value; + } + return headers; +} + +export async function runApiRequest({ path, method, query, body, bodyFile, headers } = {}) { + const normalizedMethod = String(method || 'GET').toUpperCase(); + if (!METHODS.has(normalizedMethod)) { + throw new Error(`Unsupported method ${normalizedMethod}. Use ${[...METHODS].join(', ')}.`); + } + if (body !== undefined && bodyFile) { + throw new Error('Use either --body or --body-file, not both.'); + } + + let requestBody = parseJsonValue(body, 'body'); + if (bodyFile) { + requestBody = parseJsonValue(await readFile(bodyFile, 'utf8'), 'body file'); + } + + const response = await fetchApiResponse(normalizeApiPath(path), { + method: normalizedMethod, + query: parseJsonObject(query, 'query'), + body: requestBody, + headers: parseHeaders(headers), + allowHttpError: true, + }); + return { ok: response.ok, status: response.status, data: response.data }; +} diff --git a/src/commands/hotlist/index.js b/src/commands/hotlist/index.js new file mode 100644 index 0000000..30a1f5a --- /dev/null +++ b/src/commands/hotlist/index.js @@ -0,0 +1,58 @@ +import { runHotlistGet, runHotlistList } from '../../hotlist/client.js'; + +function addTenantOptions(command) { + return command + .option('--org ', 'Organization name (auto-picked when unambiguous)') + .option('--service ', 'github, gitlab, bitbucket, or azuredevops') + .option('--provider-base-url ', 'Override the authenticated provider base URL') + .option('--max-wait ', 'Wait for an initial Hotlist build', Number, 60); +} + +export default function registerHotlistCommands(program, { runCmd }) { + const hotlist = program + .command('hotlist') + .description('Query organization-wide prioritized Hotlist findings'); + + addTenantOptions( + hotlist + .command('list') + .description('List Hotlist findings using the same filters and ranking as the app') + .option('--search ', 'Search title, repository, path, package, CVE, or check ID') + .option('--type ', 'Comma-separated finding types') + .option('--location ', 'Comma-separated repositories or cloud accounts') + .option('--severity ', 'Comma-separated severities') + .option('--ticket-status ', 'created,not_created') + .option('--compliance ', 'Comma-separated compliance frameworks') + .option('--validation ', 'Comma-separated validation flags (for example exploit_confirmed)') + .option('--limit ', 'Page size from 1 to 100', Number, 30) + .option('--cursor ', 'Continue from a previous next_cursor') + .option('--all', 'Fetch every matching page', false), + ).action((options) => runCmd(() => runHotlistList({ + org: options.org, + service: options.service, + providerBaseUrl: options.providerBaseUrl, + maxWaitSeconds: options.maxWait, + search: options.search, + types: options.type, + locations: options.location, + severities: options.severity, + ticketStatuses: options.ticketStatus, + compliance: options.compliance, + validation: options.validation, + limit: options.limit, + cursor: options.cursor, + all: options.all, + }))); + + addTenantOptions( + hotlist + .command('get ') + .description('Get one finding by the stable ID shown in the Hotlist detail panel'), + ).action((findingId, options) => runCmd(() => runHotlistGet({ + findingId, + org: options.org, + service: options.service, + providerBaseUrl: options.providerBaseUrl, + maxWaitSeconds: options.maxWait, + }))); +} diff --git a/src/hotlist/client.js b/src/hotlist/client.js new file mode 100644 index 0000000..dc851e3 --- /dev/null +++ b/src/hotlist/client.js @@ -0,0 +1,138 @@ +import { validateConnection } from '../scans/connectionHandler.js'; +import { fetchApi } from '../utils/fetchApi.js'; + +const SERVICE_ALIASES = { azure_devops: 'azuredevops', ado: 'azuredevops' }; +const PROVIDER_BASE_FIELDS = { + github: 'github_base_url', + gitlab: 'gitlab_base_url', + bitbucket: 'bitbucket_base_url', + azuredevops: 'azure_devops_base_url', +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function normalizeService(value) { + const service = String(value || '').trim().toLowerCase(); + return SERVICE_ALIASES[service] || service; +} + +function splitValues(value) { + const values = Array.isArray(value) ? value : value ? [value] : []; + return values.flatMap((entry) => String(entry).split(',')).map((entry) => entry.trim()).filter(Boolean); +} + +export function hotlistFilters(options = {}) { + return { + types: splitValues(options.types), + locations: splitValues(options.locations), + severities: splitValues(options.severities).map((value) => value.toLowerCase()), + ticket_statuses: splitValues(options.ticketStatuses), + compliance: splitValues(options.compliance).map((value) => value.toLowerCase()), + validation: splitValues(options.validation), + }; +} + +export async function resolveHotlistTenant({ org, service, providerBaseUrl } = {}) { + const validation = await validateConnection(); + if (!validation.success) { + throw new Error(validation.error || 'Unable to load authenticated CodeAnt organizations.'); + } + const requestedService = normalizeService(service); + const candidates = (validation.connections || []).filter((connection) => { + const orgMatches = !org || connection.organizationName === org; + const serviceMatches = !requestedService || normalizeService(connection.service) === requestedService; + return orgMatches && serviceMatches; + }); + if (candidates.length === 0) { + throw new Error('No authenticated organization matches --org/--service. Run `codeant scans orgs` to list available connections.'); + } + if (candidates.length > 1) { + throw new Error('More than one organization matches. Pass both --org and --service; run `codeant scans orgs` to list values.'); + } + + const connection = candidates[0]; + const normalizedService = normalizeService(connection.service); + const baseField = PROVIDER_BASE_FIELDS[normalizedService]; + if (!baseField) throw new Error(`Hotlist is not supported for service ${connection.service}.`); + const organization = org || connection.organizationName; + const baseUrl = providerBaseUrl || connection.baseUrl; + if (!baseUrl) { + throw new Error('The provider base URL is unavailable. Pass --provider-base-url explicitly.'); + } + return { + organization, + service: normalizedService, + providerBaseUrl: baseUrl, + requestBody: { + org: organization, + organization, + service: normalizedService, + [baseField]: baseUrl, + }, + }; +} + +async function requestReady(endpoint, body, maxWaitSeconds = 60) { + const deadline = Date.now() + Math.max(0, Number(maxWaitSeconds) || 0) * 1000; + while (true) { + const response = await fetchApi(endpoint, 'POST', body); + if (response?.state !== 'building') return response; + if (Date.now() >= deadline) { + throw new Error('Hotlist is still building. Retry the command in a few seconds or increase --max-wait.'); + } + const retrySeconds = Math.min(Math.max(Number(response.retry_after_seconds) || 3, 1), 10); + await sleep(retrySeconds * 1000); + } +} + +function pageLimit(value) { + const limit = value === undefined ? 30 : Number(value); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new Error('--limit must be an integer between 1 and 100.'); + } + return limit; +} + +export async function runHotlistList(options = {}) { + const tenant = await resolveHotlistTenant(options); + const request = { + ...tenant.requestBody, + search: options.search || '', + filters: hotlistFilters(options), + limit: pageLimit(options.limit), + cursor: options.cursor || null, + }; + let result = await requestReady('/explorer/security/hotlist/query', request, options.maxWaitSeconds); + if (!options.all) return { tenant: tenant.requestBody, ...result }; + + const items = [...(result.items || [])]; + while (result.has_more && result.next_cursor) { + result = await requestReady( + '/explorer/security/hotlist/query', + { ...request, cursor: result.next_cursor }, + options.maxWaitSeconds, + ); + items.push(...(result.items || [])); + } + return { + tenant: tenant.requestBody, + ...result, + items, + returned_count: items.length, + next_cursor: null, + has_more: false, + }; +} + +export async function runHotlistGet({ findingId, ...options } = {}) { + if (!/^[0-9a-f]{32}$/i.test(String(findingId || ''))) { + throw new Error('Finding ID must be the 32-character stable ID shown in Hotlist.'); + } + const tenant = await resolveHotlistTenant(options); + const result = await requestReady( + '/explorer/security/hotlist/finding', + { ...tenant.requestBody, finding_id: String(findingId).toLowerCase() }, + options.maxWaitSeconds, + ); + return { tenant: tenant.requestBody, ...result }; +} diff --git a/src/index.js b/src/index.js index 4c30079..bc9062b 100755 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,8 @@ import { setConfigValue } from './utils/config.js'; import { track, shutdown as analyticsShutdown, isTelemetryDisabled } from './utils/analytics.js'; import registerScansCommands from './commands/scans/index.js'; import registerSettingsCommands from './commands/settings/index.js'; +import registerApiCommands from './commands/api/index.js'; +import registerHotlistCommands from './commands/hotlist/index.js'; // Read version from package.json const require = createRequire(import.meta.url); @@ -397,6 +399,12 @@ program // ─── Settings commands ─── registerSettingsCommands(program, { runCmd }); + // ─── Authenticated application API passthrough ─── + registerApiCommands(program, { runCmd }); + + // ─── Organization Hotlist findings ─── + registerHotlistCommands(program, { runCmd }); + // ─── MCP server (for Claude Code plugin and other MCP clients) ─── program .command('mcp') diff --git a/src/mcp/server.js b/src/mcp/server.js index 2baeb0e..0cc9b21 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -14,6 +14,8 @@ import { runReviewHeadless } from '../reviewHeadless.js'; import * as scm from '../scm/index.js'; import { isAlreadyLoggedIn, runLoginFlow } from '../utils/loginFlow.js'; import { getConfigValue, setConfigValue } from '../utils/config.js'; +import { runHotlistGet, runHotlistList } from '../hotlist/client.js'; +import { runApiRequest } from '../commands/api/request.js'; const require = createRequire(import.meta.url); const pkg = require('../../package.json'); @@ -215,6 +217,73 @@ export async function startMcpServer() { } ); + // ─── Organization Hotlist findings (read-only) ────────────────────────── + server.registerTool( + 'codeant_hotlist_list', + { + title: 'List prioritized Hotlist findings', + description: 'Query the organization-wide Hotlist using the same stable IDs, ranking, filters, and pagination as the CodeAnt app. Use this for cross-repository security prioritization and agent triage.', + inputSchema: { + org: z.string().optional().describe('Organization name. Auto-picked when exactly one connection matches.'), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional().describe('Override only for a self-hosted provider.'), + search: z.string().optional(), + types: z.array(z.string()).optional(), + locations: z.array(z.string()).optional(), + severities: z.array(z.enum(['critical', 'high', 'medium', 'low', 'unknown'])).optional(), + ticketStatuses: z.array(z.enum(['created', 'not_created'])).optional(), + compliance: z.array(z.string()).optional(), + validation: z.array(z.enum(['exploit_confirmed'])).optional(), + limit: z.number().int().positive().max(100).optional(), + cursor: z.string().optional(), + all: z.boolean().optional().describe('Fetch every matching page. Default false.'), + maxWaitSeconds: z.number().int().nonnegative().max(600).optional(), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runHotlistList(input)); } catch (err) { return fail(err); } + } + ); + + server.registerTool( + 'codeant_hotlist_get', + { + title: 'Get a Hotlist finding', + description: 'Fetch one complete Hotlist finding by its 32-character stable ID. Use the ID displayed in the app or returned by codeant_hotlist_list.', + inputSchema: { + findingId: z.string().regex(/^[0-9a-f]{32}$/i), + org: z.string().optional(), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional(), + maxWaitSeconds: z.number().int().nonnegative().max(600).optional(), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runHotlistGet(input)); } catch (err) { return fail(err); } + } + ); + + // Generic GET keeps newly-added read APIs available without a CLI release. + // Non-GET requests are registered below only when write mode is enabled. + server.registerTool( + 'codeant_api_get', + { + title: 'Call a CodeAnt GET API', + description: 'Call any authenticated GET endpoint on the configured CodeAnt API host. The path must be relative (for example /extension/scans2/validate); absolute URLs are rejected.', + inputSchema: { + path: z.string().startsWith('/'), + query: z.record(z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional(), + headers: z.array(z.string()).optional().describe('Optional repeatable "Name: value" headers. Authorization cannot be overridden.'), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runApiRequest({ method: 'GET', ...input })); } catch (err) { return fail(err); } + } + ); + // ─── Pull requests (SCM, read-only) ────────────────────────────────────── server.registerTool( 'codeant_pr_list', @@ -429,6 +498,25 @@ export async function startMcpServer() { // ─── Write-side tools (gated behind CODEANT_READ_ONLY=0) ───────────────── if (!readOnly) { + server.registerTool( + 'codeant_api_request', + { + title: 'Call a CodeAnt write API', + description: 'Call an authenticated POST, PUT, PATCH, or DELETE endpoint on the configured CodeAnt API host. WRITE OPERATION — only enabled when CODEANT_READ_ONLY=0. Absolute URLs are rejected.', + inputSchema: { + method: z.enum(['POST', 'PUT', 'PATCH', 'DELETE']), + path: z.string().startsWith('/'), + query: z.record(z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional(), + body: z.unknown().optional(), + headers: z.array(z.string()).optional(), + }, + annotations: WRITE_NON_DESTRUCTIVE, + }, + async (input) => { + try { return ok(await runApiRequest(input)); } catch (err) { return fail(err); } + } + ); + server.registerTool( 'codeant_scans_start', { diff --git a/src/utils/fetchApi.js b/src/utils/fetchApi.js index d85e1a2..23ed3a1 100644 --- a/src/utils/fetchApi.js +++ b/src/utils/fetchApi.js @@ -23,13 +23,59 @@ const RETRYABLE_CAUSES = new Set([ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const fetchApi = async (endpoint, method = 'GET', body = null) => { - const url = endpoint.startsWith('http') ? endpoint : `${getBaseUrl()}${endpoint}`; +function responseMessage(data, status) { + if (typeof data?.error?.message === 'string') return data.error.message; + if (typeof data?.message === 'string') return data.message; + if (typeof data?.error === 'string') return data.error; + return `HTTP error ${status}`; +} + +function appendQuery(url, query) { + if (!query || typeof query !== 'object') return url; + const parsed = new URL(url); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const item of value) parsed.searchParams.append(key, String(item)); + } else { + parsed.searchParams.set(key, String(value)); + } + } + return parsed.toString(); +} + +async function parseResponse(response) { + if (response.status === 204 || response.status === 205) return null; + const text = await response.text(); + if (!text) return null; + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('json') || /^[\s]*[\[{]/.test(text)) { + try { + return JSON.parse(text); + } catch { + // A mislabeled response should still be inspectable by the caller. + } + } + return text; +} + +const fetchApiResponse = async (endpoint, { + method = 'GET', + body = null, + headers = {}, + query = null, + allowHttpError = false, +} = {}) => { + const baseUrl = getBaseUrl(); + const resolvedUrl = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`; + const url = appendQuery(resolvedUrl, query); + const normalizedMethod = String(method || 'GET').toUpperCase(); const options = { - method, + method: normalizedMethod, headers: { 'Content-Type': 'application/json', + ...headers, }, }; @@ -39,7 +85,7 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => { options.headers['Authorization'] = `Bearer ${token}`; } - if (body && method !== 'GET') { + if (body !== null && body !== undefined && !['GET', 'HEAD'].includes(normalizedMethod)) { options.body = JSON.stringify(body); } @@ -55,13 +101,21 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => { throw new Error('Access denied (403). Please run `codeant logout` and then `codeant login` to re-authenticate.'); } - const data = await response.json(); + const data = await parseResponse(response); - if (!response.ok) { - throw new Error(data.message || `HTTP error ${response.status}`); + if (!response.ok && !allowHttpError) { + const error = new Error(responseMessage(data, response.status)); + error.status = response.status; + error.data = data; + throw error; } - return data; + return { + ok: response.ok, + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + data, + }; } catch (err) { lastErr = err; const cause = err?.cause?.code || err?.cause?.message || err?.cause || ''; @@ -78,4 +132,9 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => { throw lastErr; }; -export { fetchApi }; +const fetchApi = async (endpoint, method = 'GET', body = null) => { + const response = await fetchApiResponse(endpoint, { method, body }); + return response.data; +}; + +export { fetchApi, fetchApiResponse }; diff --git a/tests/apiRequest.test.js b/tests/apiRequest.test.js new file mode 100644 index 0000000..991ae90 --- /dev/null +++ b/tests/apiRequest.test.js @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { fetchApiResponse } = vi.hoisted(() => ({ fetchApiResponse: vi.fn() })); + +vi.mock('../src/utils/fetchApi.js', () => ({ fetchApiResponse })); + +const { + normalizeApiPath, + parseHeaders, + parseJsonObject, + parseJsonValue, + runApiRequest, +} = await import('../src/commands/api/request.js'); + +describe('authenticated API request', () => { + beforeEach(() => fetchApiResponse.mockReset()); + + it('accepts only paths on the configured CodeAnt API host', () => { + expect(normalizeApiPath('/extension/scans2/validate?x=1')).toBe('/extension/scans2/validate?x=1'); + expect(() => normalizeApiPath('https://example.com/steal')).toThrow(/must be relative/); + expect(() => normalizeApiPath('//example.com/steal')).toThrow(/must be relative/); + }); + + it('rejects auth and transport header overrides', () => { + expect(parseHeaders(['If-Match: revision-1'])).toEqual({ 'If-Match': 'revision-1' }); + expect(() => parseHeaders(['Authorization: Bearer attacker'])).toThrow(/cannot be overridden/); + expect(() => parseHeaders(['Cookie: session=secret'])).toThrow(/cannot be overridden/); + }); + + it('requires an object query and accepts any JSON body value', () => { + expect(parseJsonObject('{"page":1}', 'query')).toEqual({ page: 1 }); + expect(() => parseJsonObject('[1]', 'body')).toThrow(/must be a JSON object/); + expect(parseJsonValue('[1,2]', 'body')).toEqual([1, 2]); + }); + + it('returns status and data from an authenticated relative request', async () => { + fetchApiResponse.mockResolvedValue({ ok: true, status: 200, data: { result: 'ok' } }); + + const result = await runApiRequest({ + path: '/example', + method: 'post', + query: '{"page":2}', + body: '{"org":"CodeAnt-AI"}', + headers: ['If-Match: revision-1'], + }); + + expect(fetchApiResponse).toHaveBeenCalledWith('/example', { + method: 'POST', + query: { page: 2 }, + body: { org: 'CodeAnt-AI' }, + headers: { 'If-Match': 'revision-1' }, + allowHttpError: true, + }); + expect(result).toEqual({ ok: true, status: 200, data: { result: 'ok' } }); + }); +}); diff --git a/tests/fetchApi.test.js b/tests/fetchApi.test.js new file mode 100644 index 0000000..8e3ec18 --- /dev/null +++ b/tests/fetchApi.test.js @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { getConfigValue, getBaseUrl } = vi.hoisted(() => ({ + getConfigValue: vi.fn(), + getBaseUrl: vi.fn(), +})); + +vi.mock('../src/utils/config.js', () => ({ getConfigValue })); +vi.mock('../src/utils/baseUrl.js', () => ({ getBaseUrl })); + +const { fetchApi, fetchApiResponse } = await import('../src/utils/fetchApi.js'); + +describe('fetchApi', () => { + beforeEach(() => { + getBaseUrl.mockReturnValue('https://api.codeant.test'); + getConfigValue.mockReturnValue(null); + process.env.CODEANT_API_TOKEN = 'test-token'; + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + delete process.env.CODEANT_API_TOKEN; + vi.unstubAllGlobals(); + }); + + it('preserves the existing data-only API and bearer authentication', async () => { + fetch.mockResolvedValue(new Response(JSON.stringify({ status: 'success' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + await expect(fetchApi('/validate', 'POST', { extension: 'cli' })).resolves.toEqual({ status: 'success' }); + expect(fetch).toHaveBeenCalledWith('https://api.codeant.test/validate', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer test-token' }), + body: JSON.stringify({ extension: 'cli' }), + })); + }); + + it('returns status metadata and supports array query parameters', async () => { + fetch.mockResolvedValue(new Response('plain text', { + status: 200, + headers: { 'content-type': 'text/plain' }, + })); + + await expect(fetchApiResponse('/items', { query: { tag: ['a', 'b'] } })).resolves.toEqual(expect.objectContaining({ + ok: true, + status: 200, + data: 'plain text', + })); + expect(fetch.mock.calls[0][0]).toBe('https://api.codeant.test/items?tag=a&tag=b'); + }); + + it('surfaces nested API error messages', async () => { + fetch.mockResolvedValue(new Response(JSON.stringify({ error: { message: 'Finding not found' } }), { + status: 404, + headers: { 'content-type': 'application/json' }, + })); + + await expect(fetchApi('/missing')).rejects.toThrow('Finding not found'); + }); +}); diff --git a/tests/hotlist.test.js b/tests/hotlist.test.js new file mode 100644 index 0000000..45006c0 --- /dev/null +++ b/tests/hotlist.test.js @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { validateConnection, fetchApi } = vi.hoisted(() => ({ + validateConnection: vi.fn(), + fetchApi: vi.fn(), +})); + +vi.mock('../src/scans/connectionHandler.js', () => ({ validateConnection })); +vi.mock('../src/utils/fetchApi.js', () => ({ fetchApi })); + +const { resolveHotlistTenant, runHotlistGet, runHotlistList } = await import('../src/hotlist/client.js'); + +const connectionResult = { + success: true, + connections: [ + { organizationName: 'CodeAnt-AI', service: 'github', baseUrl: 'https://github.example.com' }, + ], +}; + +describe('Hotlist client', () => { + beforeEach(() => { + validateConnection.mockReset(); + fetchApi.mockReset(); + validateConnection.mockResolvedValue(connectionResult); + }); + + it('resolves provider context from the authenticated connection', async () => { + await expect(resolveHotlistTenant({ org: 'CodeAnt-AI', service: 'github' })).resolves.toEqual({ + organization: 'CodeAnt-AI', + service: 'github', + providerBaseUrl: 'https://github.example.com', + requestBody: { + org: 'CodeAnt-AI', + organization: 'CodeAnt-AI', + service: 'github', + github_base_url: 'https://github.example.com', + }, + }); + }); + + it('passes UI-compatible filters and follows every cursor', async () => { + fetchApi + .mockResolvedValueOnce({ state: 'ready', items: [{ id: 'a' }], has_more: true, next_cursor: 'next' }) + .mockResolvedValueOnce({ state: 'ready', items: [{ id: 'b' }], has_more: false, next_cursor: null }); + + const result = await runHotlistList({ + org: 'CodeAnt-AI', + service: 'github', + severities: 'critical,high', + validation: 'exploit_confirmed', + limit: 25, + all: true, + }); + + expect(fetchApi).toHaveBeenNthCalledWith(1, '/explorer/security/hotlist/query', 'POST', expect.objectContaining({ + filters: expect.objectContaining({ + severities: ['critical', 'high'], + validation: ['exploit_confirmed'], + }), + limit: 25, + cursor: null, + })); + expect(fetchApi).toHaveBeenNthCalledWith(2, '/explorer/security/hotlist/query', 'POST', expect.objectContaining({ cursor: 'next' })); + expect(result.items).toEqual([{ id: 'a' }, { id: 'b' }]); + expect(result.returned_count).toBe(2); + }); + + it('gets one finding by stable ID', async () => { + const findingId = '0123456789abcdef0123456789abcdef'; + fetchApi.mockResolvedValue({ state: 'ready', item: { id: findingId } }); + + const result = await runHotlistGet({ findingId, org: 'CodeAnt-AI', service: 'github' }); + + expect(fetchApi).toHaveBeenCalledWith('/explorer/security/hotlist/finding', 'POST', expect.objectContaining({ + finding_id: findingId, + organization: 'CodeAnt-AI', + service: 'github', + })); + expect(result.item.id).toBe(findingId); + }); + + it('rejects unstable or malformed finding identifiers', async () => { + await expect(runHotlistGet({ findingId: 'src/app.py:12' })).rejects.toThrow(/32-character stable ID/); + expect(validateConnection).not.toHaveBeenCalled(); + }); +}); From b437ce7f40fdeab58a8c8b44dad92350998326ac Mon Sep 17 00:00:00 2001 From: Sagar Kalsaria Date: Wed, 26 Aug 2026 19:23:47 +0530 Subject: [PATCH 2/3] COD-1178: route app APIs through CLI auth context --- README.md | 4 +-- cli-api.md | 25 +++++++++++--- mcp.md | 4 +-- src/api/tenant.js | 54 ++++++++++++++++++++++++++++++ src/commands/api/index.js | 6 ++++ src/commands/api/request.js | 38 ++++++++++++++++++++-- src/commands/logout.js | 19 ++++++++--- src/hotlist/client.js | 65 +++++-------------------------------- src/mcp/server.js | 18 +++++++--- src/utils/fetchApi.js | 13 +++++++- src/utils/logout.js | 20 ++++++++++++ tests/apiRequest.test.js | 33 +++++++++++++++++-- tests/fetchApi.test.js | 28 ++++++++++++++++ tests/hotlist.test.js | 22 ++++++------- tests/logout.test.js | 45 +++++++++++++++++++++++++ 15 files changed, 300 insertions(+), 94 deletions(-) create mode 100644 src/api/tenant.js create mode 100644 src/utils/logout.js create mode 100644 tests/logout.test.js diff --git a/README.md b/README.md index 58900f8..a49989f 100644 --- a/README.md +++ b/README.md @@ -145,8 +145,8 @@ codeant hotlist get 0123456789abcdef0123456789abcdef --org CodeAnt-AI --service Call any CodeAnt application API using the saved bearer token. Only relative paths on the configured CodeAnt API host are accepted. ```bash -codeant api request GET /some/read/endpoint --query '{"page":1}' -codeant api request POST /some/app/endpoint --body '{"org":"CodeAnt-AI"}' +codeant api request GET /some/read/endpoint --org CodeAnt-AI --service github --query '{"page":1}' +codeant api request POST /some/app/endpoint --org CodeAnt-AI --service github --body '{"repo":"CodeAnt-AI/example"}' ``` See [cli-api.md](cli-api.md) for the complete Hotlist, raw API, authentication, self-hosted provider, and agent/MCP manual. diff --git a/cli-api.md b/cli-api.md index ae36081..85bc865 100644 --- a/cli-api.md +++ b/cli-api.md @@ -1,6 +1,6 @@ # CodeAnt application APIs from the CLI -The CLI uses the same authenticated CodeAnt API and organization/provider context as the web app. Sign in once, discover the connections available to that token, then use a first-class command or the generic API request command. +The CLI can call the authenticated application endpoints used by the CodeAnt web app without adding a second backend adapter for each endpoint. Sign in once, select one exact organization/provider connection, then use a first-class command or the generic API request command. ```bash codeant login @@ -9,6 +9,10 @@ codeant scans orgs `CODEANT_API_TOKEN` and `CODEANT_API_URL` can be used instead of the saved login for agents, CI, and self-hosted installations. +The browser login binds the CLI key to the signed-in user and the exact connections visible to that user. For application API calls, the backend resolves the selected connection and injects the same verified user identity used by the app. The existing organization-membership, RBAC, repository-access, audit, and request guards still run. CLI keys expire after 90 days by default (`CLI_API_KEY_TTL_DAYS` controls the backend deployment value), and `codeant logout` revokes the key server-side before deleting it locally. + +Keys created before this authenticated application-API bridge do not contain the verified user identity. Run `codeant logout` followed by `codeant login` once after upgrading. + ## Hotlist findings Hotlist commands use the same organization-wide snapshot, ranking, filters, stable finding IDs, and cursor pagination as the app. @@ -58,12 +62,16 @@ For self-hosted GitHub, GitLab, Bitbucket, or Azure DevOps, the CLI normally dis Use the generic request command when a first-class command does not exist yet: ```bash -codeant api request GET /some/read/endpoint --query '{"page":1}' +codeant api request GET /some/read/endpoint \ + --org CodeAnt-AI --service github \ + --query '{"page":1}' codeant api request POST /some/app/endpoint \ - --body '{"org":"CodeAnt-AI","service":"github"}' + --org CodeAnt-AI --service github \ + --body '{"repo":"CodeAnt-AI/example"}' codeant api request PATCH /some/app/endpoint \ + --org CodeAnt-AI --service github \ --body-file ./request.json \ --header 'If-Match: revision-123' ``` @@ -74,6 +82,10 @@ The output is JSON: { "ok": true, "status": 200, + "tenant": { + "org": "CodeAnt-AI", + "service": "github" + }, "data": {} } ``` @@ -81,8 +93,10 @@ The output is JSON: Security properties: - The path must start with `/` and is always resolved against the configured CodeAnt API host. Absolute and protocol-relative URLs are rejected, so the bearer token cannot be forwarded to another host. -- Authentication is supplied from `CODEANT_API_TOKEN` or the token saved by `codeant login`. -- `Authorization`, `Cookie`, `Host`, and `Content-Length` headers cannot be overridden. +- Authentication is supplied from `CODEANT_API_TOKEN` or the key saved by `codeant login`. +- `--org`, `--service`, and the discovered provider base URL must match one saved login connection exactly. They are auto-selected only when unambiguous. Use `--provider-base-url` for a self-hosted override. +- POST/PUT/PATCH/DELETE bodies must be JSON objects. The CLI adds the selected tenant fields before sending the request; conflicting tenant values are rejected by the backend. +- `Authorization`, `Cookie`, `Host`, `Content-Length`, and the `X-CodeAnt-CLI-*` tenant headers cannot be overridden. - The backend remains authoritative for account access, organization membership, RBAC, and endpoint authorization. The generic command can call write endpoints. Review the method, path, and body before running it. @@ -104,5 +118,6 @@ Set `CODEANT_READ_ONLY=0` to opt in to write tools, including `codeant_api_reque | No matching organization | Run `codeant scans orgs`, then pass its exact `organizationName` and `service`. | | Multiple organizations match | Pass both `--org` and `--service`. | | Access denied (403) | Run `codeant logout`, then `codeant login`, or replace `CODEANT_API_TOKEN`. | +| Invalid token after upgrading | Older keys lack verified CLI identity metadata. Run `codeant logout`, then `codeant login`. | | Hotlist is still building | Retry, or increase `--max-wait`. | | Finding not found | Refresh the app/Hotlist and copy the current stable finding ID and tenant context. | diff --git a/mcp.md b/mcp.md index 109865e..05ec8ee 100644 --- a/mcp.md +++ b/mcp.md @@ -18,7 +18,7 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn | `codeant_scans_dismissed` | read | Dismissed alerts for a repo. | | `codeant_hotlist_list` | read | Prioritized organization-wide Hotlist findings with stable IDs. | | `codeant_hotlist_get` | read | One complete Hotlist finding by stable ID. | -| `codeant_api_get` | read | Authenticated GET request to any relative CodeAnt API path. | +| `codeant_api_get` | read | Authenticated GET request to any relative CodeAnt app API path, with exact org/provider context. | | `codeant_pr_list` | read | List PRs/MRs across GitHub, GitLab, Bitbucket, Azure DevOps. | | `codeant_pr_get` | read | Detail for a PR/MR. | | `codeant_pr_comments` | read | Comments on a PR, filtered. | @@ -26,7 +26,7 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn | `codeant_review_local` | read | Run a CodeAnt review on local working-copy changes. | | `codeant_scans_start` | **write** | Trigger a new scan. Gated. | | `codeant_pr_resolve` | **write** | Resolve a PR conversation thread. Gated. | -| `codeant_api_request` | **write** | Authenticated POST/PUT/PATCH/DELETE request to a relative CodeAnt API path. Gated. | +| `codeant_api_request` | **write** | Authenticated POST/PUT/PATCH/DELETE request to a relative CodeAnt app API path, with exact org/provider context. Gated. | Write tools are only registered when `CODEANT_READ_ONLY=0`. Default = read-only. diff --git a/src/api/tenant.js b/src/api/tenant.js new file mode 100644 index 0000000..42a7e5c --- /dev/null +++ b/src/api/tenant.js @@ -0,0 +1,54 @@ +import { validateConnection } from '../scans/connectionHandler.js'; + +const SERVICE_ALIASES = { azure_devops: 'azuredevops', ado: 'azuredevops' }; +const PROVIDER_BASE_FIELDS = { + github: 'github_base_url', + gitlab: 'gitlab_base_url', + bitbucket: 'bitbucket_base_url', + azuredevops: 'azure_devops_base_url', +}; + +export function normalizeService(value) { + const service = String(value || '').trim().toLowerCase(); + return SERVICE_ALIASES[service] || service; +} + +export async function resolveCliTenant({ org, service, providerBaseUrl } = {}) { + const validation = await validateConnection(); + if (!validation.success) { + throw new Error(validation.error || 'Unable to load authenticated CodeAnt organizations.'); + } + const requestedService = normalizeService(service); + const candidates = (validation.connections || []).filter((connection) => { + const orgMatches = !org || connection.organizationName === org; + const serviceMatches = !requestedService || normalizeService(connection.service) === requestedService; + return orgMatches && serviceMatches; + }); + if (candidates.length === 0) { + throw new Error('No authenticated organization matches --org/--service. Run `codeant scans orgs` to list available connections.'); + } + if (candidates.length > 1) { + throw new Error('More than one organization matches. Pass both --org and --service; run `codeant scans orgs` to list values.'); + } + + const connection = candidates[0]; + const normalizedService = normalizeService(connection.service); + const baseField = PROVIDER_BASE_FIELDS[normalizedService]; + if (!baseField) throw new Error(`Application APIs are not supported for service ${connection.service}.`); + const organization = org || connection.organizationName; + const baseUrl = providerBaseUrl || connection.baseUrl; + if (!baseUrl) { + throw new Error('The provider base URL is unavailable. Pass --provider-base-url explicitly.'); + } + return { + organization, + service: normalizedService, + providerBaseUrl: baseUrl, + requestBody: { + org: organization, + organization, + service: normalizedService, + [baseField]: baseUrl, + }, + }; +} diff --git a/src/commands/api/index.js b/src/commands/api/index.js index 247a4e1..6f8e162 100644 --- a/src/commands/api/index.js +++ b/src/commands/api/index.js @@ -15,6 +15,9 @@ export default function registerApiCommands(program, { runCmd }) { .option('--query ', 'Query parameters as a JSON object') .option('--body ', 'JSON request body') .option('--body-file ', 'Read the JSON request body from a file') + .option('--org ', 'Authenticated CodeAnt organization') + .option('--service ', 'SCM provider: github, gitlab, bitbucket, or azuredevops') + .option('--provider-base-url ', 'Provider base URL override for self-hosted SCMs') .option('-H, --header
', 'Additional header (repeatable, "Name: value")', collect, []) .action((method, path, options) => runCmd(() => runApiRequest({ method, @@ -23,5 +26,8 @@ export default function registerApiCommands(program, { runCmd }) { body: options.body, bodyFile: options.bodyFile, headers: options.header, + org: options.org, + service: options.service, + providerBaseUrl: options.providerBaseUrl, }))); } diff --git a/src/commands/api/request.js b/src/commands/api/request.js index 4d0bbd8..6680718 100644 --- a/src/commands/api/request.js +++ b/src/commands/api/request.js @@ -1,9 +1,18 @@ import { readFile } from 'node:fs/promises'; import { fetchApiResponse } from '../../utils/fetchApi.js'; +import { resolveCliTenant } from '../../api/tenant.js'; const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']); -const BLOCKED_HEADERS = new Set(['authorization', 'cookie', 'host', 'content-length']); +const BLOCKED_HEADERS = new Set([ + 'authorization', + 'cookie', + 'host', + 'content-length', + 'x-codeant-cli-org', + 'x-codeant-cli-service', + 'x-codeant-cli-base-url', +]); export function normalizeApiPath(path) { const value = String(path || '').trim(); @@ -52,7 +61,17 @@ export function parseHeaders(values = []) { return headers; } -export async function runApiRequest({ path, method, query, body, bodyFile, headers } = {}) { +export async function runApiRequest({ + path, + method, + query, + body, + bodyFile, + headers, + org, + service, + providerBaseUrl, +} = {}) { const normalizedMethod = String(method || 'GET').toUpperCase(); if (!METHODS.has(normalizedMethod)) { throw new Error(`Unsupported method ${normalizedMethod}. Use ${[...METHODS].join(', ')}.`); @@ -66,12 +85,25 @@ export async function runApiRequest({ path, method, query, body, bodyFile, heade requestBody = parseJsonValue(await readFile(bodyFile, 'utf8'), 'body file'); } + const tenant = await resolveCliTenant({ org, service, providerBaseUrl }); + if (!['GET', 'HEAD'].includes(normalizedMethod)) { + if (requestBody !== undefined && (!requestBody || typeof requestBody !== 'object' || Array.isArray(requestBody))) { + throw new Error('Authenticated application API request bodies must be JSON objects.'); + } + requestBody = { ...tenant.requestBody, ...(requestBody || {}) }; + } const response = await fetchApiResponse(normalizeApiPath(path), { method: normalizedMethod, query: parseJsonObject(query, 'query'), body: requestBody, headers: parseHeaders(headers), allowHttpError: true, + tenant, }); - return { ok: response.ok, status: response.status, data: response.data }; + return { + ok: response.ok, + status: response.status, + tenant: tenant.requestBody, + data: response.data, + }; } diff --git a/src/commands/logout.js b/src/commands/logout.js index b1a4a4b..039c8b1 100644 --- a/src/commands/logout.js +++ b/src/commands/logout.js @@ -1,17 +1,25 @@ import React, { useEffect } from 'react'; import { Text, Box, useApp } from 'ink'; -import { getConfigValue, setConfigValue } from '../utils/config.js'; +import { getConfigValue } from '../utils/config.js'; +import { logoutCodeAnt } from '../utils/logout.js'; export default function Logout() { const { exit } = useApp(); const wasLoggedIn = !!getConfigValue('apiKeyV2'); + const [warning, setWarning] = React.useState(null); + const [done, setDone] = React.useState(!wasLoggedIn); useEffect(() => { - if (wasLoggedIn) { - setConfigValue('apiKeyV2', null); + if (!wasLoggedIn) { + exit(); + return; } - exit(); + logoutCodeAnt().then((result) => { + setWarning(result.warning || null); + setDone(true); + setTimeout(() => exit(), 100); + }); }, []); if (!wasLoggedIn) { @@ -25,6 +33,7 @@ export default function Logout() { return React.createElement( Box, { flexDirection: 'column', padding: 1 }, - React.createElement(Text, { color: 'green' }, '✓ Logged out successfully.') + React.createElement(Text, { color: done ? 'green' : 'gray' }, done ? '✓ Logged out successfully.' : 'Revoking session...'), + warning ? React.createElement(Text, { color: 'yellow' }, warning) : null ); } diff --git a/src/hotlist/client.js b/src/hotlist/client.js index dc851e3..e10044c 100644 --- a/src/hotlist/client.js +++ b/src/hotlist/client.js @@ -1,21 +1,8 @@ -import { validateConnection } from '../scans/connectionHandler.js'; -import { fetchApi } from '../utils/fetchApi.js'; - -const SERVICE_ALIASES = { azure_devops: 'azuredevops', ado: 'azuredevops' }; -const PROVIDER_BASE_FIELDS = { - github: 'github_base_url', - gitlab: 'gitlab_base_url', - bitbucket: 'bitbucket_base_url', - azuredevops: 'azure_devops_base_url', -}; +import { resolveCliTenant } from '../api/tenant.js'; +import { fetchAppApi } from '../utils/fetchApi.js'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -export function normalizeService(value) { - const service = String(value || '').trim().toLowerCase(); - return SERVICE_ALIASES[service] || service; -} - function splitValues(value) { const values = Array.isArray(value) ? value : value ? [value] : []; return values.flatMap((entry) => String(entry).split(',')).map((entry) => entry.trim()).filter(Boolean); @@ -32,50 +19,12 @@ export function hotlistFilters(options = {}) { }; } -export async function resolveHotlistTenant({ org, service, providerBaseUrl } = {}) { - const validation = await validateConnection(); - if (!validation.success) { - throw new Error(validation.error || 'Unable to load authenticated CodeAnt organizations.'); - } - const requestedService = normalizeService(service); - const candidates = (validation.connections || []).filter((connection) => { - const orgMatches = !org || connection.organizationName === org; - const serviceMatches = !requestedService || normalizeService(connection.service) === requestedService; - return orgMatches && serviceMatches; - }); - if (candidates.length === 0) { - throw new Error('No authenticated organization matches --org/--service. Run `codeant scans orgs` to list available connections.'); - } - if (candidates.length > 1) { - throw new Error('More than one organization matches. Pass both --org and --service; run `codeant scans orgs` to list values.'); - } - - const connection = candidates[0]; - const normalizedService = normalizeService(connection.service); - const baseField = PROVIDER_BASE_FIELDS[normalizedService]; - if (!baseField) throw new Error(`Hotlist is not supported for service ${connection.service}.`); - const organization = org || connection.organizationName; - const baseUrl = providerBaseUrl || connection.baseUrl; - if (!baseUrl) { - throw new Error('The provider base URL is unavailable. Pass --provider-base-url explicitly.'); - } - return { - organization, - service: normalizedService, - providerBaseUrl: baseUrl, - requestBody: { - org: organization, - organization, - service: normalizedService, - [baseField]: baseUrl, - }, - }; -} +export const resolveHotlistTenant = resolveCliTenant; -async function requestReady(endpoint, body, maxWaitSeconds = 60) { +async function requestReady(endpoint, body, tenant, maxWaitSeconds = 60) { const deadline = Date.now() + Math.max(0, Number(maxWaitSeconds) || 0) * 1000; while (true) { - const response = await fetchApi(endpoint, 'POST', body); + const response = await fetchAppApi(endpoint, 'POST', body, tenant); if (response?.state !== 'building') return response; if (Date.now() >= deadline) { throw new Error('Hotlist is still building. Retry the command in a few seconds or increase --max-wait.'); @@ -102,7 +51,7 @@ export async function runHotlistList(options = {}) { limit: pageLimit(options.limit), cursor: options.cursor || null, }; - let result = await requestReady('/explorer/security/hotlist/query', request, options.maxWaitSeconds); + let result = await requestReady('/explorer/security/hotlist/query', request, tenant, options.maxWaitSeconds); if (!options.all) return { tenant: tenant.requestBody, ...result }; const items = [...(result.items || [])]; @@ -110,6 +59,7 @@ export async function runHotlistList(options = {}) { result = await requestReady( '/explorer/security/hotlist/query', { ...request, cursor: result.next_cursor }, + tenant, options.maxWaitSeconds, ); items.push(...(result.items || [])); @@ -132,6 +82,7 @@ export async function runHotlistGet({ findingId, ...options } = {}) { const result = await requestReady( '/explorer/security/hotlist/finding', { ...tenant.requestBody, finding_id: String(findingId).toLowerCase() }, + tenant, options.maxWaitSeconds, ); return { tenant: tenant.requestBody, ...result }; diff --git a/src/mcp/server.js b/src/mcp/server.js index 0cc9b21..610a867 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -13,8 +13,9 @@ import { runStartScan } from '../commands/scans/start-scan.js'; import { runReviewHeadless } from '../reviewHeadless.js'; import * as scm from '../scm/index.js'; import { isAlreadyLoggedIn, runLoginFlow } from '../utils/loginFlow.js'; -import { getConfigValue, setConfigValue } from '../utils/config.js'; +import { getConfigValue } from '../utils/config.js'; import { runHotlistGet, runHotlistList } from '../hotlist/client.js'; +import { logoutCodeAnt } from '../utils/logout.js'; import { runApiRequest } from '../commands/api/request.js'; const require = createRequire(import.meta.url); @@ -274,6 +275,9 @@ export async function startMcpServer() { description: 'Call any authenticated GET endpoint on the configured CodeAnt API host. The path must be relative (for example /extension/scans2/validate); absolute URLs are rejected.', inputSchema: { path: z.string().startsWith('/'), + org: z.string().optional().describe('Organization name. Required when the login has multiple matching connections.'), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional().describe('Override only for a self-hosted provider.'), query: z.record(z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional(), headers: z.array(z.string()).optional().describe('Optional repeatable "Name: value" headers. Authorization cannot be overridden.'), }, @@ -488,10 +492,11 @@ export async function startMcpServer() { }, async () => { try { - const wasLoggedIn = !!(process.env.CODEANT_API_TOKEN?.trim() || getConfigValue('apiKeyV2')); - setConfigValue('apiKeyV2', null); - delete process.env.CODEANT_API_TOKEN; - return ok({ wasLoggedIn, status: wasLoggedIn ? 'logged_out' : 'not_logged_in' }); + const result = await logoutCodeAnt(); + return ok({ + ...result, + status: result.wasLoggedIn ? 'logged_out' : 'not_logged_in', + }); } catch (err) { return fail(err); } } ); @@ -506,6 +511,9 @@ export async function startMcpServer() { inputSchema: { method: z.enum(['POST', 'PUT', 'PATCH', 'DELETE']), path: z.string().startsWith('/'), + org: z.string().optional().describe('Organization name. Required when the login has multiple matching connections.'), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional().describe('Override only for a self-hosted provider.'), query: z.record(z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional(), body: z.unknown().optional(), headers: z.array(z.string()).optional(), diff --git a/src/utils/fetchApi.js b/src/utils/fetchApi.js index 23ed3a1..843b69a 100644 --- a/src/utils/fetchApi.js +++ b/src/utils/fetchApi.js @@ -65,6 +65,7 @@ const fetchApiResponse = async (endpoint, { headers = {}, query = null, allowHttpError = false, + tenant = null, } = {}) => { const baseUrl = getBaseUrl(); const resolvedUrl = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`; @@ -84,6 +85,11 @@ const fetchApiResponse = async (endpoint, { if (token) { options.headers['Authorization'] = `Bearer ${token}`; } + if (tenant) { + options.headers['X-CodeAnt-CLI-Org'] = tenant.organization; + options.headers['X-CodeAnt-CLI-Service'] = tenant.service; + options.headers['X-CodeAnt-CLI-Base-URL'] = tenant.providerBaseUrl; + } if (body !== null && body !== undefined && !['GET', 'HEAD'].includes(normalizedMethod)) { options.body = JSON.stringify(body); @@ -137,4 +143,9 @@ const fetchApi = async (endpoint, method = 'GET', body = null) => { return response.data; }; -export { fetchApi, fetchApiResponse }; +const fetchAppApi = async (endpoint, method = 'GET', body = null, tenant) => { + const response = await fetchApiResponse(endpoint, { method, body, tenant }); + return response.data; +}; + +export { fetchApi, fetchApiResponse, fetchAppApi }; diff --git a/src/utils/logout.js b/src/utils/logout.js new file mode 100644 index 0000000..7594dd8 --- /dev/null +++ b/src/utils/logout.js @@ -0,0 +1,20 @@ +import { getConfigValue, setConfigValue } from './config.js'; +import { fetchApi } from './fetchApi.js'; + +export async function logoutCodeAnt() { + const token = process.env.CODEANT_API_TOKEN?.trim() || getConfigValue('apiKeyV2'); + if (!token) return { wasLoggedIn: false, serverRevoked: false }; + + let serverRevoked = false; + let warning; + try { + await fetchApi('/extension/logout', 'POST', {}); + serverRevoked = true; + } catch (error) { + warning = `The local token was cleared, but server revocation could not be confirmed: ${error.message}`; + } finally { + setConfigValue('apiKeyV2', null); + delete process.env.CODEANT_API_TOKEN; + } + return { wasLoggedIn: true, serverRevoked, warning }; +} diff --git a/tests/apiRequest.test.js b/tests/apiRequest.test.js index 991ae90..34bf53b 100644 --- a/tests/apiRequest.test.js +++ b/tests/apiRequest.test.js @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { fetchApiResponse } = vi.hoisted(() => ({ fetchApiResponse: vi.fn() })); +const { resolveCliTenant } = vi.hoisted(() => ({ resolveCliTenant: vi.fn() })); vi.mock('../src/utils/fetchApi.js', () => ({ fetchApiResponse })); +vi.mock('../src/api/tenant.js', () => ({ resolveCliTenant })); const { normalizeApiPath, @@ -13,7 +15,16 @@ const { } = await import('../src/commands/api/request.js'); describe('authenticated API request', () => { - beforeEach(() => fetchApiResponse.mockReset()); + beforeEach(() => { + fetchApiResponse.mockReset(); + resolveCliTenant.mockReset(); + resolveCliTenant.mockResolvedValue({ + organization: 'CodeAnt-AI', + service: 'github', + providerBaseUrl: 'https://github.com', + requestBody: { org: 'CodeAnt-AI', service: 'github' }, + }); + }); it('accepts only paths on the configured CodeAnt API host', () => { expect(normalizeApiPath('/extension/scans2/validate?x=1')).toBe('/extension/scans2/validate?x=1'); @@ -25,6 +36,7 @@ describe('authenticated API request', () => { expect(parseHeaders(['If-Match: revision-1'])).toEqual({ 'If-Match': 'revision-1' }); expect(() => parseHeaders(['Authorization: Bearer attacker'])).toThrow(/cannot be overridden/); expect(() => parseHeaders(['Cookie: session=secret'])).toThrow(/cannot be overridden/); + expect(() => parseHeaders(['X-CodeAnt-CLI-Org: Other'])).toThrow(/cannot be overridden/); }); it('requires an object query and accepts any JSON body value', () => { @@ -47,10 +59,25 @@ describe('authenticated API request', () => { expect(fetchApiResponse).toHaveBeenCalledWith('/example', { method: 'POST', query: { page: 2 }, - body: { org: 'CodeAnt-AI' }, + body: { org: 'CodeAnt-AI', service: 'github' }, headers: { 'If-Match': 'revision-1' }, allowHttpError: true, + tenant: expect.objectContaining({ organization: 'CodeAnt-AI', service: 'github' }), }); - expect(result).toEqual({ ok: true, status: 200, data: { result: 'ok' } }); + expect(result).toEqual({ + ok: true, + status: 200, + tenant: { org: 'CodeAnt-AI', service: 'github' }, + data: { result: 'ok' }, + }); + }); + + it('requires object bodies for authenticated application API calls', async () => { + await expect(runApiRequest({ + path: '/example', + method: 'POST', + body: '[1,2]', + })).rejects.toThrow(/request bodies must be JSON objects/); + expect(fetchApiResponse).not.toHaveBeenCalled(); }); }); diff --git a/tests/fetchApi.test.js b/tests/fetchApi.test.js index 8e3ec18..fb190cd 100644 --- a/tests/fetchApi.test.js +++ b/tests/fetchApi.test.js @@ -51,6 +51,34 @@ describe('fetchApi', () => { expect(fetch.mock.calls[0][0]).toBe('https://api.codeant.test/items?tag=a&tag=b'); }); + it('attaches server-managed CLI tenant headers for app API calls', async () => { + fetch.mockResolvedValue(new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + await fetchApiResponse('/explorer/security/hotlist/query', { + method: 'POST', + body: {}, + tenant: { + organization: 'Acme', + service: 'gitlab', + providerBaseUrl: 'https://gitlab.acme.test', + }, + }); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.codeant.test/explorer/security/hotlist/query', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-CodeAnt-CLI-Org': 'Acme', + 'X-CodeAnt-CLI-Service': 'gitlab', + 'X-CodeAnt-CLI-Base-URL': 'https://gitlab.acme.test', + }), + }), + ); + }); + it('surfaces nested API error messages', async () => { fetch.mockResolvedValue(new Response(JSON.stringify({ error: { message: 'Finding not found' } }), { status: 404, diff --git a/tests/hotlist.test.js b/tests/hotlist.test.js index 45006c0..ce7c60f 100644 --- a/tests/hotlist.test.js +++ b/tests/hotlist.test.js @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { validateConnection, fetchApi } = vi.hoisted(() => ({ +const { validateConnection, fetchAppApi } = vi.hoisted(() => ({ validateConnection: vi.fn(), - fetchApi: vi.fn(), + fetchAppApi: vi.fn(), })); vi.mock('../src/scans/connectionHandler.js', () => ({ validateConnection })); -vi.mock('../src/utils/fetchApi.js', () => ({ fetchApi })); +vi.mock('../src/utils/fetchApi.js', () => ({ fetchAppApi })); const { resolveHotlistTenant, runHotlistGet, runHotlistList } = await import('../src/hotlist/client.js'); @@ -20,7 +20,7 @@ const connectionResult = { describe('Hotlist client', () => { beforeEach(() => { validateConnection.mockReset(); - fetchApi.mockReset(); + fetchAppApi.mockReset(); validateConnection.mockResolvedValue(connectionResult); }); @@ -39,7 +39,7 @@ describe('Hotlist client', () => { }); it('passes UI-compatible filters and follows every cursor', async () => { - fetchApi + fetchAppApi .mockResolvedValueOnce({ state: 'ready', items: [{ id: 'a' }], has_more: true, next_cursor: 'next' }) .mockResolvedValueOnce({ state: 'ready', items: [{ id: 'b' }], has_more: false, next_cursor: null }); @@ -52,30 +52,30 @@ describe('Hotlist client', () => { all: true, }); - expect(fetchApi).toHaveBeenNthCalledWith(1, '/explorer/security/hotlist/query', 'POST', expect.objectContaining({ + expect(fetchAppApi).toHaveBeenNthCalledWith(1, '/explorer/security/hotlist/query', 'POST', expect.objectContaining({ filters: expect.objectContaining({ severities: ['critical', 'high'], validation: ['exploit_confirmed'], }), limit: 25, cursor: null, - })); - expect(fetchApi).toHaveBeenNthCalledWith(2, '/explorer/security/hotlist/query', 'POST', expect.objectContaining({ cursor: 'next' })); + }), expect.objectContaining({ organization: 'CodeAnt-AI', service: 'github' })); + expect(fetchAppApi).toHaveBeenNthCalledWith(2, '/explorer/security/hotlist/query', 'POST', expect.objectContaining({ cursor: 'next' }), expect.any(Object)); expect(result.items).toEqual([{ id: 'a' }, { id: 'b' }]); expect(result.returned_count).toBe(2); }); it('gets one finding by stable ID', async () => { const findingId = '0123456789abcdef0123456789abcdef'; - fetchApi.mockResolvedValue({ state: 'ready', item: { id: findingId } }); + fetchAppApi.mockResolvedValue({ state: 'ready', item: { id: findingId } }); const result = await runHotlistGet({ findingId, org: 'CodeAnt-AI', service: 'github' }); - expect(fetchApi).toHaveBeenCalledWith('/explorer/security/hotlist/finding', 'POST', expect.objectContaining({ + expect(fetchAppApi).toHaveBeenCalledWith('/explorer/security/hotlist/finding', 'POST', expect.objectContaining({ finding_id: findingId, organization: 'CodeAnt-AI', service: 'github', - })); + }), expect.any(Object)); expect(result.item.id).toBe(findingId); }); diff --git a/tests/logout.test.js b/tests/logout.test.js new file mode 100644 index 0000000..a0a443e --- /dev/null +++ b/tests/logout.test.js @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { fetchApi, getConfigValue, setConfigValue } = vi.hoisted(() => ({ + fetchApi: vi.fn(), + getConfigValue: vi.fn(), + setConfigValue: vi.fn(), +})); + +vi.mock('../src/utils/fetchApi.js', () => ({ fetchApi })); +vi.mock('../src/utils/config.js', () => ({ getConfigValue, setConfigValue })); + +const { logoutCodeAnt } = await import('../src/utils/logout.js'); + +describe('logoutCodeAnt', () => { + beforeEach(() => { + fetchApi.mockReset(); + getConfigValue.mockReset(); + setConfigValue.mockReset(); + delete process.env.CODEANT_API_TOKEN; + }); + + it('revokes the server key before clearing local authentication', async () => { + getConfigValue.mockReturnValue('key'); + fetchApi.mockResolvedValue({ status: 'logged_out' }); + + await expect(logoutCodeAnt()).resolves.toEqual({ + wasLoggedIn: true, + serverRevoked: true, + warning: undefined, + }); + expect(fetchApi).toHaveBeenCalledWith('/extension/logout', 'POST', {}); + expect(setConfigValue).toHaveBeenCalledWith('apiKeyV2', null); + }); + + it('still clears the local key and reports when revocation cannot be confirmed', async () => { + getConfigValue.mockReturnValue('key'); + fetchApi.mockRejectedValue(new Error('offline')); + + const result = await logoutCodeAnt(); + + expect(result.serverRevoked).toBe(false); + expect(result.warning).toMatch(/could not be confirmed.*offline/); + expect(setConfigValue).toHaveBeenCalledWith('apiKeyV2', null); + }); +}); From 4ba71c15eb6565da78d3ad3dd55c052d44a5add5 Mon Sep 17 00:00:00 2001 From: Sagar Kalsaria Date: Thu, 27 Aug 2026 12:29:01 +0530 Subject: [PATCH 3/3] COD-1178: expose all findings through CLI --- README.md | 16 +- cli-api.md | 7 + findings.md | 163 ++++++++++++++++++++ mcp.md | 11 +- mcpb/manifest.json | 9 +- package.json | 9 +- src/commands/findings/index.js | 197 +++++++++++++++++++++++++ src/findings/antipatterns.js | 31 ++++ src/findings/cloud.js | 119 +++++++++++++++ src/findings/pentest.js | 42 ++++++ src/index.js | 4 + src/mcp/server.js | 151 +++++++++++++++++++ tests/cloudFindings.test.js | 102 +++++++++++++ tests/organizationAntipatterns.test.js | 55 +++++++ tests/pentestFindings.test.js | 54 +++++++ 15 files changed, 965 insertions(+), 5 deletions(-) create mode 100644 findings.md create mode 100644 src/commands/findings/index.js create mode 100644 src/findings/antipatterns.js create mode 100644 src/findings/cloud.js create mode 100644 src/findings/pentest.js create mode 100644 tests/cloudFindings.test.js create mode 100644 tests/organizationAntipatterns.test.js create mode 100644 tests/pentestFindings.test.js diff --git a/README.md b/README.md index a49989f..0e06492 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,20 @@ codeant hotlist list --org CodeAnt-AI --service github --severity critical,high codeant hotlist get 0123456789abcdef0123456789abcdef --org CodeAnt-AI --service github ``` +#### `findings` + +Access repository, organization Hotlist, cloud-security, anti-pattern, and pentest findings through one command group. + +```bash +codeant findings repos --org CodeAnt-AI +codeant findings repo --repo CodeAnt-AI/example --types sast,sca,iac,anti_patterns +codeant findings list --severity critical,high +codeant findings cloud history --provider all +codeant findings pentest history +``` + +See [findings.md](findings.md) for the complete command and agent manual. + #### `api request` Call any CodeAnt application API using the saved bearer token. Only relative paths on the configured CodeAnt API host are accepted. @@ -149,7 +163,7 @@ codeant api request GET /some/read/endpoint --org CodeAnt-AI --service github -- codeant api request POST /some/app/endpoint --org CodeAnt-AI --service github --body '{"repo":"CodeAnt-AI/example"}' ``` -See [cli-api.md](cli-api.md) for the complete Hotlist, raw API, authentication, self-hosted provider, and agent/MCP manual. +See [findings.md](findings.md) for all finding commands and [cli-api.md](cli-api.md) for raw API, authentication, self-hosted provider, and agent/MCP details. ### Global Options diff --git a/cli-api.md b/cli-api.md index 85bc865..517fc6e 100644 --- a/cli-api.md +++ b/cli-api.md @@ -57,6 +57,10 @@ Comma-separated values are accepted. The default page size is 30 and the maximum For self-hosted GitHub, GitLab, Bitbucket, or Azure DevOps, the CLI normally discovers the provider base URL from the authenticated connection. Use `--provider-base-url` only to override it. +## All findings + +Use `codeant findings` for first-class access to repository findings, organization Hotlist and anti-pattern findings, AWS/Azure/GCP cloud-security findings, and pentest engagements. The full command matrix, provider-specific fields, examples, and agent workflow are documented in [findings.md](findings.md). + ## Any app API Use the generic request command when a first-class command does not exist yet: @@ -108,6 +112,9 @@ Run `codeant mcp` or install the CodeAnt MCP bundle. Agents receive dedicated re - `codeant_hotlist_list` — filter and page through organization-wide findings. - `codeant_hotlist_get` — fetch one finding by stable ID. - `codeant_api_get` — authenticated GET access for newly-added read APIs. +- `codeant_findings_antipatterns` — selected or all-repository anti-pattern findings. +- `codeant_cloud_scan_history`, `codeant_cloud_findings_list`, `codeant_cloud_finding_get` — cloud scan discovery, findings, and detail. +- `codeant_pentest_history`, `codeant_pentest_issues`, `codeant_pentest_report` — pentest engagement discovery and results. Set `CODEANT_READ_ONLY=0` to opt in to write tools, including `codeant_api_request` for POST/PUT/PATCH/DELETE. Read-only mode is the default. The MCP server never opens a browser during startup; the agent must explicitly call `codeant_login` when no token is configured. diff --git a/findings.md b/findings.md new file mode 100644 index 0000000..3ceaf34 --- /dev/null +++ b/findings.md @@ -0,0 +1,163 @@ +# CodeAnt findings CLI + +`codeant findings` is the unified, read-only entry point for findings visible in the CodeAnt app. It reuses the same authenticated backend endpoints and authorization checks as the UI. + +```bash +codeant login +codeant scans orgs +codeant findings --help +``` + +When one login has multiple connections, pass the exact `--org` and `--service` values returned by `codeant scans orgs`. A self-hosted provider base URL is discovered from the selected connection; use `--provider-base-url` only as an explicit override. + +## Coverage + +| App data | CLI command | Scope | +|---|---|---| +| Repository list | `codeant findings repos` | organization | +| SAST, SCA, IaC, Secrets, SBOM | `codeant findings repo` | repository + scan/branch | +| Anti-patterns, dead code, docstrings, complex functions | `codeant findings repo` | repository + scan/branch | +| Prioritized SAST/SCA/IaC/Secrets/Infrastructure/AI Exploitation | `codeant findings list/get` | organization Hotlist | +| Anti-patterns across repositories | `codeant findings antipatterns` | selected repos or organization | +| AWS/Azure/GCP CSPM, VM, and container findings | `codeant findings cloud history/list/get` | organization + cloud resource scope | +| Pentest engagements, issues, reports | `codeant findings pentest history/issues/report` | organization + engagement | + +The existing `codeant scans repos`, `codeant scans results`, and `codeant hotlist list/get` commands remain supported. The unified commands are aliases or thin authenticated clients, so existing scripts do not need to migrate. + +## Repository list and repo-level findings + +```bash +# List connected repositories +codeant findings repos --org CodeAnt-AI + +# Latest scan, all supported finding types +codeant findings repo --repo CodeAnt-AI/example --types all + +# Selected categories and severities +codeant findings repo \ + --repo CodeAnt-AI/example \ + --branch main \ + --types sast,sca,iac,anti_patterns \ + --severity critical,high + +# A specific scan, formatted for another tool +codeant findings repo \ + --repo CodeAnt-AI/example \ + --scan 0123456789abcdef \ + --types sast,secrets \ + --format sarif \ + --output codeant.sarif +``` + +Supported repo types are `sast`, `sca`, `secrets`, `iac`, `dead_code`, `sbom`, `anti_patterns`, `docstring`, and `complex_functions`. Use `--types all` for all of them. Formats are `json`, `sarif`, `csv`, `md`, and `table`; JSON is the default. + +Use `--filter-dismissed` to exclude dismissed findings and `--no-false-positives` to exclude false positives. `--path`, `--check`, `--limit`, and `--offset` support agent-friendly filtering and pagination. + +## Organization Hotlist findings + +`findings list/get` exposes the same stable IDs, prioritization, filters, and cursor pagination as the app Hotlist. + +```bash +codeant findings list --org CodeAnt-AI --service github --severity critical,high +codeant findings list --type SCA,IaC --location CodeAnt-AI/example --all +codeant findings get 0123456789abcdef0123456789abcdef --org CodeAnt-AI --service github +``` + +Hotlist types are `SAST`, `SCA`, `Secrets`, `IaC`, `Infrastructure`, and `AI Exploitation`. The last two cover prioritized cloud-security and pentest findings. Use the dedicated cloud and pentest commands below when complete scan/engagement data is required. + +## Organization anti-patterns + +```bash +# Every repository in the selected organization +codeant findings antipatterns --org CodeAnt-AI --service github + +# Only selected repositories +codeant findings antipatterns \ + --org CodeAnt-AI --service github \ + --repos CodeAnt-AI/api,CodeAnt-AI/web +``` + +When `--repos` is omitted, the CLI first lists the organization's repositories and sends all of them to the same aggregate anti-pattern endpoint used by the Quality Report UI. + +## Cloud security findings + +Cloud findings are organization/account scoped rather than repository scoped. + +```bash +# History across AWS, Azure, and GCP +codeant findings cloud history --org CodeAnt-AI --service github + +# Latest scan per provider +codeant findings cloud history --provider all --latest + +# VM and container vulnerability scan histories +codeant findings cloud history --provider all --kind vm +codeant findings cloud history --provider all --kind container + +# AWS findings and one full detail record +codeant findings cloud list --provider aws --scan-id --account-id +codeant findings cloud get --provider aws --scan-id --uid --cloud-service iam + +# VM and container vulnerabilities use the same list/detail flow +codeant findings cloud list --provider aws --kind vm --scan-id +codeant findings cloud get --provider gcp --kind container --scan-id --uid + +# Azure requires the tenant ID +codeant findings cloud list \ + --provider azure --tenant-id --scan-id \ + --severity high --subscription-id + +# GCP requires the project ID +codeant findings cloud list \ + --provider gcp --project-id --scan-id \ + --framework cis +``` + +`--kind` defaults to `cspm`; use `vm` or `container` for the other Cloud Security result views. CSPM `cloud list` supports `--cloud-service`, `--severity`, `--status`, `--framework`, and `--min-days-unused`. AWS additionally supports `--exploit-attempted-only`; Azure additionally supports `--subscription-id`. CSPM responses include `findings` and `dismissed_findings`; VM/container responses preserve their UI result payload unchanged. + +## Pentest findings + +```bash +# Discover engagement IDs +codeant findings pentest history --org CodeAnt-AI --service github + +# All available open issues for an engagement +codeant findings pentest issues --report-id + +# Full customer report +codeant findings pentest report --report-id + +# Test-environment variant +codeant findings pentest issues --report-id --variant test +codeant findings pentest report --report-id --variant test +``` + +`--variant prod` is the default. Pentest entitlements and critical/high redaction are enforced by the backend exactly as they are in the UI; the CLI does not bypass locked content. + +## Agent/MCP tools + +Agents can use these read-only MCP tools: + +| Tool | Purpose | +|---|---| +| `codeant_scans_repos` | List repositories. | +| `codeant_scans_results` | Fetch repo-level SAST/SCA/IaC/Secrets/quality findings. | +| `codeant_hotlist_list`, `codeant_hotlist_get` | Query prioritized org-wide findings and stable IDs. | +| `codeant_findings_antipatterns` | Fetch selected or all-repo anti-pattern findings. | +| `codeant_cloud_scan_history` | Discover AWS/Azure/GCP scan IDs and scopes. | +| `codeant_cloud_findings_list`, `codeant_cloud_finding_get` | List cloud findings and retrieve full detail. | +| `codeant_pentest_history`, `codeant_pentest_issues`, `codeant_pentest_report` | Discover and inspect pentest engagements. | + +All these tools are available in the default read-only MCP mode. A typical agent flow is discovery (`orgs` -> `repos`, cloud history, or pentest history), list/filter findings, then retrieve one detailed finding or report. + +## Errors and access + +| Error | Resolution | +|---|---| +| No or multiple matching organizations | Run `codeant scans orgs`; pass exact `--org` and `--service`. | +| Access denied (403) | Run `codeant logout`, then `codeant login`. Older CLI keys must be refreshed once. | +| Missing Azure/GCP scope | Pass `--tenant-id` for Azure or `--project-id` for GCP. | +| Report or scan not found | Use the corresponding history command and verify the selected tenant/provider. | +| Redacted pentest fields | Unlock the engagement in the app; CLI access follows the same entitlement. | + +For the generic authenticated API escape hatch and authentication details, see [cli-api.md](cli-api.md). diff --git a/mcp.md b/mcp.md index 05ec8ee..3f80f21 100644 --- a/mcp.md +++ b/mcp.md @@ -18,6 +18,13 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn | `codeant_scans_dismissed` | read | Dismissed alerts for a repo. | | `codeant_hotlist_list` | read | Prioritized organization-wide Hotlist findings with stable IDs. | | `codeant_hotlist_get` | read | One complete Hotlist finding by stable ID. | +| `codeant_findings_antipatterns` | read | Anti-pattern findings across selected or all organization repos. | +| `codeant_cloud_scan_history` | read | AWS/Azure/GCP CSPM, VM, or container scan history. | +| `codeant_cloud_findings_list` | read | Findings for one CSPM, VM, or container scan. | +| `codeant_cloud_finding_get` | read | Full detail for one cloud finding UID. | +| `codeant_pentest_history` | read | Pentest engagement history. | +| `codeant_pentest_issues` | read | All available issues for a pentest engagement. | +| `codeant_pentest_report` | read | Full pentest customer report. | | `codeant_api_get` | read | Authenticated GET request to any relative CodeAnt app API path, with exact org/provider context. | | `codeant_pr_list` | read | List PRs/MRs across GitHub, GitLab, Bitbucket, Azure DevOps. | | `codeant_pr_get` | read | Detail for a PR/MR. | @@ -30,7 +37,7 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn Write tools are only registered when `CODEANT_READ_ONLY=0`. Default = read-only. -For Hotlist examples, raw API syntax, tenant/provider selection, and response details, see [cli-api.md](cli-api.md). +For the complete finding coverage and examples, see [findings.md](findings.md). For raw API syntax, tenant/provider selection, and response details, see [cli-api.md](cli-api.md). Every tool carries MCP annotations (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) so the client can decide whether to auto-approve calls. @@ -154,7 +161,7 @@ cd dist/mcpb-stage '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'; sleep 1) | node server/index.js ``` -Expect 16 tools in the `tools/list` response (or 19 if `CODEANT_READ_ONLY=0`). +Expect 23 tools in the `tools/list` response (or 26 if `CODEANT_READ_ONLY=0`). ### Bumping the version diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 5400ec2..47c8e28 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -4,7 +4,7 @@ "display_name": "CodeAnt AI", "version": "0.5.3", "description": "Drive CodeAnt AI security scans and code review from Claude — org-wide secret triage, cross-repo SAST/SCA findings, on-demand scans, and local PR review.", - "long_description": "CodeAnt AI inside Claude. Ask things like \"show my highest-priority Hotlist findings\", \"how many critical SAST findings do I have across my org?\", or \"review my staged changes\" — Claude calls the CodeAnt API directly via this MCP server.\n\nIncludes 16 read-only tools, including organization Hotlist list/get and an authenticated GET escape hatch for new APIs, plus 3 opt-in write tools gated behind a setting.\n\nRequires a CodeAnt account. Sign up at https://codeant.ai. To authenticate, call the `codeant_login` tool — it opens the CodeAnt sign-in page in your browser and saves the token automatically.\n\nCollects anonymous usage telemetry via PostHog by default; set CODEANT_TELEMETRY_DISABLED=1 to opt out.", + "long_description": "CodeAnt AI inside Claude. Ask things like \"show my highest-priority Hotlist findings\", \"list AWS cloud findings\", \"show this pentest report\", or \"review my staged changes\" — Claude calls the CodeAnt API directly via this MCP server.\n\nIncludes 23 read-only tools, including repository, Hotlist, cloud security, anti-pattern, and pentest findings plus an authenticated GET escape hatch for new APIs, and 3 opt-in write tools gated behind a setting.\n\nRequires a CodeAnt account. Sign up at https://codeant.ai. To authenticate, call the `codeant_login` tool — it opens the CodeAnt sign-in page in your browser and saves the token automatically.\n\nCollects anonymous usage telemetry via PostHog by default; set CODEANT_TELEMETRY_DISABLED=1 to opt out.", "author": { "name": "CodeAnt AI", "email": "support@codeant.ai", @@ -67,6 +67,13 @@ { "name": "codeant_scans_dismissed", "description": "List dismissed alerts for a repository." }, { "name": "codeant_hotlist_list", "description": "List prioritized organization-wide Hotlist findings with stable IDs." }, { "name": "codeant_hotlist_get", "description": "Fetch one complete Hotlist finding by its stable ID." }, + { "name": "codeant_findings_antipatterns", "description": "List anti-pattern findings across selected or all organization repositories." }, + { "name": "codeant_cloud_scan_history", "description": "List AWS, Azure, or GCP CSPM, VM, or container scan history." }, + { "name": "codeant_cloud_findings_list", "description": "List findings for a CSPM, VM, or container security scan." }, + { "name": "codeant_cloud_finding_get", "description": "Fetch complete detail for one cloud security finding." }, + { "name": "codeant_pentest_history", "description": "List pentest engagements visible in CodeAnt." }, + { "name": "codeant_pentest_issues", "description": "List all available issues for a pentest engagement." }, + { "name": "codeant_pentest_report", "description": "Fetch a complete pentest customer report." }, { "name": "codeant_api_get", "description": "Call any authenticated GET endpoint on the configured CodeAnt API host." }, { "name": "codeant_pr_list", "description": "List pull requests / merge requests across GitHub, GitLab, Bitbucket, Azure DevOps." }, { "name": "codeant_pr_get", "description": "Fetch detailed information for a single PR/MR." }, diff --git a/package.json b/package.json index 64babe4..c3cc3e8 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,17 @@ "./scans/fetch-advanced-results": "./src/scans/fetchAdvancedScanResults.js", "./scans/dismissed-alerts": "./src/scans/fetchDismissedAlerts.js", "./hotlist": "./src/hotlist/client.js", + "./findings/cloud": "./src/findings/cloud.js", + "./findings/pentest": "./src/findings/pentest.js", + "./findings/antipatterns": "./src/findings/antipatterns.js", "./api": "./src/commands/api/request.js" }, "files": [ - "src" + "src", + "findings.md", + "cli-api.md", + "scans.md", + "mcp.md" ], "dependencies": { "@gitbeaker/rest": "^43.8.0", diff --git a/src/commands/findings/index.js b/src/commands/findings/index.js new file mode 100644 index 0000000..e41ebfd --- /dev/null +++ b/src/commands/findings/index.js @@ -0,0 +1,197 @@ +import { runRepos } from '../scans/repos.js'; +import { runResults } from '../scans/results.js'; +import { setQuiet, setNoColor } from '../scans/lib/log.js'; +import { setNoColor as tableSetNoColor } from '../scans/formatters/table.js'; +import { runHotlistGet, runHotlistList } from '../../hotlist/client.js'; +import { runOrganizationAntipatterns } from '../../findings/antipatterns.js'; +import { runCloudFindingGet, runCloudFindings, runCloudHistory } from '../../findings/cloud.js'; +import { runPentestHistory, runPentestIssues, runPentestReport } from '../../findings/pentest.js'; + +function addTenantOptions(command) { + return command + .option('--org ', 'Organization name (auto-picked when unambiguous)') + .option('--service ', 'github, gitlab, bitbucket, or azuredevops') + .option('--provider-base-url ', 'Override the authenticated provider base URL'); +} + +function tenantOptions(options) { + return { + org: options.org, + service: options.service, + providerBaseUrl: options.providerBaseUrl, + }; +} + +function addHotlistOptions(command) { + return addTenantOptions(command) + .option('--search ', 'Search title, repository, path, package, CVE, or check ID') + .option('--type ', 'Comma-separated types: SAST,SCA,Secrets,IaC,Infrastructure,AI Exploitation') + .option('--location ', 'Comma-separated repositories or cloud accounts') + .option('--severity ', 'Comma-separated severities') + .option('--ticket-status ', 'created,not_created') + .option('--compliance ', 'Comma-separated compliance frameworks') + .option('--validation ', 'Comma-separated validation flags') + .option('--limit ', 'Page size from 1 to 100', Number, 30) + .option('--cursor ', 'Continue from a previous next_cursor') + .option('--all', 'Fetch every matching page', false) + .option('--max-wait ', 'Wait for an initial Hotlist build', Number, 60); +} + +function hotlistOptions(options) { + return { + ...tenantOptions(options), + search: options.search, + types: options.type, + locations: options.location, + severities: options.severity, + ticketStatuses: options.ticketStatus, + compliance: options.compliance, + validation: options.validation, + limit: options.limit, + cursor: options.cursor, + all: options.all, + maxWaitSeconds: options.maxWait, + }; +} + +function addCloudScopeOptions(command) { + return addTenantOptions(command) + .requiredOption('--provider ', 'Cloud provider: aws, azure, or gcp') + .option('--kind ', 'Finding kind: cspm, vm, or container', 'cspm') + .requiredOption('--scan-id ', 'Cloud scan ID') + .option('--account-id ', 'AWS account ID') + .option('--tenant-id ', 'Azure tenant ID') + .option('--project-id ', 'GCP project ID') + .option('--cloud-service ', 'Provider service filter or finding service'); +} + +function cloudOptions(options) { + return { + ...tenantOptions(options), + provider: options.provider, + kind: options.kind, + scanId: options.scanId, + accountId: options.accountId, + tenantId: options.tenantId, + projectId: options.projectId, + cloudService: options.cloudService, + severity: options.severity, + status: options.status, + framework: options.framework, + subscriptionId: options.subscriptionId, + exploitAttemptedOnly: options.exploitAttemptedOnly, + minDaysUnused: options.minDaysUnused, + }; +} + +function runRepoResults(options) { + setQuiet(options.quiet); + if (options.noColor) { + setNoColor(true); + tableSetNoColor(true); + } + return runResults({ + repo: options.repo, + scan: options.scan, + branch: options.branch, + types: options.types, + severity: options.severity, + path: options.path, + check: options.check, + filterDismissed: options.filterDismissed || false, + includeFalsePositives: options.falsePositives ?? true, + format: options.format, + output: options.output, + fields: options.fields, + limit: options.limit, + offset: options.offset, + failFast: options.failFast || false, + }); +} + +export default function registerFindingsCommands(program, { runCmd }) { + const findings = program.command('findings').description('Access CodeAnt findings from repositories, Hotlist, cloud security, and pentesting'); + + findings + .command('repos') + .description('List repositories available for repo-level findings') + .option('--org ', 'Organization name (auto-picked when only one is authenticated)') + .action((options) => runCmd(() => runRepos({ org: options.org }))); + + findings + .command('repo') + .description('Fetch repo-level SAST, SCA, IaC, secrets, anti-pattern, and quality findings') + .requiredOption('--repo ', 'Repository (owner/repo)') + .option('--scan ', 'Specific commit SHA to use') + .option('--branch ', 'Resolve latest scan on this branch') + .option('--types ', 'Comma-separated types: sast,sca,secrets,iac,dead_code,sbom,anti_patterns,docstring,complex_functions,all', 'all') + .option('--severity ', 'Filter by severity') + .option('--path ', 'Filter by file path glob') + .option('--check ', 'Filter by check ID or name') + .option('--filter-dismissed', 'Exclude dismissed findings') + .option('--no-false-positives', 'Exclude false positives') + .option('--format ', 'json|sarif|csv|md|table', 'json') + .option('--output ', 'Write output to a file') + .option('--fields ', 'Project findings to a subset of fields') + .option('--limit ', 'Max findings per page', Number, 100) + .option('--offset ', 'Pagination offset', Number, 0) + .option('--fail-fast', 'Stop on the first category error') + .option('--no-color', 'Disable ANSI color') + .option('--quiet', 'Suppress progress output') + .action(async (options) => { + try { await runRepoResults(options); } + catch (err) { + process.stderr.write(JSON.stringify({ error: err.message }) + '\n'); + process.exitCode = err.exitCode ?? 1; + } + }); + + addHotlistOptions(findings.command('list').description('List organization-wide Hotlist findings')) + .action((options) => runCmd(() => runHotlistList(hotlistOptions(options)))); + + addTenantOptions(findings.command('get ').description('Get one Hotlist finding by its stable ID')) + .option('--max-wait ', 'Wait for an initial Hotlist build', Number, 60) + .action((findingId, options) => runCmd(() => runHotlistGet({ + findingId, + ...tenantOptions(options), + maxWaitSeconds: options.maxWait, + }))); + + addTenantOptions(findings.command('antipatterns').description('List anti-pattern findings across selected or all organization repositories')) + .option('--repos ', 'Comma-separated owner/repo values; defaults to every repository') + .action((options) => runCmd(() => runOrganizationAntipatterns({ ...tenantOptions(options), repos: options.repos }))); + + const cloud = findings.command('cloud').description('Cloud security CSPM, VM, and container findings'); + addTenantOptions(cloud.command('history').description('List cloud scan history, or latest scans')) + .option('--provider ', 'aws, azure, gcp, or all', 'all') + .option('--kind ', 'Finding kind: cspm, vm, or container', 'cspm') + .option('--latest', 'Return only latest CSPM scans') + .action((options) => runCmd(() => runCloudHistory({ ...tenantOptions(options), provider: options.provider, kind: options.kind, latest: options.latest }))); + + addCloudScopeOptions(cloud.command('list').description('List findings for a cloud scan')) + .option('--severity ', 'Severity filter') + .option('--status ', 'Finding status filter') + .option('--framework ', 'Compliance framework filter') + .option('--subscription-id ', 'Azure subscription ID filter') + .option('--exploit-attempted-only', 'AWS findings with validation attempts only') + .option('--min-days-unused ', 'Minimum unused age in days', Number) + .action((options) => runCmd(() => runCloudFindings(cloudOptions(options)))); + + addCloudScopeOptions(cloud.command('get').description('Get one cloud finding with full detail')) + .requiredOption('--uid ', 'Finding UID') + .action((options) => runCmd(() => runCloudFindingGet({ ...cloudOptions(options), uid: options.uid }))); + + const pentest = findings.command('pentest').description('Pentest histories, issues, and reports'); + addTenantOptions(pentest.command('history').description('List pentest engagements')) + .action((options) => runCmd(() => runPentestHistory(tenantOptions(options)))); + + addTenantOptions(pentest.command('issues').description('List all issues for a pentest engagement')) + .requiredOption('--report-id ', 'Pentest report/engagement ID') + .option('--variant ', 'prod or test', 'prod') + .action((options) => runCmd(() => runPentestIssues({ ...tenantOptions(options), reportId: options.reportId, variant: options.variant }))); + + addTenantOptions(pentest.command('report').description('Get the full pentest customer report')) + .requiredOption('--report-id ', 'Pentest report/engagement ID') + .option('--variant ', 'prod or test', 'prod') + .action((options) => runCmd(() => runPentestReport({ ...tenantOptions(options), reportId: options.reportId, variant: options.variant }))); +} diff --git a/src/findings/antipatterns.js b/src/findings/antipatterns.js new file mode 100644 index 0000000..62f56bd --- /dev/null +++ b/src/findings/antipatterns.js @@ -0,0 +1,31 @@ +import { resolveCliTenant } from '../api/tenant.js'; +import { runRepos } from '../commands/scans/repos.js'; +import { fetchAppApi } from '../utils/fetchApi.js'; + +function splitRepos(value) { + const values = Array.isArray(value) ? value : value ? [value] : []; + return values + .flatMap((entry) => String(entry).split(',')) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export async function runOrganizationAntipatterns(options = {}) { + const tenant = await resolveCliTenant(options); + let repos = splitRepos(options.repos); + if (repos.length === 0) { + const result = await runRepos({ org: tenant.organization }); + repos = (result.repos || []).map((repo) => repo.full_name || repo.name).filter(Boolean); + } + if (repos.length === 0) throw new Error('No repositories are available for this organization.'); + + const result = await fetchAppApi( + '/explorer/quality/antipatterns', + 'POST', + { ...tenant.requestBody, org: tenant.organization, repos }, + tenant, + ); + return { tenant: tenant.requestBody, ...result }; +} + +export { splitRepos }; diff --git a/src/findings/cloud.js b/src/findings/cloud.js new file mode 100644 index 0000000..f6dd38a --- /dev/null +++ b/src/findings/cloud.js @@ -0,0 +1,119 @@ +import { resolveCliTenant } from '../api/tenant.js'; +import { fetchAppApi } from '../utils/fetchApi.js'; + +const PROVIDERS = new Set(['aws', 'azure', 'gcp']); +const KINDS = new Set(['cspm', 'vm', 'container']); + +function normalizeProvider(value, { allowAll = false } = {}) { + const provider = String(value || '').trim().toLowerCase(); + if (allowAll && (!provider || provider === 'all')) return 'all'; + if (!PROVIDERS.has(provider)) { + throw new Error(`--provider must be one of: ${[...PROVIDERS].join(', ')}.`); + } + return provider; +} + +function requireValue(value, option) { + if (!String(value || '').trim()) throw new Error(`${option} is required.`); + return String(value).trim(); +} + +function normalizeKind(value) { + const kind = String(value || 'cspm').trim().toLowerCase(); + if (!KINDS.has(kind)) throw new Error(`--kind must be one of: ${[...KINDS].join(', ')}.`); + return kind; +} + +function endpoint(provider, kind, action) { + if (kind === 'vm') return `/cloud/${provider}/vm-scanning/${action}`; + if (kind === 'container') return `/cloud/${provider}/container-scanning/${action}`; + return `/cloud/${provider}/scan/${action}`; +} + +function providerScope(provider, options = {}) { + if (provider === 'azure') { + return { tenant_id: requireValue(options.tenantId, '--tenant-id for Azure findings') }; + } + if (provider === 'gcp') { + return { project_id: requireValue(options.projectId, '--project-id for GCP findings') }; + } + return options.accountId ? { account_id: options.accountId } : {}; +} + +function optionalFilters(provider, options = {}) { + const filters = {}; + const service = options.cloudService; + if (service) filters[`${provider}_service`] = service; + if (options.severity) filters.severity = options.severity; + if (options.status) filters.status = options.status; + if (options.framework) filters.framework = options.framework; + if (options.minDaysUnused !== undefined) filters.min_days_unused = options.minDaysUnused; + if (provider === 'aws' && options.exploitAttemptedOnly) filters.exploit_attempted_only = true; + if (provider === 'azure' && options.subscriptionId) filters.subscription_id = options.subscriptionId; + return filters; +} + +async function request(provider, kind, action, options = {}, extra = {}, resolvedTenant = null) { + const tenant = resolvedTenant || await resolveCliTenant(options); + const data = await fetchAppApi( + endpoint(provider, kind, action), + 'POST', + { + ...tenant.requestBody, + username: tenant.organization, + ...extra, + }, + tenant, + ); + return { tenant: tenant.requestBody, provider, kind, ...data }; +} + +export async function runCloudHistory(options = {}) { + const provider = normalizeProvider(options.provider, { allowAll: true }); + const kind = normalizeKind(options.kind); + if (options.latest && kind !== 'cspm') { + throw new Error('--latest is supported only for --kind cspm; VM and container history already returns scan records.'); + } + const action = options.latest ? 'latest' : 'history'; + const providers = provider === 'all' ? [...PROVIDERS] : [provider]; + const tenant = await resolveCliTenant(options); + if (provider !== 'all') return request(provider, kind, action, options, {}, tenant); + const settled = await Promise.allSettled( + providers.map((item) => request(item, kind, action, options, {}, tenant)), + ); + const results = settled.map((result, index) => ( + result.status === 'fulfilled' + ? result.value + : { provider: providers[index], kind, error: result.reason?.message || String(result.reason) } + )); + return { + tenant: tenant.requestBody, + providers: Object.fromEntries(results.map((result) => [result.provider, result])), + }; +} + +export async function runCloudFindings(options = {}) { + const provider = normalizeProvider(options.provider); + const kind = normalizeKind(options.kind); + const scanId = requireValue(options.scanId, '--scan-id'); + return request(provider, kind, kind === 'cspm' ? 'findings' : 'results', options, { + scan_id: scanId, + ...(kind === 'cspm' ? providerScope(provider, options) : {}), + ...(kind === 'cspm' ? optionalFilters(provider, options) : {}), + }); +} + +export async function runCloudFindingGet(options = {}) { + const provider = normalizeProvider(options.provider); + const kind = normalizeKind(options.kind); + const scanId = requireValue(options.scanId, '--scan-id'); + const uid = requireValue(options.uid, '--uid'); + return request(provider, kind, 'finding_detail', options, { + scan_id: scanId, + uid, + ...(kind === 'cspm' ? providerScope(provider, options) : {}), + ...(kind === 'cspm' && options.cloudService ? { [`${provider}_service`]: options.cloudService } : {}), + }); +} + +export { normalizeKind, normalizeProvider }; diff --git a/src/findings/pentest.js b/src/findings/pentest.js new file mode 100644 index 0000000..1ac3408 --- /dev/null +++ b/src/findings/pentest.js @@ -0,0 +1,42 @@ +import { resolveCliTenant } from '../api/tenant.js'; +import { fetchAppApi } from '../utils/fetchApi.js'; + +function variantBody(variant) { + if (!variant || variant === 'prod') return {}; + if (variant !== 'test') throw new Error('--variant must be prod or test.'); + return { variant: 'test' }; +} + +async function request(endpoint, options = {}, body = {}) { + const tenant = await resolveCliTenant(options); + const data = await fetchAppApi( + endpoint, + 'POST', + { ...tenant.requestBody, ...body, org: tenant.organization }, + tenant, + ); + return { tenant: tenant.requestBody, ...data }; +} + +function reportId(options) { + if (!String(options.reportId || '').trim()) throw new Error('--report-id is required.'); + return String(options.reportId).trim(); +} + +export async function runPentestHistory(options = {}) { + return request('/pentesting/history', options); +} + +export async function runPentestIssues(options = {}) { + return request('/pentesting/issues', options, { + report_id: reportId(options), + ...variantBody(options.variant), + }); +} + +export async function runPentestReport(options = {}) { + return request('/pentesting/report', options, { + report_id: reportId(options), + ...variantBody(options.variant), + }); +} diff --git a/src/index.js b/src/index.js index bc9062b..6dff2b7 100755 --- a/src/index.js +++ b/src/index.js @@ -22,6 +22,7 @@ import registerScansCommands from './commands/scans/index.js'; import registerSettingsCommands from './commands/settings/index.js'; import registerApiCommands from './commands/api/index.js'; import registerHotlistCommands from './commands/hotlist/index.js'; +import registerFindingsCommands from './commands/findings/index.js'; // Read version from package.json const require = createRequire(import.meta.url); @@ -405,6 +406,9 @@ program // ─── Organization Hotlist findings ─── registerHotlistCommands(program, { runCmd }); + // ─── Unified findings commands ─── + registerFindingsCommands(program, { runCmd }); + // ─── MCP server (for Claude Code plugin and other MCP clients) ─── program .command('mcp') diff --git a/src/mcp/server.js b/src/mcp/server.js index 610a867..74efc11 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -17,6 +17,9 @@ import { getConfigValue } from '../utils/config.js'; import { runHotlistGet, runHotlistList } from '../hotlist/client.js'; import { logoutCodeAnt } from '../utils/logout.js'; import { runApiRequest } from '../commands/api/request.js'; +import { runOrganizationAntipatterns } from '../findings/antipatterns.js'; +import { runCloudFindingGet, runCloudFindings, runCloudHistory } from '../findings/cloud.js'; +import { runPentestHistory, runPentestIssues, runPentestReport } from '../findings/pentest.js'; const require = createRequire(import.meta.url); const pkg = require('../../package.json'); @@ -266,6 +269,154 @@ export async function startMcpServer() { } ); + server.registerTool( + 'codeant_findings_antipatterns', + { + title: 'List organization anti-pattern findings', + description: 'Fetch anti-pattern findings across selected repositories, or every repository in the organization when repos is omitted.', + inputSchema: { + org: z.string().optional(), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional(), + repos: z.array(z.string()).optional().describe('Repositories in owner/repo form. Omit to query every repository.'), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runOrganizationAntipatterns(input)); } catch (err) { return fail(err); } + } + ); + + server.registerTool( + 'codeant_cloud_scan_history', + { + title: 'List cloud security scan history', + description: 'List AWS, Azure, or GCP CSPM, VM, or container scans visible in the CodeAnt Cloud Security UI. Cloud findings are organization/account scoped, not repository scoped.', + inputSchema: { + org: z.string().optional(), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional(), + provider: z.enum(['aws', 'azure', 'gcp', 'all']).optional().describe('Default all.'), + kind: z.enum(['cspm', 'vm', 'container']).optional().describe('Default cspm.'), + latest: z.boolean().optional().describe('Return latest scans instead of complete history. CSPM only.'), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runCloudHistory(input)); } catch (err) { return fail(err); } + } + ); + + server.registerTool( + 'codeant_cloud_findings_list', + { + title: 'List cloud security findings', + description: 'Fetch findings for one AWS, Azure, or GCP CSPM, VM, or container scan using the same endpoint as the app.', + inputSchema: { + org: z.string().optional(), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional(), + provider: z.enum(['aws', 'azure', 'gcp']), + kind: z.enum(['cspm', 'vm', 'container']).optional().describe('Default cspm.'), + scanId: z.string(), + accountId: z.string().optional().describe('Optional AWS account ID.'), + tenantId: z.string().optional().describe('Required for Azure.'), + projectId: z.string().optional().describe('Required for GCP.'), + cloudService: z.string().optional(), + severity: z.string().optional(), + status: z.string().optional(), + framework: z.string().optional(), + subscriptionId: z.string().optional(), + exploitAttemptedOnly: z.boolean().optional(), + minDaysUnused: z.number().int().nonnegative().optional(), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runCloudFindings(input)); } catch (err) { return fail(err); } + } + ); + + server.registerTool( + 'codeant_cloud_finding_get', + { + title: 'Get cloud security finding detail', + description: 'Fetch complete detail for one CSPM, VM, or container finding UID.', + inputSchema: { + org: z.string().optional(), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional(), + provider: z.enum(['aws', 'azure', 'gcp']), + kind: z.enum(['cspm', 'vm', 'container']).optional().describe('Default cspm.'), + scanId: z.string(), + uid: z.string(), + accountId: z.string().optional(), + tenantId: z.string().optional().describe('Required for Azure.'), + projectId: z.string().optional().describe('Required for GCP.'), + cloudService: z.string().optional(), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runCloudFindingGet(input)); } catch (err) { return fail(err); } + } + ); + + server.registerTool( + 'codeant_pentest_history', + { + title: 'List pentest engagements', + description: 'List every pentest engagement visible in the CodeAnt Pentesting UI, including status and finding counts.', + inputSchema: { + org: z.string().optional(), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional(), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runPentestHistory(input)); } catch (err) { return fail(err); } + } + ); + + server.registerTool( + 'codeant_pentest_issues', + { + title: 'List pentest issues', + description: 'Fetch all available open issues for one pentest engagement. The backend applies the same entitlement redaction as the UI.', + inputSchema: { + org: z.string().optional(), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional(), + reportId: z.string(), + variant: z.enum(['prod', 'test']).optional(), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runPentestIssues(input)); } catch (err) { return fail(err); } + } + ); + + server.registerTool( + 'codeant_pentest_report', + { + title: 'Get pentest report', + description: 'Fetch the full customer report for one pentest engagement. The backend applies the same entitlement redaction as the UI.', + inputSchema: { + org: z.string().optional(), + service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(), + providerBaseUrl: z.string().url().optional(), + reportId: z.string(), + variant: z.enum(['prod', 'test']).optional(), + }, + annotations: READ, + }, + async (input) => { + try { return ok(await runPentestReport(input)); } catch (err) { return fail(err); } + } + ); + // Generic GET keeps newly-added read APIs available without a CLI release. // Non-GET requests are registered below only when write mode is enabled. server.registerTool( diff --git a/tests/cloudFindings.test.js b/tests/cloudFindings.test.js new file mode 100644 index 0000000..1fe41ea --- /dev/null +++ b/tests/cloudFindings.test.js @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { resolveCliTenant, fetchAppApi } = vi.hoisted(() => ({ + resolveCliTenant: vi.fn(), + fetchAppApi: vi.fn(), +})); + +vi.mock('../src/api/tenant.js', () => ({ resolveCliTenant })); +vi.mock('../src/utils/fetchApi.js', () => ({ fetchAppApi })); + +const { runCloudFindingGet, runCloudFindings, runCloudHistory } = await import('../src/findings/cloud.js'); + +const tenant = { + organization: 'CodeAnt-AI', + service: 'github', + providerBaseUrl: 'https://github.com', + requestBody: { org: 'CodeAnt-AI', organization: 'CodeAnt-AI', service: 'github', github_base_url: 'https://github.com' }, +}; + +describe('cloud findings client', () => { + beforeEach(() => { + resolveCliTenant.mockReset(); + fetchAppApi.mockReset(); + resolveCliTenant.mockResolvedValue(tenant); + fetchAppApi.mockResolvedValue({ scans: [], findings: [] }); + }); + + it('loads all provider histories with one resolved authenticated tenant', async () => { + const result = await runCloudHistory({ provider: 'all' }); + + expect(resolveCliTenant).toHaveBeenCalledTimes(1); + expect(fetchAppApi).toHaveBeenCalledTimes(3); + expect(fetchAppApi).toHaveBeenCalledWith('/cloud/aws/scan/history', 'POST', expect.objectContaining({ + username: 'CodeAnt-AI', + github_base_url: 'https://github.com', + }), tenant); + expect(Object.keys(result.providers)).toEqual(['aws', 'azure', 'gcp']); + }); + + it('keeps other provider histories when one provider is unavailable', async () => { + fetchAppApi + .mockResolvedValueOnce({ scans: [{ scan_id: 'aws-1' }] }) + .mockRejectedValueOnce(new Error('Azure unavailable')) + .mockResolvedValueOnce({ scans: [{ scan_id: 'gcp-1' }] }); + + const result = await runCloudHistory({ provider: 'all', kind: 'container' }); + expect(result.providers.aws.scans).toEqual([{ scan_id: 'aws-1' }]); + expect(result.providers.azure.error).toBe('Azure unavailable'); + expect(result.providers.gcp.scans).toEqual([{ scan_id: 'gcp-1' }]); + }); + + it('passes Azure scope and UI-compatible finding filters', async () => { + await runCloudFindings({ + provider: 'azure', + scanId: 'scan-1', + tenantId: 'tenant-1', + cloudService: 'compute', + severity: 'high', + status: 'FAIL', + framework: 'cis', + subscriptionId: 'sub-1', + minDaysUnused: 30, + }); + + expect(fetchAppApi).toHaveBeenCalledWith('/cloud/azure/scan/findings', 'POST', expect.objectContaining({ + scan_id: 'scan-1', + tenant_id: 'tenant-1', + azure_service: 'compute', + severity: 'high', + status: 'FAIL', + framework: 'cis', + subscription_id: 'sub-1', + min_days_unused: 30, + }), tenant); + }); + + it('requires provider-specific scope and fetches detail by UID', async () => { + await expect(runCloudFindings({ provider: 'gcp', scanId: 'scan-1' })) + .rejects.toThrow(/--project-id/); + + await runCloudFindingGet({ provider: 'aws', scanId: 'scan-2', uid: 'finding-1', cloudService: 'iam' }); + expect(fetchAppApi).toHaveBeenLastCalledWith('/cloud/aws/scan/finding_detail', 'POST', expect.objectContaining({ + scan_id: 'scan-2', + uid: 'finding-1', + aws_service: 'iam', + }), tenant); + }); + + it('uses the VM and container result endpoints without CSPM account fields', async () => { + await runCloudFindings({ provider: 'gcp', kind: 'vm', scanId: 'vm-scan' }); + expect(fetchAppApi).toHaveBeenNthCalledWith(1, '/cloud/gcp/vm-scanning/results', 'POST', expect.objectContaining({ + scan_id: 'vm-scan', + }), tenant); + expect(fetchAppApi.mock.calls[0][2]).not.toHaveProperty('project_id'); + + await runCloudFindingGet({ provider: 'azure', kind: 'container', scanId: 'container-scan', uid: 'CVE-1' }); + expect(fetchAppApi).toHaveBeenNthCalledWith(2, '/cloud/azure/container-scanning/finding_detail', 'POST', expect.objectContaining({ + scan_id: 'container-scan', + uid: 'CVE-1', + }), tenant); + }); +}); diff --git a/tests/organizationAntipatterns.test.js b/tests/organizationAntipatterns.test.js new file mode 100644 index 0000000..a85c988 --- /dev/null +++ b/tests/organizationAntipatterns.test.js @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { resolveCliTenant, runRepos, fetchAppApi } = vi.hoisted(() => ({ + resolveCliTenant: vi.fn(), + runRepos: vi.fn(), + fetchAppApi: vi.fn(), +})); + +vi.mock('../src/api/tenant.js', () => ({ resolveCliTenant })); +vi.mock('../src/commands/scans/repos.js', () => ({ runRepos })); +vi.mock('../src/utils/fetchApi.js', () => ({ fetchAppApi })); + +const { runOrganizationAntipatterns, splitRepos } = await import('../src/findings/antipatterns.js'); + +const tenant = { + organization: 'CodeAnt-AI', + service: 'github', + providerBaseUrl: 'https://github.com', + requestBody: { org: 'CodeAnt-AI', organization: 'CodeAnt-AI', service: 'github', github_base_url: 'https://github.com' }, +}; + +describe('organization anti-pattern findings', () => { + beforeEach(() => { + resolveCliTenant.mockReset(); + runRepos.mockReset(); + fetchAppApi.mockReset(); + resolveCliTenant.mockResolvedValue(tenant); + fetchAppApi.mockResolvedValue({ antipatterns: [], total_issues: 0 }); + }); + + it('accepts comma-separated or repeated repository values', () => { + expect(splitRepos(['CodeAnt-AI/a,CodeAnt-AI/b', 'CodeAnt-AI/c'])) + .toEqual(['CodeAnt-AI/a', 'CodeAnt-AI/b', 'CodeAnt-AI/c']); + }); + + it('discovers every organization repository when none are supplied', async () => { + runRepos.mockResolvedValue({ repos: [{ full_name: 'CodeAnt-AI/a' }, { full_name: 'CodeAnt-AI/b' }] }); + + await runOrganizationAntipatterns({ org: 'CodeAnt-AI', service: 'github' }); + + expect(runRepos).toHaveBeenCalledWith({ org: 'CodeAnt-AI' }); + expect(fetchAppApi).toHaveBeenCalledWith('/explorer/quality/antipatterns', 'POST', expect.objectContaining({ + repos: ['CodeAnt-AI/a', 'CodeAnt-AI/b'], + org: 'CodeAnt-AI', + }), tenant); + }); + + it('uses explicitly selected repositories without discovery', async () => { + await runOrganizationAntipatterns({ repos: 'CodeAnt-AI/a,CodeAnt-AI/b' }); + expect(runRepos).not.toHaveBeenCalled(); + expect(fetchAppApi).toHaveBeenCalledWith(expect.any(String), 'POST', expect.objectContaining({ + repos: ['CodeAnt-AI/a', 'CodeAnt-AI/b'], + }), tenant); + }); +}); diff --git a/tests/pentestFindings.test.js b/tests/pentestFindings.test.js new file mode 100644 index 0000000..42b6d00 --- /dev/null +++ b/tests/pentestFindings.test.js @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { resolveCliTenant, fetchAppApi } = vi.hoisted(() => ({ + resolveCliTenant: vi.fn(), + fetchAppApi: vi.fn(), +})); + +vi.mock('../src/api/tenant.js', () => ({ resolveCliTenant })); +vi.mock('../src/utils/fetchApi.js', () => ({ fetchAppApi })); + +const { runPentestHistory, runPentestIssues, runPentestReport } = await import('../src/findings/pentest.js'); + +const tenant = { + organization: 'CodeAnt-AI', + service: 'github', + providerBaseUrl: 'https://github.com', + requestBody: { org: 'CodeAnt-AI', organization: 'CodeAnt-AI', service: 'github', github_base_url: 'https://github.com' }, +}; + +describe('pentest findings client', () => { + beforeEach(() => { + resolveCliTenant.mockReset(); + fetchAppApi.mockReset(); + resolveCliTenant.mockResolvedValue(tenant); + fetchAppApi.mockResolvedValue({ status: 'success', data: {} }); + }); + + it('lists engagement history using the selected organization', async () => { + await runPentestHistory({ org: 'CodeAnt-AI' }); + expect(fetchAppApi).toHaveBeenCalledWith('/pentesting/history', 'POST', expect.objectContaining({ + org: 'CodeAnt-AI', + service: 'github', + }), tenant); + }); + + it('fetches issues and test reports without changing UI entitlement behavior', async () => { + await runPentestIssues({ reportId: 'pt-1', variant: 'prod' }); + expect(fetchAppApi).toHaveBeenNthCalledWith(1, '/pentesting/issues', 'POST', expect.objectContaining({ + report_id: 'pt-1', + }), tenant); + expect(fetchAppApi.mock.calls[0][2]).not.toHaveProperty('variant'); + + await runPentestReport({ reportId: 'pt-1', variant: 'test' }); + expect(fetchAppApi).toHaveBeenNthCalledWith(2, '/pentesting/report', 'POST', expect.objectContaining({ + report_id: 'pt-1', + variant: 'test', + }), tenant); + }); + + it('rejects missing report IDs and unknown variants before an API request', async () => { + await expect(runPentestIssues({ variant: 'prod' })).rejects.toThrow(/--report-id/); + await expect(runPentestReport({ reportId: 'pt-1', variant: 'preview' })).rejects.toThrow(/--variant/); + }); +});