diff --git a/changelog.md b/changelog.md index 9150a3f..96e4b18 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,9 @@ # Changelog +## [0.5.16] - 02/11/2026 + +- Added `findings cloud start --kind container` to start an AWS ECR, Azure ACR, or GCP Artifact Registry container scan without the UI. `--wait` waits up to `--timeout` seconds (default 1800), and `--fail-on ` exits 2 when the scan has findings at or above that severity. A scan with failed images exits 3 and a timeout exits 4. + ## [0.5.15] - 26/10/2026 - Added `--secrets-scope current|history|all` to `findings repo` and `scans results`, with `current` as the default. Use `--history-run` to retrieve a specific history scan, including when no snapshot scan exists. diff --git a/findings.md b/findings.md index 3302f5d..87e5534 100644 --- a/findings.md +++ b/findings.md @@ -1,6 +1,6 @@ # 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. +`codeant findings` is the unified entry point for findings visible in the CodeAnt app. It reuses the same authenticated backend endpoints and authorization checks as the UI. Every command is read-only except `findings cloud start`, which starts a container scan. ```bash codeant login @@ -20,6 +20,7 @@ When one login has multiple connections, pass the exact `--org` and `--service` | 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 | +| Start an ECR/ACR/Artifact Registry container scan | `codeant findings cloud start` | organization + registry connection | | 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. @@ -119,6 +120,64 @@ codeant findings cloud list \ `--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 keep the UI result payload, but `findings` is paged with the same `--limit`/`--offset`/`--all` options as anti-patterns and the response carries `total`; `findings_total` and `severity_rollup` still describe the whole scan. +### Starting a container scan + +`findings cloud start` scans images from an AWS, Azure, or GCP registry connection already set up in the CodeAnt app. For AWS, the CodeAnt IAM role needs the `AmazonEC2ContainerRegistryReadOnly` policy; the CSPM policies alone cannot pull images (see [Container Scanning](https://docs.codeant.ai/cloud_security/container-scanning)). It needs no UI and works with `CODEANT_API_TOKEN` in CI. + +```bash +# Start and return immediately +codeant findings cloud start --provider aws --kind container \ + --region ap-south-1 --repositories api,worker:v1.4.2 + +# Start, wait up to 30 minutes, and exit 2 on HIGH or CRITICAL findings +codeant findings cloud start --provider aws --kind container \ + --region ap-south-1 --repositories api \ + --wait --timeout 1800 --fail-on high + +# Azure ACR and GCP Artifact Registry +codeant findings cloud start --provider azure --kind container --repositories myregistry/api:latest --wait +codeant findings cloud start --provider gcp --kind container --repositories my-project/us-central1/docker/api --wait + +# Findings of the scan, with the existing command +codeant findings cloud list --provider aws --kind container --scan-id --all +``` + +| Provider | `--repositories` entry | Other inputs | +|---|---|---| +| `aws` | `` | `--region` (required), `--config-id` to pick one of several connected AWS accounts | +| `azure` | `/` | — | +| `gcp` | `///` | — | + +Append `:` to scan one tag; without it the newest tag is scanned. Without `--repositories`, every repository the connection can see is scanned. The `repositories` field of an earlier scan in `findings cloud history --kind container` shows exact names. + +Without `--wait`, the response is `{ "scan_id", "status": "running", "repositories_total" }`. With `--wait`, the CLI checks the scan every 15 seconds and prints its summary once it finishes: `status` (`completed` or `completed_with_errors`), `severity_rollup`, `findings_total`, and one entry per image under `repositories` with its `status` and `error`. With `--fail-on`, the summary also carries `fail_on` and `threshold_breached`. `--fail-on` counts that severity and every higher one, from `critical`, `high`, `medium`, `low`, `unknown`. + +| Exit code | Meaning | +|---|---| +| `0` | Started (no `--wait`), or finished below the `--fail-on` threshold | +| `1` | Request error: authentication, missing registry connection, denied cloud access, invalid input | +| `2` | Findings at or above `--fail-on` | +| `3` | At least one image failed to scan, for example an unknown repository or tag; see `repositories[].error` | +| `4` | Still running when `--timeout` ran out; the scan continues and its `scan_id` is in the error | + +A threshold breach takes precedence over failed images. A repository name that does not exist does not fail the request; the scan finishes with that image failed (exit `3`). + +#### CI example (GitHub Actions) + +```yaml +- name: Scan container image + env: + CODEANT_API_TOKEN: ${{ secrets.CODEANT_API_TOKEN }} + run: | + npm install -g codeant-cli + codeant findings cloud start --provider aws --kind container \ + --org my-org --service github \ + --region us-east-1 --repositories api:${{ github.sha }} \ + --wait --timeout 1800 --fail-on critical +``` + +`CODEANT_API_TOKEN` is the `apiKeyV2` value that `codeant login` saves in `~/.codeant/config.json`. Pass `--org` and `--service` when the login covers more than one organization. + ## Pentest findings ```bash diff --git a/package.json b/package.json index 1a1ac55..5bf945b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeant-cli", - "version": "0.5.15", + "version": "0.5.16", "description": "Code review CLI tool", "type": "module", "repository": { diff --git a/src/commands/findings/index.js b/src/commands/findings/index.js index 4d20c34..199fcea 100644 --- a/src/commands/findings/index.js +++ b/src/commands/findings/index.js @@ -4,7 +4,8 @@ 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 { runCloudFindingGet, runCloudFindings, runCloudHistory, runCloudScanStart } from '../../findings/cloud.js'; +import { intOption } from '../../utils/intOption.js'; import { runPentestHistory, runPentestIssues, runPentestReport } from '../../findings/pentest.js'; function addTenantOptions(command) { @@ -196,6 +197,37 @@ export default function registerFindingsCommands(program, { runCmd }) { .requiredOption('--uid ', 'Finding UID') .action((options) => runCmd(() => runCloudFindingGet({ ...cloudOptions(options), uid: options.uid }))); + addTenantOptions(cloud.command('start').description('Start a container scan; --wait waits for it and applies --fail-on')) + .requiredOption('--provider ', 'Cloud provider: aws, azure, or gcp') + .requiredOption('--kind ', 'Scan kind: container') + .option('--region ', 'AWS region of the ECR registry (required for AWS)') + .option('--config-id ', 'AWS connection ID (default: the first connected AWS account)') + .option('--repositories ', 'Comma-separated repositories, each optionally :tag (default: every repository)') + .option('--wait', 'Wait until the scan finishes') + .option('--timeout ', 'Maximum --wait time (default: 1800)', intOption({ min: 1 })) + .option('--fail-on ', 'With --wait, exit 2 when findings at or above: critical, high, medium, low, unknown') + .action(async (options) => { + try { + const result = await runCloudScanStart({ + ...tenantOptions(options), + provider: options.provider, + kind: options.kind, + region: options.region, + configId: options.configId, + repositories: options.repositories, + wait: options.wait, + timeout: options.timeout, + failOn: options.failOn, + }); + console.log(JSON.stringify(result, null, 2)); + if (result.threshold_breached) process.exitCode = 2; + else if (result.status === 'completed_with_errors') process.exitCode = 3; + } catch (err) { + console.error(JSON.stringify({ error: err.message }, null, 2)); + process.exit(err.exitCode ?? 1); + } + }); + 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)))); diff --git a/src/findings/cloud.js b/src/findings/cloud.js index 1e5f307..df12078 100644 --- a/src/findings/cloud.js +++ b/src/findings/cloud.js @@ -1,9 +1,13 @@ +import { setTimeout as sleep } from 'node:timers/promises'; + import { resolveCliTenant } from '../api/tenant.js'; import { fetchAppApi } from '../utils/fetchApi.js'; import { fetchPaginated } from './paginate.js'; const PROVIDERS = new Set(['aws', 'azure', 'gcp']); const KINDS = new Set(['cspm', 'vm', 'container']); +const SEVERITIES = ['critical', 'high', 'medium', 'low', 'unknown']; +const POLL_INTERVAL_MS = 15_000; function normalizeProvider(value, { allowAll = false } = {}) { const provider = String(value || '').trim().toLowerCase(); @@ -116,4 +120,50 @@ export async function runCloudFindingGet(options = {}) { }); } +function scanSummary(scan, failOn) { + const { findings, total, limit, offset, ...summary } = scan; + if (!failOn) return summary; + const blocking = SEVERITIES.slice(0, SEVERITIES.indexOf(failOn) + 1); + return { + ...summary, + fail_on: failOn, + threshold_breached: blocking.some((severity) => summary.severity_rollup?.[severity] > 0), + }; +} + +export async function runCloudScanStart(options = {}) { + const provider = normalizeProvider(options.provider); + if (normalizeKind(options.kind) !== 'container') { + throw new Error('Only --kind container scans can be started from the CLI.'); + } + const failOn = options.failOn?.toString().trim().toLowerCase(); + if (failOn !== undefined && !SEVERITIES.includes(failOn)) { + throw new Error(`--fail-on must be one of: ${SEVERITIES.join(', ')}.`); + } + if (failOn !== undefined && !options.wait) throw new Error('--fail-on requires --wait.'); + const target = {}; + if (provider === 'aws') target.region = requireValue(options.region, '--region for AWS container scans'); + if (options.configId) target.config_id = options.configId; + if (options.repositories) { + target.repositories = String(options.repositories).split(',').map((name) => name.trim()).filter(Boolean); + } + + const tenant = await resolveCliTenant(options); + const started = await request(provider, 'container', 'start', options, target, tenant); + if (!options.wait) return started; + + const timeout = options.timeout ?? 1800; + const deadline = Date.now() + timeout * 1000; + for (;;) { + const scan = await request(provider, 'container', 'results/paginated', options, { scan_id: started.scan_id, limit: 25 }, tenant); + if (scan.status !== 'running') return scanSummary(scan, failOn); + if (Date.now() >= deadline) { + const err = new Error(`Scan ${started.scan_id} is still running after ${timeout}s. Check it later with: codeant findings cloud list --provider ${provider} --kind container --scan-id ${started.scan_id}`); + err.exitCode = 4; + throw err; + } + await sleep(Math.min(POLL_INTERVAL_MS, deadline - Date.now())); + } +} + export { normalizeKind, normalizeProvider };