From d9a90e51f9dd126af82eb2e526d370ff5fd954c8 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Mon, 9 Nov 2026 11:06:00 +0000 Subject: [PATCH 1/4] feat(findings): accept ContainerSCA in --scan-types There was no way to list only container image scanning findings. The API's value for that scan type is ContainerSCA; ContainerScanning and Container (the product's wording) are accepted as aliases. The normalized list is de-duplicated since several aliases map to one value. Refs OD-1049 Co-Authored-By: Claude Opus 5.5 --- .changeset/findings-container-sca.md | 5 +++++ SPECS/README.md | 1 + SPECS/commands/findings.md | 4 ++-- src/commands/findings.test.ts | 30 ++++++++++++++++++++++++++++ src/commands/findings.ts | 14 +++++++++++-- 5 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 .changeset/findings-container-sca.md diff --git a/.changeset/findings-container-sca.md b/.changeset/findings-container-sca.md new file mode 100644 index 0000000..933b539 --- /dev/null +++ b/.changeset/findings-container-sca.md @@ -0,0 +1,5 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +`findings --scan-types` now accepts `ContainerSCA` (aliases `ContainerScanning` and `Container`), so you can list only container image scanning findings. diff --git a/SPECS/README.md b/SPECS/README.md index 34951f1..08d3e5a 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -48,6 +48,7 @@ This is the single source of truth for all project tasks and specs. | Date | What was done | |---|---| +| 2026-11-09 | (OD-1049) `findings --scan-types` accepts container image scanning. The API value is **`ContainerSCA`** (as the `SearchSRMItems.scanTypes` example in the spec lists it), not `ContainerScanning` as the ticket named it; `ContainerScanning` and `Container` are accepted as aliases, normalized like the other scan types (case-insensitive, ignoring spaces/hyphens/underscores). The normalized list is now de-duplicated, since several aliases resolve to one value. Not done: the separate `ContainerImageFilter` (filter by image name/tag) on the same request body is not exposed as an option (1 new test, 799 total) | | 2026-11-03 | **Node.js engines raised for CVE-2026-111497** (Brotli decoder OOB write, bundled in Node). `engines` `>=20` → `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`, the first patched release of each line; Node 20 is EOL and unpatched, so it is dropped. CI matrix `[20, 22]` → `[22, 24]`. No dependency change: the repo ships no Brotli package, but `fetch` (undici) decompresses `Content-Encoding: br` responses with Node's bundled decoder, so the runtime is the exposure. `engines` only warns on install; no runtime version check was added | | 2026-09-30 | (OD-590, OD-784) `fixedVersion` surfaced on `issue`/`issues`/`pull-request --issue`, now that `CommitIssue` carries it (first in API `57.7.9`; counterpart of `codacy-spa` #3145). **API bump: pinned `57.6.4` → `57.7.10`** — a diff of both bundled specs shows only `CommitIssue.fixedVersion` added, the organization webhook operations retagged `organization` → `webhooks` (the CLI uses none of them) and a `hasWebhooksEnabled` field on an organization schema; no operation signatures shifted, so `SPECS/repository-tokens.md`'s whitelist needed no changes. `57.7.9` and `57.7.10` bundle identical specs. `printIssueCard` (list) and `printIssueCodeContext` (detail, shared with `pull-request --issue` and `finding`'s linked-issue block) now pass `issue.fixedVersion` to the existing `formatDependencyChainsLine`/`formatDependencyChainsBlock`, so a direct dependency reads `Direct - Update to ` and a transitive one ends `(Fixed in )`, as on `finding`/`findings`. The param `finding` passes (`SrmItem.fixedVersion`) still wins when non-empty. An empty array (no fix available) renders as before — no version text — rather than adding a "no fix" phrase the other commands don't use. `fixedVersion` added to all three JSON projections (issue.test.ts 24→28, issues.test.ts 76→78, pull-request.test.ts +2) | | 2026-09-23 | (OD-748) `images` shows a **Tags** column (per-image `tagCount`) and an `Image tags: X of Y used` line under the header, read from `listOrganizationImages`' new `usage` object (OD-724, API 57.6.4 — `fetch-api` bumped from 57.4.17). Still one request per page. The line turns red at the cap, where new tags are rejected. Exact figures, not `formatCount`. `--output json` stays an array and gains `tagCount` only; `usage` is not in the JSON, since adding it would change the top-level shape The `image --delete --keep-latest` budget warning now reads that same `usage.limit` from the image-count request it already made, in place of the hardcoded `DEFAULT_ORG_TAG_CAP` (1,000), and drops its "this CLI cannot read the value in force" disclaimer Both commands tolerate a response without `usage`/`tagCount` (an API behind the client), and the exact-figure formatter is now shared as `formatExactCount` (5 new tests, 784 total) | diff --git a/SPECS/commands/findings.md b/SPECS/commands/findings.md index f19809c..7db496d 100644 --- a/SPECS/commands/findings.md +++ b/SPECS/commands/findings.md @@ -28,7 +28,7 @@ codacy find gh my-org my-repo --output json | `--severities ` | `-s` | Comma-separated priority levels: Critical, High, Medium, Low | | `--statuses ` | `-S` | Comma-separated statuses: Overdue, OnTrack, DueSoon, ClosedOnTime, ClosedLate, Ignored | | `--categories ` | `-c` | Comma-separated security category names | -| `--scan-types ` | `-T` | Comma-separated scan types: SAST, Secrets, SCA, CICD, IaC, DAST, PenTesting, License, CSPM | +| `--scan-types ` | `-T` | Comma-separated scan types: SAST, Secrets, SCA, ContainerSCA (aliases: ContainerScanning, Container), CICD, IaC, DAST, PenTesting, License, CSPM. Case-insensitive; spaces, hyphens and underscores are ignored | | `--dast-targets ` | `-d` | Comma-separated DAST target URLs | Default status filter: `Overdue,OnTrack,DueSoon`. @@ -75,4 +75,4 @@ When an item carries `advisoryInformation` (`{advisoryId, vulnerableFunctions, p ## Tests -File: `src/commands/findings.test.ts` — 27 tests (24 + 3 for advisoryInformation). +File: `src/commands/findings.test.ts` — 29 tests. diff --git a/src/commands/findings.test.ts b/src/commands/findings.test.ts index 95b8b55..65b70fa 100644 --- a/src/commands/findings.test.ts +++ b/src/commands/findings.test.ts @@ -376,6 +376,36 @@ describe("findings command", () => { ); }); + it("should map container scanning aliases to ContainerSCA and de-duplicate", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", + "test", + "findings", + "gh", + "test-org", + "--scan-types", + "ContainerSCA,container-scanning,Container_Scanning,container", + ]); + + expect(SecurityService.searchSecurityItems).toHaveBeenCalledWith( + "gh", + "test-org", + undefined, + 100, + "Status", + "asc", + { + statuses: ["Overdue", "OnTrack", "DueSoon"], + scanTypes: ["ContainerSCA"], + }, + ); + }); + it("should preserve the API sort order (no client-side resorting)", async () => { // API returns Medium first, Critical second — client must not re-sort const apiOrder = [mockFindings[1], mockFindings[0]]; // Medium, then Critical diff --git a/src/commands/findings.ts b/src/commands/findings.ts index a8504b4..6aea9f9 100644 --- a/src/commands/findings.ts +++ b/src/commands/findings.ts @@ -55,6 +55,12 @@ const SCAN_TYPE_NORMALIZE: Record = { sast: "SAST", secrets: "Secrets", sca: "SCA", + // The API calls container image scanning "ContainerSCA"; the product UI + // calls it "Container scanning", so both spellings (and plain "container") + // map to the API value. + containersca: "ContainerSCA", + containerscanning: "ContainerSCA", + container: "ContainerSCA", cicd: "CICD", iac: "IaC", dast: "DAST", @@ -210,7 +216,7 @@ export function registerFindingsCommand(program: Command) { ) .option( "-T, --scan-types ", - "comma-separated scan types (case-insensitive): SAST, Secrets, SCA, CICD, IaC, DAST, PenTesting, License, CSPM", + "comma-separated scan types (case-insensitive): SAST, Secrets, SCA, ContainerSCA (alias: ContainerScanning), CICD, IaC, DAST, PenTesting, License, CSPM", ) .option("-n, --limit ", "maximum number of findings to return (default: 100, max: 1000)", "100") .option("-d, --dast-targets ", "comma-separated DAST target URLs") @@ -224,6 +230,7 @@ Examples: $ codacy findings gh my-org # organization-wide findings $ codacy findings gh my-org --severities Critical,High $ codacy findings gh my-org my-repo --statuses Overdue,DueSoon + $ codacy findings gh my-org --scan-types ContainerSCA # container image findings only $ codacy findings gh my-org my-repo --limit 500 $ codacy findings gh my-org my-repo --output json`, ) @@ -286,7 +293,10 @@ Examples: const categories = parseCommaList(opts.categories); if (categories) body.categories = categories; const scanTypes = parseCommaList(opts.scanTypes); - if (scanTypes) body.scanTypes = scanTypes.map(normalizeScanType); + // De-duplicated: several aliases map to the same API value (e.g. + // ContainerScanning and ContainerSCA). + if (scanTypes) + body.scanTypes = [...new Set(scanTypes.map(normalizeScanType))]; const dastTargets = parseCommaList(opts.dastTargets); if (dastTargets) body.dastTargetUrls = dastTargets; From 593d30736e0b1ddcd54626752f3ef12e98fc082b Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Mon, 9 Nov 2026 11:09:08 +0000 Subject: [PATCH 2/4] feat(findings): filter by container image and tag Adds -i, --image and -t, --image-tag , mapped to the search body's containerImage (ContainerImageFilter). The API requires a name for a tag, so --image-tag alone is refused before any request. Cards show the scanned image as name:tag and JSON gains imageName/imageTag. Also lists the Container alias in --scan-types help (Codacy review). Refs OD-1049 Co-Authored-By: Claude Opus 5.5 --- .changeset/findings-container-sca.md | 2 +- SPECS/README.md | 2 +- SPECS/commands/findings.md | 12 ++- src/commands/findings.test.ts | 118 +++++++++++++++++++++++++++ src/commands/findings.ts | 31 ++++++- 5 files changed, 158 insertions(+), 7 deletions(-) diff --git a/.changeset/findings-container-sca.md b/.changeset/findings-container-sca.md index 933b539..eb068cf 100644 --- a/.changeset/findings-container-sca.md +++ b/.changeset/findings-container-sca.md @@ -2,4 +2,4 @@ "@codacy/codacy-cloud-cli": minor --- -`findings --scan-types` now accepts `ContainerSCA` (aliases `ContainerScanning` and `Container`), so you can list only container image scanning findings. +`findings` can now narrow to container scanning: `--scan-types` accepts `ContainerSCA` (aliases `ContainerScanning` and `Container`), and the new `--image ` and `--image-tag ` options filter findings to one container image. Container findings show their image as `name:tag` on each card and include `imageName`/`imageTag` in `--output json`. diff --git a/SPECS/README.md b/SPECS/README.md index 08d3e5a..b09ffe2 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -48,7 +48,7 @@ This is the single source of truth for all project tasks and specs. | Date | What was done | |---|---| -| 2026-11-09 | (OD-1049) `findings --scan-types` accepts container image scanning. The API value is **`ContainerSCA`** (as the `SearchSRMItems.scanTypes` example in the spec lists it), not `ContainerScanning` as the ticket named it; `ContainerScanning` and `Container` are accepted as aliases, normalized like the other scan types (case-insensitive, ignoring spaces/hyphens/underscores). The normalized list is now de-duplicated, since several aliases resolve to one value. Not done: the separate `ContainerImageFilter` (filter by image name/tag) on the same request body is not exposed as an option (1 new test, 799 total) | +| 2026-11-09 | (OD-1049) `findings` can narrow to container scanning. **`--scan-types` accepts `ContainerSCA`**, the API value (as the `SearchSRMItems.scanTypes` example in the spec lists it), not `ContainerScanning` as the ticket named it; `ContainerScanning` and `Container` are accepted as aliases, normalized like the other scan types. The normalized list is now de-duplicated, since several aliases resolve to one value. **`-i, --image ` and `-t, --image-tag `** map to the body's `containerImage` (`ContainerImageFilter`); `--image-tag` without `--image` is refused locally, since the API requires `name`. Cards show the scanned image as `name:tag` at the end of the status line, and JSON gains `imageName`/`imageTag` (7 new tests, 805 total) | | 2026-11-03 | **Node.js engines raised for CVE-2026-111497** (Brotli decoder OOB write, bundled in Node). `engines` `>=20` → `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`, the first patched release of each line; Node 20 is EOL and unpatched, so it is dropped. CI matrix `[20, 22]` → `[22, 24]`. No dependency change: the repo ships no Brotli package, but `fetch` (undici) decompresses `Content-Encoding: br` responses with Node's bundled decoder, so the runtime is the exposure. `engines` only warns on install; no runtime version check was added | | 2026-09-30 | (OD-590, OD-784) `fixedVersion` surfaced on `issue`/`issues`/`pull-request --issue`, now that `CommitIssue` carries it (first in API `57.7.9`; counterpart of `codacy-spa` #3145). **API bump: pinned `57.6.4` → `57.7.10`** — a diff of both bundled specs shows only `CommitIssue.fixedVersion` added, the organization webhook operations retagged `organization` → `webhooks` (the CLI uses none of them) and a `hasWebhooksEnabled` field on an organization schema; no operation signatures shifted, so `SPECS/repository-tokens.md`'s whitelist needed no changes. `57.7.9` and `57.7.10` bundle identical specs. `printIssueCard` (list) and `printIssueCodeContext` (detail, shared with `pull-request --issue` and `finding`'s linked-issue block) now pass `issue.fixedVersion` to the existing `formatDependencyChainsLine`/`formatDependencyChainsBlock`, so a direct dependency reads `Direct - Update to ` and a transitive one ends `(Fixed in )`, as on `finding`/`findings`. The param `finding` passes (`SrmItem.fixedVersion`) still wins when non-empty. An empty array (no fix available) renders as before — no version text — rather than adding a "no fix" phrase the other commands don't use. `fixedVersion` added to all three JSON projections (issue.test.ts 24→28, issues.test.ts 76→78, pull-request.test.ts +2) | | 2026-09-23 | (OD-748) `images` shows a **Tags** column (per-image `tagCount`) and an `Image tags: X of Y used` line under the header, read from `listOrganizationImages`' new `usage` object (OD-724, API 57.6.4 — `fetch-api` bumped from 57.4.17). Still one request per page. The line turns red at the cap, where new tags are rejected. Exact figures, not `formatCount`. `--output json` stays an array and gains `tagCount` only; `usage` is not in the JSON, since adding it would change the top-level shape The `image --delete --keep-latest` budget warning now reads that same `usage.limit` from the image-count request it already made, in place of the hardcoded `DEFAULT_ORG_TAG_CAP` (1,000), and drops its "this CLI cannot read the value in force" disclaimer Both commands tolerate a response without `usage`/`tagCount` (an API behind the client), and the exact-figure formatter is now shared as `formatExactCount` (5 new tests, 784 total) | diff --git a/SPECS/commands/findings.md b/SPECS/commands/findings.md index 7db496d..82aeff6 100644 --- a/SPECS/commands/findings.md +++ b/SPECS/commands/findings.md @@ -30,9 +30,13 @@ codacy find gh my-org my-repo --output json | `--categories ` | `-c` | Comma-separated security category names | | `--scan-types ` | `-T` | Comma-separated scan types: SAST, Secrets, SCA, ContainerSCA (aliases: ContainerScanning, Container), CICD, IaC, DAST, PenTesting, License, CSPM. Case-insensitive; spaces, hyphens and underscores are ignored | | `--dast-targets ` | `-d` | Comma-separated DAST target URLs | +| `--image ` | `-i` | Container image name (sent as `containerImage.name`) | +| `--image-tag ` | `-t` | Container image tag (sent as `containerImage.tag`). Requires `--image`: the API rejects a tag without a name, so the CLI refuses it locally before any request or git auto-detection | Default status filter: `Overdue,OnTrack,DueSoon`. +`--image` / `--image-tag` map to the request body's single `ContainerImageFilter` (one image per request, not a list — hence singular names). `--image-tag` rather than `--tag` so it reads as narrowing the image, not as a finding tag. `--image` does not add `ContainerSCA` to `--scan-types` implicitly; the API applies the filter itself. + ## Output Card-style format: @@ -44,7 +48,7 @@ Card-style format: {Finding title} {Optional: affectedTargets} -{Status} {DueAt} | {Optional: CVE or CWE} | {Optional: AffectedVersion → FixedVersion} | {Optional: Application} +{Status} {DueAt} | {Optional: CVE or CWE} | {Optional: AffectedVersion → FixedVersion} | {Optional: Application} | {Optional: imageName[:imageTag]} {Optional: Dependency import chain (SCA findings with dependencyChains)} {Optional: Vulnerable functions: fn1, fn2 (+N more) — from item.advisoryInformation} @@ -73,6 +77,10 @@ is dropped from the status line (it would duplicate the chain line). When an item carries `advisoryInformation` (`{advisoryId, vulnerableFunctions, publishedAt}`), a compact `Vulnerable functions: fn1, fn2 (+N more)` line is shown below the status/chain lines, via the shared `summarizeFunctions` helper (`utils/formatting.ts`, capped at 3 entries) — same rendering as `issue`/`pull-request`'s card view. Included in `--output json` as `advisoryInformation.{advisoryId,vulnerableFunctions,publishedAt}` per item. +### Container image (container scanning) + +When an item carries `imageName` (container scanning findings), the status line ends with the scanned image as `name:tag` (just `name` when `imageTag` is absent), dimmed and sanitized. `--output json` includes `imageName` and `imageTag` per item. + ## Tests -File: `src/commands/findings.test.ts` — 29 tests. +File: `src/commands/findings.test.ts` — 35 tests. diff --git a/src/commands/findings.test.ts b/src/commands/findings.test.ts index 65b70fa..7f331ae 100644 --- a/src/commands/findings.test.ts +++ b/src/commands/findings.test.ts @@ -777,6 +777,124 @@ describe("findings command", () => { mockExit.mockRestore(); }); + describe("container image filter", () => { + const containerFinding = { + ...mockFindings[0], + id: "finding-container", + scanType: "ContainerSCA", + imageName: "codacy/codacy-website", + imageTag: "1.25.3", + }; + + async function runFindings(...args: string[]) { + const program = createProgram(); + await program.parseAsync(["node", "test", ...args]); + } + + it("should send containerImage with name only when --image is given", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [], + } as any); + + await runFindings("findings", "gh", "test-org", "--image", "nginx"); + + expect(SecurityService.searchSecurityItems).toHaveBeenCalledWith( + "gh", + "test-org", + undefined, + 100, + "Status", + "asc", + { + statuses: ["Overdue", "OnTrack", "DueSoon"], + containerImage: { name: "nginx" }, + }, + ); + }); + + it("should send containerImage with name and tag when both are given", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [], + } as any); + + await runFindings( + "findings", + "gh", + "test-org", + "-T", + "container", + "-i", + "nginx", + "-t", + "1.25.3", + ); + + expect(SecurityService.searchSecurityItems).toHaveBeenCalledWith( + "gh", + "test-org", + undefined, + 100, + "Status", + "asc", + { + statuses: ["Overdue", "OnTrack", "DueSoon"], + scanTypes: ["ContainerSCA"], + containerImage: { name: "nginx", tag: "1.25.3" }, + }, + ); + }); + + it("should refuse --image-tag without --image before any request", async () => { + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + await expect( + runFindings("findings", "gh", "test-org", "--image-tag", "1.25.3"), + ).rejects.toThrow("process.exit called"); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("--image-tag requires --image"), + ); + expect(SecurityService.searchSecurityItems).not.toHaveBeenCalled(); + mockExit.mockRestore(); + }); + + it("should show the scanned image as name:tag on the card", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [containerFinding], + } as any); + + await runFindings("findings", "gh", "test-org"); + + expect(getAllOutput()).toContain("codacy/codacy-website:1.25.3"); + }); + + it("should show the image name alone when the finding has no tag", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [{ ...containerFinding, imageTag: undefined }], + } as any); + + await runFindings("findings", "gh", "test-org"); + + const output = getAllOutput(); + expect(output).toContain("codacy/codacy-website"); + expect(output).not.toContain("codacy/codacy-website:"); + }); + + it("should include imageName and imageTag in JSON output", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [containerFinding], + } as any); + + await runFindings("--output", "json", "findings", "gh", "test-org"); + + const json = JSON.parse(vi.mocked(console.log).mock.calls[0][0]); + expect(json.findings[0].imageName).toBe("codacy/codacy-website"); + expect(json.findings[0].imageTag).toBe("1.25.3"); + }); + }); + describe("advisory information (vulnerable functions)", () => { const mockAdvisoryInformation = { advisoryId: "CVE-2026-27903", diff --git a/src/commands/findings.ts b/src/commands/findings.ts index 6aea9f9..ec322e0 100644 --- a/src/commands/findings.ts +++ b/src/commands/findings.ts @@ -130,6 +130,14 @@ function buildFindingStatusLine(item: SrmItem, hasChains: boolean): string { if (item.application) parts.push(ansis.dim(sanitizeText(item.application))); + // Container scanning findings: the scanned image, as name:tag. + if (item.imageName) { + const image = item.imageTag + ? `${item.imageName}:${item.imageTag}` + : item.imageName; + parts.push(ansis.dim(sanitizeText(image))); + } + return parts.join(pipe); } @@ -216,10 +224,12 @@ export function registerFindingsCommand(program: Command) { ) .option( "-T, --scan-types ", - "comma-separated scan types (case-insensitive): SAST, Secrets, SCA, ContainerSCA (alias: ContainerScanning), CICD, IaC, DAST, PenTesting, License, CSPM", + "comma-separated scan types (case-insensitive): SAST, Secrets, SCA, ContainerSCA (aliases: ContainerScanning, Container), CICD, IaC, DAST, PenTesting, License, CSPM", ) .option("-n, --limit ", "maximum number of findings to return (default: 100, max: 1000)", "100") .option("-d, --dast-targets ", "comma-separated DAST target URLs") + .option("-i, --image ", "container image name, e.g. nginx (container scanning findings)") + .option("-t, --image-tag ", "container image tag, e.g. 1.25.3 (requires --image)") .addOption(repositoryTokenOption()) .addHelpText( "after", @@ -231,6 +241,7 @@ Examples: $ codacy findings gh my-org --severities Critical,High $ codacy findings gh my-org my-repo --statuses Overdue,DueSoon $ codacy findings gh my-org --scan-types ContainerSCA # container image findings only + $ codacy findings gh my-org --image nginx --image-tag 1.25.3 $ codacy findings gh my-org my-repo --limit 500 $ codacy findings gh my-org my-repo --output json`, ) @@ -243,6 +254,14 @@ Examples: try { resolveAccountAuth(this, "Codacy does not accept repository tokens on the security findings endpoints"); + // The API rejects a tag without an image name (ContainerImageFilter + // requires `name`). Refuse locally, before git auto-detection or any + // request. + const opts = this.opts(); + if (opts.imageTag && !opts.image) { + throw new Error("--image-tag requires --image ."); + } + const argCount = [providerArg, organizationArg, repositoryArg].filter( (v) => v !== undefined, ).length; @@ -278,7 +297,6 @@ Examples: ); } - const opts = this.opts(); const format = getOutputFormat(this); const body: SearchSRMItems = {}; @@ -299,6 +317,11 @@ Examples: body.scanTypes = [...new Set(scanTypes.map(normalizeScanType))]; const dastTargets = parseCommaList(opts.dastTargets); if (dastTargets) body.dastTargetUrls = dastTargets; + if (opts.image) { + body.containerImage = opts.imageTag + ? { name: opts.image, tag: opts.imageTag } + : { name: opts.image }; + } const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 100, 1), 1000); @@ -352,6 +375,8 @@ Examples: "fixedVersion", "application", "affectedTargets", + "imageName", + "imageTag", "dependencyChains", "advisoryInformation.advisoryId", "advisoryInformation.vulnerableFunctions", @@ -367,7 +392,7 @@ Examples: if (total > items.length || cursor) { printPaginationWarning( { cursor: "more", limit: items.length }, - "Use --limit (max 1000) to fetch more, or --severities, --statuses to filter.", + "Use --limit (max 1000) to fetch more, or --severities, --statuses, --image to filter.", ); } } catch (err) { From 4bac273162efb3528b11825b66240eda79d6b245 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Mon, 9 Nov 2026 11:19:20 +0000 Subject: [PATCH 3/4] feat(findings): show image:tag in place of the repository A container scanning finding is about an image, not a repository, so its card header shows name:tag where org-wide listings show the repository, and no longer repeats it at the end of the status line. The image also shows in the single-repository view, where the repository is hidden as redundant. Refs OD-1049 Co-Authored-By: Claude Opus 5.5 --- .changeset/findings-container-sca.md | 2 +- SPECS/README.md | 2 +- SPECS/commands/findings.md | 8 ++--- src/commands/findings.test.ts | 44 ++++++++++++++++++++++++---- src/commands/findings.ts | 23 ++++++++------- 5 files changed, 58 insertions(+), 21 deletions(-) diff --git a/.changeset/findings-container-sca.md b/.changeset/findings-container-sca.md index eb068cf..1e43cc9 100644 --- a/.changeset/findings-container-sca.md +++ b/.changeset/findings-container-sca.md @@ -2,4 +2,4 @@ "@codacy/codacy-cloud-cli": minor --- -`findings` can now narrow to container scanning: `--scan-types` accepts `ContainerSCA` (aliases `ContainerScanning` and `Container`), and the new `--image ` and `--image-tag ` options filter findings to one container image. Container findings show their image as `name:tag` on each card and include `imageName`/`imageTag` in `--output json`. +`findings` can now narrow to container scanning: `--scan-types` accepts `ContainerSCA` (aliases `ContainerScanning` and `Container`), and the new `--image ` and `--image-tag ` options filter findings to one container image. Container findings show their image as `name:tag` in place of the repository on each card and include `imageName`/`imageTag` in `--output json`. diff --git a/SPECS/README.md b/SPECS/README.md index b09ffe2..36f8871 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -48,7 +48,7 @@ This is the single source of truth for all project tasks and specs. | Date | What was done | |---|---| -| 2026-11-09 | (OD-1049) `findings` can narrow to container scanning. **`--scan-types` accepts `ContainerSCA`**, the API value (as the `SearchSRMItems.scanTypes` example in the spec lists it), not `ContainerScanning` as the ticket named it; `ContainerScanning` and `Container` are accepted as aliases, normalized like the other scan types. The normalized list is now de-duplicated, since several aliases resolve to one value. **`-i, --image ` and `-t, --image-tag `** map to the body's `containerImage` (`ContainerImageFilter`); `--image-tag` without `--image` is refused locally, since the API requires `name`. Cards show the scanned image as `name:tag` at the end of the status line, and JSON gains `imageName`/`imageTag` (7 new tests, 805 total) | +| 2026-11-09 | (OD-1049) `findings` can narrow to container scanning. **`--scan-types` accepts `ContainerSCA`**, the API value (as the `SearchSRMItems.scanTypes` example in the spec lists it), not `ContainerScanning` as the ticket named it; `ContainerScanning` and `Container` are accepted as aliases, normalized like the other scan types. The normalized list is now de-duplicated, since several aliases resolve to one value. **`-i, --image ` and `-t, --image-tag `** map to the body's `containerImage` (`ContainerImageFilter`); `--image-tag` without `--image` is refused locally, since the API requires `name`. Container findings show the scanned image as `name:tag` on the card's header line, in place of the repository (and also in the single-repository view, where the repository is hidden), and JSON gains `imageName`/`imageTag` (9 new tests, 807 total) | | 2026-11-03 | **Node.js engines raised for CVE-2026-111497** (Brotli decoder OOB write, bundled in Node). `engines` `>=20` → `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`, the first patched release of each line; Node 20 is EOL and unpatched, so it is dropped. CI matrix `[20, 22]` → `[22, 24]`. No dependency change: the repo ships no Brotli package, but `fetch` (undici) decompresses `Content-Encoding: br` responses with Node's bundled decoder, so the runtime is the exposure. `engines` only warns on install; no runtime version check was added | | 2026-09-30 | (OD-590, OD-784) `fixedVersion` surfaced on `issue`/`issues`/`pull-request --issue`, now that `CommitIssue` carries it (first in API `57.7.9`; counterpart of `codacy-spa` #3145). **API bump: pinned `57.6.4` → `57.7.10`** — a diff of both bundled specs shows only `CommitIssue.fixedVersion` added, the organization webhook operations retagged `organization` → `webhooks` (the CLI uses none of them) and a `hasWebhooksEnabled` field on an organization schema; no operation signatures shifted, so `SPECS/repository-tokens.md`'s whitelist needed no changes. `57.7.9` and `57.7.10` bundle identical specs. `printIssueCard` (list) and `printIssueCodeContext` (detail, shared with `pull-request --issue` and `finding`'s linked-issue block) now pass `issue.fixedVersion` to the existing `formatDependencyChainsLine`/`formatDependencyChainsBlock`, so a direct dependency reads `Direct - Update to ` and a transitive one ends `(Fixed in )`, as on `finding`/`findings`. The param `finding` passes (`SrmItem.fixedVersion`) still wins when non-empty. An empty array (no fix available) renders as before — no version text — rather than adding a "no fix" phrase the other commands don't use. `fixedVersion` added to all three JSON projections (issue.test.ts 24→28, issues.test.ts 76→78, pull-request.test.ts +2) | | 2026-09-23 | (OD-748) `images` shows a **Tags** column (per-image `tagCount`) and an `Image tags: X of Y used` line under the header, read from `listOrganizationImages`' new `usage` object (OD-724, API 57.6.4 — `fetch-api` bumped from 57.4.17). Still one request per page. The line turns red at the cap, where new tags are rejected. Exact figures, not `formatCount`. `--output json` stays an array and gains `tagCount` only; `usage` is not in the JSON, since adding it would change the top-level shape The `image --delete --keep-latest` budget warning now reads that same `usage.limit` from the image-count request it already made, in place of the hardcoded `DEFAULT_ORG_TAG_CAP` (1,000), and drops its "this CLI cannot read the value in force" disclaimer Both commands tolerate a response without `usage`/`tagCount` (an API behind the client), and the exact-figure formatter is now shared as `formatExactCount` (5 new tests, 784 total) | diff --git a/SPECS/commands/findings.md b/SPECS/commands/findings.md index 82aeff6..f4b1ebc 100644 --- a/SPECS/commands/findings.md +++ b/SPECS/commands/findings.md @@ -44,11 +44,11 @@ Card-style format: ``` ──────────────────────────────────────── -{Priority colored} | {SecurityCategory} {ScanType} | {Optional: Likelihood} {Optional: EffortToFix} | {Optional: Repository} {id dimmed} +{Priority colored} | {SecurityCategory} {ScanType} | {Optional: Likelihood} {Optional: EffortToFix} | {Optional: Image or Repository} {id dimmed} {Finding title} {Optional: affectedTargets} -{Status} {DueAt} | {Optional: CVE or CWE} | {Optional: AffectedVersion → FixedVersion} | {Optional: Application} | {Optional: imageName[:imageTag]} +{Status} {DueAt} | {Optional: CVE or CWE} | {Optional: AffectedVersion → FixedVersion} | {Optional: Application} {Optional: Dependency import chain (SCA findings with dependencyChains)} {Optional: Vulnerable functions: fn1, fn2 (+N more) — from item.advisoryInformation} @@ -79,8 +79,8 @@ When an item carries `advisoryInformation` (`{advisoryId, vulnerableFunctions, p ### Container image (container scanning) -When an item carries `imageName` (container scanning findings), the status line ends with the scanned image as `name:tag` (just `name` when `imageTag` is absent), dimmed and sanitized. `--output json` includes `imageName` and `imageTag` per item. +When an item carries `imageName` (container scanning findings), the header line shows the scanned image as `name:tag` (just `name` when `imageTag` is absent), dimmed and sanitized, **in place of the repository**: the finding is about an image, not a repository. Unlike the repository, the image is shown in the single-repository view too, since the command's arguments don't imply it. The switch is on `imageName` being present, not on `scanType === "ContainerSCA"`. `--output json` includes `imageName` and `imageTag` per item. ## Tests -File: `src/commands/findings.test.ts` — 35 tests. +File: `src/commands/findings.test.ts` — 37 tests. diff --git a/src/commands/findings.test.ts b/src/commands/findings.test.ts index 7f331ae..20b41aa 100644 --- a/src/commands/findings.test.ts +++ b/src/commands/findings.test.ts @@ -860,14 +860,34 @@ describe("findings command", () => { mockExit.mockRestore(); }); - it("should show the scanned image as name:tag on the card", async () => { + // The header line is the only card line that carries the finding id. + function headerLine(): string { + const calls = vi.mocked(console.log).mock.calls.map((c) => String(c[0] ?? "")); + return calls.find((l) => l.includes("finding-container")) ?? ""; + } + + it("should show image:tag instead of the repository on the header line", async () => { vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ data: [containerFinding], } as any); await runFindings("findings", "gh", "test-org"); - expect(getAllOutput()).toContain("codacy/codacy-website:1.25.3"); + const header = headerLine(); + expect(header).toContain("codacy/codacy-website:1.25.3"); + expect(header).not.toContain("my-repo"); + // Shown once, not repeated on the status line. + expect(getAllOutput().split("codacy/codacy-website").length - 1).toBe(1); + }); + + it("should show the image in the single-repository view too", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [containerFinding], + } as any); + + await runFindings("findings", "gh", "test-org", "test-repo"); + + expect(headerLine()).toContain("codacy/codacy-website:1.25.3"); }); it("should show the image name alone when the finding has no tag", async () => { @@ -877,9 +897,23 @@ describe("findings command", () => { await runFindings("findings", "gh", "test-org"); - const output = getAllOutput(); - expect(output).toContain("codacy/codacy-website"); - expect(output).not.toContain("codacy/codacy-website:"); + const header = headerLine(); + expect(header).toContain("codacy/codacy-website"); + expect(header).not.toContain("codacy/codacy-website:"); + }); + + it("should keep showing the repository for non-container findings", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [mockFindings[0]], + } as any); + + await runFindings("findings", "gh", "test-org"); + + const header = vi + .mocked(console.log) + .mock.calls.map((c) => String(c[0] ?? "")) + .find((l) => l.includes(mockFindings[0].id)); + expect(header).toContain("my-repo"); }); it("should include imageName and imageTag in JSON output", async () => { diff --git a/src/commands/findings.ts b/src/commands/findings.ts index ec322e0..eee02dd 100644 --- a/src/commands/findings.ts +++ b/src/commands/findings.ts @@ -83,7 +83,7 @@ function normalizeScanType(input: string): string { ); } -// Line 1: Priority | SecurityCategory ScanType | Likelihood EffortToFix | Repository +// Line 1: Priority | SecurityCategory ScanType | Likelihood EffortToFix | Repository or Image function buildFindingHeaderLine(item: SrmItem, showRepo: boolean): string { const pipe = ` ${ansis.dim("|")} `; const parts: string[] = [colorPriority(item.priority)]; @@ -101,7 +101,18 @@ function buildFindingHeaderLine(item: SrmItem, showRepo: boolean): string { ) as string[]; if (penTestParts.length > 0) parts.push(penTestParts.join(" ")); - if (showRepo && item.repository) parts.push(ansis.dim(sanitizeText(item.repository))); + // A container scanning finding is about an image, not a repository, so its + // image:tag takes the repository's slot. It is shown even in the + // single-repository view (where the repository is hidden as redundant), + // since the image is not implied by the command's arguments. + if (item.imageName) { + const image = item.imageTag + ? `${item.imageName}:${item.imageTag}` + : item.imageName; + parts.push(ansis.dim(sanitizeText(image))); + } else if (showRepo && item.repository) { + parts.push(ansis.dim(sanitizeText(item.repository))); + } const idLabel = ansis.hex("#555555")(item.id); return parts.join(pipe) + ` ${idLabel}`; @@ -130,14 +141,6 @@ function buildFindingStatusLine(item: SrmItem, hasChains: boolean): string { if (item.application) parts.push(ansis.dim(sanitizeText(item.application))); - // Container scanning findings: the scanned image, as name:tag. - if (item.imageName) { - const image = item.imageTag - ? `${item.imageName}:${item.imageTag}` - : item.imageName; - parts.push(ansis.dim(sanitizeText(image))); - } - return parts.join(pipe); } From 6706f0216915fb3449bb006c017c4e926a7e55a5 Mon Sep 17 00:00:00 2001 From: Alejandro Rizzo Date: Mon, 9 Nov 2026 13:35:06 +0000 Subject: [PATCH 4/4] fix(findings): validate --image input and show the image on finding Addresses the /review-pr round on #66: - --image/--image-tag go through parseImageFilter: values are trimmed, and empty values, a tag without a name, digest references and two different tags are refused before any request. An empty --image was dropped by a truthiness check, widening the result to the whole org. - --image name:tag is split into name and tag (a registry port stays in the name), since the cards print the image in that form. - finding shows image:tag in place of the repository, through the shared formatImageReference, and its JSON gains imageName/imageTag. Refs OD-1049 Co-Authored-By: Claude Opus 5.5 --- .changeset/findings-container-sca.md | 2 +- SPECS/README.md | 2 +- SPECS/commands/finding.md | 8 ++- SPECS/commands/findings.md | 17 +++-- src/commands/AGENTS.md | 4 ++ src/commands/finding.test.ts | 42 ++++++++++++ src/commands/finding.ts | 11 +++- src/commands/findings.test.ts | 97 +++++++++++++++++++++++++++- src/commands/findings.ts | 82 ++++++++++++++++++----- src/utils/formatting.test.ts | 17 +++++ src/utils/formatting.ts | 14 ++++ 11 files changed, 269 insertions(+), 27 deletions(-) diff --git a/.changeset/findings-container-sca.md b/.changeset/findings-container-sca.md index 1e43cc9..0a00482 100644 --- a/.changeset/findings-container-sca.md +++ b/.changeset/findings-container-sca.md @@ -2,4 +2,4 @@ "@codacy/codacy-cloud-cli": minor --- -`findings` can now narrow to container scanning: `--scan-types` accepts `ContainerSCA` (aliases `ContainerScanning` and `Container`), and the new `--image ` and `--image-tag ` options filter findings to one container image. Container findings show their image as `name:tag` in place of the repository on each card and include `imageName`/`imageTag` in `--output json`. +`findings` can now narrow to container scanning: `--scan-types` accepts `ContainerSCA` (aliases `ContainerScanning` and `Container`), and the new `--image ` (or `name:tag`) and `--image-tag ` options filter findings to one container image. Container findings show their image as `name:tag` in place of the repository, on both `findings` cards and the `finding` detail view, and include `imageName`/`imageTag` in `--output json`. diff --git a/SPECS/README.md b/SPECS/README.md index 36f8871..0cdfacc 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -48,7 +48,7 @@ This is the single source of truth for all project tasks and specs. | Date | What was done | |---|---| -| 2026-11-09 | (OD-1049) `findings` can narrow to container scanning. **`--scan-types` accepts `ContainerSCA`**, the API value (as the `SearchSRMItems.scanTypes` example in the spec lists it), not `ContainerScanning` as the ticket named it; `ContainerScanning` and `Container` are accepted as aliases, normalized like the other scan types. The normalized list is now de-duplicated, since several aliases resolve to one value. **`-i, --image ` and `-t, --image-tag `** map to the body's `containerImage` (`ContainerImageFilter`); `--image-tag` without `--image` is refused locally, since the API requires `name`. Container findings show the scanned image as `name:tag` on the card's header line, in place of the repository (and also in the single-repository view, where the repository is hidden), and JSON gains `imageName`/`imageTag` (9 new tests, 807 total) | +| 2026-11-09 | (OD-1049) `findings` can narrow to container scanning. **`--scan-types` accepts `ContainerSCA`**, the API value (as the `SearchSRMItems.scanTypes` example in the spec lists it), not `ContainerScanning` as the ticket named it; `ContainerScanning` and `Container` are accepted as aliases, normalized like the other scan types. The normalized list is now de-duplicated, since several aliases resolve to one value. **`-i, --image ` and `-t, --image-tag `** map to the body's `containerImage` (`ContainerImageFilter`), through `parseImageFilter`: values are trimmed, `--image name:tag` is split (a registry port stays in the name), and empty values, a tag without a name, a digest reference or two different tags are refused before any request. An empty `--image` would otherwise be dropped by a truthiness check and silently widen the result to the whole org. **Container findings show `image:tag` in place of the repository** on the `findings` card (also in the single-repository view) and on the `finding` header, through the shared `formatImageReference`; both commands' JSON gains `imageName`/`imageTag` (25 new tests, 823 total) | | 2026-11-03 | **Node.js engines raised for CVE-2026-111497** (Brotli decoder OOB write, bundled in Node). `engines` `>=20` → `^22.23.4 \|\| ^24.22.1 \|\| >=26.12.1`, the first patched release of each line; Node 20 is EOL and unpatched, so it is dropped. CI matrix `[20, 22]` → `[22, 24]`. No dependency change: the repo ships no Brotli package, but `fetch` (undici) decompresses `Content-Encoding: br` responses with Node's bundled decoder, so the runtime is the exposure. `engines` only warns on install; no runtime version check was added | | 2026-09-30 | (OD-590, OD-784) `fixedVersion` surfaced on `issue`/`issues`/`pull-request --issue`, now that `CommitIssue` carries it (first in API `57.7.9`; counterpart of `codacy-spa` #3145). **API bump: pinned `57.6.4` → `57.7.10`** — a diff of both bundled specs shows only `CommitIssue.fixedVersion` added, the organization webhook operations retagged `organization` → `webhooks` (the CLI uses none of them) and a `hasWebhooksEnabled` field on an organization schema; no operation signatures shifted, so `SPECS/repository-tokens.md`'s whitelist needed no changes. `57.7.9` and `57.7.10` bundle identical specs. `printIssueCard` (list) and `printIssueCodeContext` (detail, shared with `pull-request --issue` and `finding`'s linked-issue block) now pass `issue.fixedVersion` to the existing `formatDependencyChainsLine`/`formatDependencyChainsBlock`, so a direct dependency reads `Direct - Update to ` and a transitive one ends `(Fixed in )`, as on `finding`/`findings`. The param `finding` passes (`SrmItem.fixedVersion`) still wins when non-empty. An empty array (no fix available) renders as before — no version text — rather than adding a "no fix" phrase the other commands don't use. `fixedVersion` added to all three JSON projections (issue.test.ts 24→28, issues.test.ts 76→78, pull-request.test.ts +2) | | 2026-09-23 | (OD-748) `images` shows a **Tags** column (per-image `tagCount`) and an `Image tags: X of Y used` line under the header, read from `listOrganizationImages`' new `usage` object (OD-724, API 57.6.4 — `fetch-api` bumped from 57.4.17). Still one request per page. The line turns red at the cap, where new tags are rejected. Exact figures, not `formatCount`. `--output json` stays an array and gains `tagCount` only; `usage` is not in the JSON, since adding it would change the top-level shape The `image --delete --keep-latest` budget warning now reads that same `usage.limit` from the image-count request it already made, in place of the hardcoded `DEFAULT_ORG_TAG_CAP` (1,000), and drops its "this CLI cannot read the value in force" disclaimer Both commands tolerate a response without `usage`/`tagCount` (an API behind the client), and the exact-figure formatter is now shared as `formatExactCount` (5 new tests, 784 total) | diff --git a/SPECS/commands/finding.md b/SPECS/commands/finding.md index 3feba9f..c8c13e6 100644 --- a/SPECS/commands/finding.md +++ b/SPECS/commands/finding.md @@ -41,7 +41,7 @@ The `findingId` is the UUID shown in dim gray at the end of each findings card. ## Output Format ``` -{Priority colored} | {SecurityCategory} {ScanType} | {Optional: Likelihood} {EffortToFix} | {Optional: Repository} {id dimmed} +{Priority colored} | {SecurityCategory} {ScanType} | {Optional: Likelihood} {EffortToFix} | {Optional: Image or Repository} {id dimmed} {Finding title} {Status} {DueAt} | {Optional: CVE/CWE} | {Optional: AffectedVersion → FixedVersion} | {Optional: Application} | {Optional: AffectedTargets} @@ -77,6 +77,10 @@ For Codacy-source findings, the CVE block is injected between the code context a When `item.advisoryInformation` is present, shows the shared `printAdvisoryBlock` (advisory ID header, optional published date, one bullet per vulnerable function) via `utils/formatting.ts` — the same renderer used by `issue`/`pull-request --issue`. Shown here only when there is **no** linked Codacy issue; when there is one, `printIssueCodeContext` already renders the equivalent block from `issue.advisoryInformation`, so this avoids a duplicate. This is what makes vulnerable functions visible for SCA/dependency findings and any other non-Codacy-source finding, which have no linked issue to borrow the block from. Included in `--output json` as `finding.advisoryInformation.{advisoryId,vulnerableFunctions,publishedAt}`. +### Container image (container scanning) + +When `item.imageName` is present, the header line shows the scanned image as `name:tag` (just `name` without `imageTag`) **in place of the repository**, matching the `findings` card the id was picked from. Built by the shared `formatImageReference` (`utils/formatting.ts`) and sanitized. Included in `--output json` as `finding.imageName` / `finding.imageTag`. + ## Dependency import chains (SCA) When a finding carries `dependencyChains` (`string[][]`), **all** chains are listed @@ -97,4 +101,4 @@ carries the fixed version — `CommitIssue` itself has no `fixedVersion` field t ## Tests -File: `src/commands/finding.test.ts` — 29 tests (27 + 2 for the linked-issue dependency-chain dedup). +File: `src/commands/finding.test.ts` — 31 tests (27 + 2 for the linked-issue dependency-chain dedup + 2 for the container image). diff --git a/SPECS/commands/findings.md b/SPECS/commands/findings.md index f4b1ebc..d893608 100644 --- a/SPECS/commands/findings.md +++ b/SPECS/commands/findings.md @@ -30,12 +30,21 @@ codacy find gh my-org my-repo --output json | `--categories ` | `-c` | Comma-separated security category names | | `--scan-types ` | `-T` | Comma-separated scan types: SAST, Secrets, SCA, ContainerSCA (aliases: ContainerScanning, Container), CICD, IaC, DAST, PenTesting, License, CSPM. Case-insensitive; spaces, hyphens and underscores are ignored | | `--dast-targets ` | `-d` | Comma-separated DAST target URLs | -| `--image ` | `-i` | Container image name (sent as `containerImage.name`) | -| `--image-tag ` | `-t` | Container image tag (sent as `containerImage.tag`). Requires `--image`: the API rejects a tag without a name, so the CLI refuses it locally before any request or git auto-detection | +| `--image ` | `-i` | Container image name, or `name:tag` (sent as `containerImage.name` / `.tag`) | +| `--image-tag ` | `-t` | Container image tag (sent as `containerImage.tag`). Requires `--image`: the API rejects a tag without a name | Default status filter: `Overdue,OnTrack,DueSoon`. -`--image` / `--image-tag` map to the request body's single `ContainerImageFilter` (one image per request, not a list — hence singular names). `--image-tag` rather than `--tag` so it reads as narrowing the image, not as a finding tag. `--image` does not add `ContainerSCA` to `--scan-types` implicitly; the API applies the filter itself. +`--image` / `--image-tag` are validated by `parseImageFilter`, before git auto-detection or any request, and refused rather than sent when the API would mishandle them silently: + +- **Empty or whitespace-only values.** A truthiness check would drop `--image ""`, widening the result to every finding in the org (a script's `--image "$IMG"` with `IMG` unset). Both values are trimmed. +- **`--image-tag` without an image name.** +- **A digest reference** (`name@sha256:…`): the API filters by tag only. +- **Two different tags**, one in `--image name:tag` and one in `--image-tag`. The same tag given both ways is accepted. + +`--image name:tag` is split into name and tag, because the cards print the image in that form and copying it back is the natural next step. Only a colon after the last `/` starts a tag, so a registry port (`registry:5000/app`) stays part of the name. + +They map to the request body's single `ContainerImageFilter` (one image per request, not a list — hence singular names). `--image-tag` rather than `--tag` so it reads as narrowing the image, not as a finding tag. `--image` does not add `ContainerSCA` to `--scan-types` implicitly; the API applies the filter itself. ## Output @@ -83,4 +92,4 @@ When an item carries `imageName` (container scanning findings), the header line ## Tests -File: `src/commands/findings.test.ts` — 37 tests. +File: `src/commands/findings.test.ts` — 48 tests (incl. 9 unit tests for `parseImageFilter`). diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index 4efacf8..81d23be 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -287,6 +287,10 @@ Dependency-chain helpers shared between `findings.ts` (list) and `finding.ts` (d - `formatDependencyChainsLine(chains, fixedVersion)` — one-line list summary: first chain with its Direct/Transitive label + `... and N more`; returns `null` for no chains - `formatDependencyChainsBlock(chains, fixedVersion)` — multi-line detail block: all chains, label shown once, continuation lines aligned under the label; returns `null` for no chains +Container-image helper shared between `findings.ts` (list) and `finding.ts` (detail): +- `formatImageReference(imageName, imageTag)` — `name:tag` (or `name`) for a container scanning finding, `undefined` without an image. Both commands show it **in place of the repository** on the header line (`findings` also in its single-repository view, where the repository is hidden). Returns the raw value; callers sanitize before styling +- `parseImageFilter(image, imageTag)` (exported from `findings.ts`, not shared) builds the `containerImage` request filter and **refuses** input the API would mishandle silently — empty values (a truthiness check would drop `--image ""` and widen to the whole org), a tag without a name, a digest, conflicting tags — and splits `--image name:tag`, treating only a colon after the last `/` as a tag separator so a registry port stays in the name + Advisory-information helpers shared between `issue.ts`/`pull-request.ts` (via `CommitIssue.advisoryInformation`) and `finding.ts`/`findings.ts` (via `SrmItem.advisoryInformation`) — same `AdvisoryInformation` shape from either source: - `summarizeFunctions(fns, limit = 3)` — compact `fn1, fn2 (+N more)` string for card views (`issue`/`pull-request` cards, `findings` list) - `printAdvisoryBlock(advisory)` — full detail block: bold `Vulnerable Functions ()` header, optional `Published:` date, one bullet per function; used by `printIssueCodeContext` (issue detail views) and directly by `finding.ts` when there's no linked Codacy issue to render it via that path diff --git a/src/commands/finding.test.ts b/src/commands/finding.test.ts index 395202f..45a6bec 100644 --- a/src/commands/finding.test.ts +++ b/src/commands/finding.test.ts @@ -283,6 +283,48 @@ describe("finding command", () => { expect(output).not.toContain("0.1.2 → 0.1.5"); }); + describe("container image", () => { + const containerFinding = { + ...mockScaFinding, + id: "img-123", + scanType: "ContainerSCA", + imageName: "codacy/codacy-website", + imageTag: "1.25.3", + }; + + it("should show image:tag instead of the repository on the header line", async () => { + vi.mocked(SecurityService.getSecurityItem).mockResolvedValue({ + data: containerFinding, + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "finding", "gh", "test-org", "img-123"]); + + // The header line is the only line that carries the finding id. + const header = vi + .mocked(console.log) + .mock.calls.map((c) => String(c[0] ?? "")) + .find((l) => l.includes("img-123")); + expect(header).toContain("codacy/codacy-website:1.25.3"); + expect(header).not.toContain("my-repo"); + }); + + it("should include imageName and imageTag in JSON output", async () => { + vi.mocked(SecurityService.getSecurityItem).mockResolvedValue({ + data: containerFinding, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", "finding", "gh", "test-org", "img-123", + ]); + + const json = JSON.parse(vi.mocked(console.log).mock.calls[0][0]); + expect(json.finding.imageName).toBe("codacy/codacy-website"); + expect(json.finding.imageTag).toBe("1.25.3"); + }); + }); + it("should include dependencyChains in JSON output", async () => { vi.mocked(SecurityService.getSecurityItem).mockResolvedValue({ data: mockScaWithChains, diff --git a/src/commands/finding.ts b/src/commands/finding.ts index 2e08277..058c7e0 100644 --- a/src/commands/finding.ts +++ b/src/commands/finding.ts @@ -10,6 +10,7 @@ import { formatDueDate, formatVersionSegment, formatDependencyChainsBlock, + formatImageReference, printCveBlock, printAdvisoryBlock, printIssueCodeContext, @@ -47,7 +48,7 @@ function printFindingDetail( console.log(); - // Line 1: Priority | SecurityCategory ScanType | Likelihood EffortToFix | Repository + // Line 1: Priority | SecurityCategory ScanType | Likelihood EffortToFix | Repository or Image const line1Parts: string[] = [colorPriority(item.priority)]; const catParts = [ @@ -63,7 +64,11 @@ function printFindingDetail( ) as string[]; if (penTestParts.length > 0) line1Parts.push(penTestParts.join(" ")); - if (item.repository) line1Parts.push(ansis.dim(sanitizeText(item.repository))); + // A container scanning finding shows its image:tag in the repository's + // slot, the same as on the `findings` card it was picked from. + const image = formatImageReference(item.imageName, item.imageTag); + if (image) line1Parts.push(ansis.dim(sanitizeText(image))); + else if (item.repository) line1Parts.push(ansis.dim(sanitizeText(item.repository))); const idLabel = ansis.hex("#555555")(item.id); console.log(line1Parts.join(pipe) + ` ${idLabel}`); @@ -276,6 +281,8 @@ Examples: "finding.fixedVersion", "finding.application", "finding.affectedTargets", + "finding.imageName", + "finding.imageTag", "finding.dependencyChains", "finding.ignored", "finding.summary", diff --git a/src/commands/findings.test.ts b/src/commands/findings.test.ts index 20b41aa..e5ce2d8 100644 --- a/src/commands/findings.test.ts +++ b/src/commands/findings.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Command } from "commander"; -import { registerFindingsCommand } from "./findings"; +import { registerFindingsCommand, parseImageFilter } from "./findings"; import { SecurityService } from "../api/client/services/SecurityService"; vi.mock("../api/client/services/SecurityService"); @@ -844,6 +844,43 @@ describe("findings command", () => { ); }); + it("should split --image name:tag into name and tag", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [], + } as any); + + await runFindings("findings", "gh", "test-org", "--image", " nginx:1.25.3 "); + + expect(SecurityService.searchSecurityItems).toHaveBeenCalledWith( + "gh", + "test-org", + undefined, + 100, + "Status", + "asc", + { + statuses: ["Overdue", "OnTrack", "DueSoon"], + containerImage: { name: "nginx", tag: "1.25.3" }, + }, + ); + }); + + it("should refuse an empty --image before any request", async () => { + const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); + + await expect( + runFindings("findings", "gh", "test-org", "--image", " "), + ).rejects.toThrow("process.exit called"); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("--image needs an image name"), + ); + expect(SecurityService.searchSecurityItems).not.toHaveBeenCalled(); + mockExit.mockRestore(); + }); + it("should refuse --image-tag without --image before any request", async () => { const mockExit = vi.spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit called"); @@ -929,6 +966,64 @@ describe("findings command", () => { }); }); + describe("parseImageFilter", () => { + it("returns undefined when neither option is given", () => { + expect(parseImageFilter(undefined, undefined)).toBeUndefined(); + }); + + it("trims the name and tag", () => { + expect(parseImageFilter(" nginx ", " 1.25.3 ")).toEqual({ + name: "nginx", + tag: "1.25.3", + }); + }); + + it("keeps a registry port as part of the name", () => { + expect(parseImageFilter("registry:5000/team/app", undefined)).toEqual({ + name: "registry:5000/team/app", + }); + expect(parseImageFilter("registry:5000/team/app:2.0", undefined)).toEqual({ + name: "registry:5000/team/app", + tag: "2.0", + }); + }); + + it("accepts the same tag given both ways", () => { + expect(parseImageFilter("nginx:1.25.3", "1.25.3")).toEqual({ + name: "nginx", + tag: "1.25.3", + }); + }); + + it("refuses two different tags", () => { + expect(() => parseImageFilter("nginx:1.25.3", "1.26.0")).toThrow( + "name different tags", + ); + }); + + it("refuses empty values", () => { + expect(() => parseImageFilter("", undefined)).toThrow("--image needs an image name"); + expect(() => parseImageFilter("nginx", " ")).toThrow("--image-tag needs a tag"); + }); + + it("refuses a tag without a name", () => { + expect(() => parseImageFilter(undefined, "1.25.3")).toThrow( + "--image-tag requires --image", + ); + }); + + it("refuses a dangling colon or a missing name", () => { + expect(() => parseImageFilter("nginx:", undefined)).toThrow("expects name or name:tag"); + expect(() => parseImageFilter(":1.25.3", undefined)).toThrow("expects name or name:tag"); + }); + + it("refuses a digest reference", () => { + expect(() => parseImageFilter("nginx@sha256:abc", undefined)).toThrow( + "not a digest reference", + ); + }); + }); + describe("advisory information (vulnerable functions)", () => { const mockAdvisoryInformation = { advisoryId: "CVE-2026-27903", diff --git a/src/commands/findings.ts b/src/commands/findings.ts index eee02dd..1a1d4be 100644 --- a/src/commands/findings.ts +++ b/src/commands/findings.ts @@ -17,12 +17,14 @@ import { formatDueDate, formatVersionSegment, formatDependencyChainsLine, + formatImageReference, summarizeFunctions, } from "../utils/formatting"; import { sanitizeText } from "../utils/sanitize"; import { SecurityService } from "../api/client/services/SecurityService"; import { SrmItem } from "../api/client/models/SrmItem"; import { SearchSRMItems } from "../api/client/models/SearchSRMItems"; +import { ContainerImageFilter } from "../api/client/models/ContainerImageFilter"; const PRIORITY_ORDER: Record = { Critical: 0, @@ -105,10 +107,8 @@ function buildFindingHeaderLine(item: SrmItem, showRepo: boolean): string { // image:tag takes the repository's slot. It is shown even in the // single-repository view (where the repository is hidden as redundant), // since the image is not implied by the command's arguments. - if (item.imageName) { - const image = item.imageTag - ? `${item.imageName}:${item.imageTag}` - : item.imageName; + const image = formatImageReference(item.imageName, item.imageTag); + if (image) { parts.push(ansis.dim(sanitizeText(image))); } else if (showRepo && item.repository) { parts.push(ansis.dim(sanitizeText(item.repository))); @@ -193,6 +193,62 @@ function printFindingsList( } } +/** + * Build the request's `containerImage` filter from `--image` / `--image-tag`, + * or undefined when neither was given. Throws (before any request) on input + * the API would mishandle silently: + * + * - An empty or whitespace-only value. A truthiness check would drop it, so + * `--image "$IMG"` with `IMG` unset would widen to every finding in the org. + * - `--image-tag` without an image name: `ContainerImageFilter` requires `name`. + * - A digest reference (`name@sha256:…`): the API filters by tag only. + * - Two different tags, one in `--image name:tag` and one in `--image-tag`. + * + * `--image name:tag` is split, because the cards print the image in exactly + * that form and copying it back is the natural next step; sent whole it would + * match no image name. Only a colon after the last `/` starts a tag, so a + * registry port (`registry:5000/app`) stays part of the name. + */ +export function parseImageFilter( + image: string | undefined, + imageTag: string | undefined, +): ContainerImageFilter | undefined { + if (image === undefined && imageTag === undefined) return undefined; + + let name = image?.trim(); + let tag = imageTag?.trim(); + if (image !== undefined && !name) { + throw new Error("--image needs an image name, but the value is empty."); + } + if (imageTag !== undefined && !tag) { + throw new Error("--image-tag needs a tag, but the value is empty."); + } + if (!name) throw new Error("--image-tag requires --image ."); + + if (name.includes("@")) { + throw new Error( + `--image takes an image name or name:tag, not a digest reference (got "${name}").`, + ); + } + + const colon = name.lastIndexOf(":"); + if (colon > name.lastIndexOf("/")) { + const refTag = name.slice(colon + 1); + name = name.slice(0, colon); + if (!name || !refTag) { + throw new Error(`--image expects name or name:tag (got "${image!.trim()}").`); + } + if (tag && tag !== refTag) { + throw new Error( + `--image ${name}:${refTag} and --image-tag ${tag} name different tags; give the tag once.`, + ); + } + tag = refTag; + } + + return tag ? { name, tag } : { name }; +} + function parseCommaList(value: string | undefined): string[] | undefined { if (!value) return undefined; return value @@ -231,7 +287,7 @@ export function registerFindingsCommand(program: Command) { ) .option("-n, --limit ", "maximum number of findings to return (default: 100, max: 1000)", "100") .option("-d, --dast-targets ", "comma-separated DAST target URLs") - .option("-i, --image ", "container image name, e.g. nginx (container scanning findings)") + .option("-i, --image ", "container image name, or name:tag, e.g. nginx or nginx:1.25.3 (container scanning findings)") .option("-t, --image-tag ", "container image tag, e.g. 1.25.3 (requires --image)") .addOption(repositoryTokenOption()) .addHelpText( @@ -257,13 +313,11 @@ Examples: try { resolveAccountAuth(this, "Codacy does not accept repository tokens on the security findings endpoints"); - // The API rejects a tag without an image name (ContainerImageFilter - // requires `name`). Refuse locally, before git auto-detection or any - // request. + // Validated here, before git auto-detection or any request, so a bad + // --image/--image-tag is refused rather than silently widening or + // emptying the result. See parseImageFilter. const opts = this.opts(); - if (opts.imageTag && !opts.image) { - throw new Error("--image-tag requires --image ."); - } + const containerImage = parseImageFilter(opts.image, opts.imageTag); const argCount = [providerArg, organizationArg, repositoryArg].filter( (v) => v !== undefined, @@ -320,11 +374,7 @@ Examples: body.scanTypes = [...new Set(scanTypes.map(normalizeScanType))]; const dastTargets = parseCommaList(opts.dastTargets); if (dastTargets) body.dastTargetUrls = dastTargets; - if (opts.image) { - body.containerImage = opts.imageTag - ? { name: opts.image, tag: opts.imageTag } - : { name: opts.image }; - } + if (containerImage) body.containerImage = containerImage; const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 100, 1), 1000); diff --git a/src/utils/formatting.test.ts b/src/utils/formatting.test.ts index e654cd1..d920806 100644 --- a/src/utils/formatting.test.ts +++ b/src/utils/formatting.test.ts @@ -23,6 +23,7 @@ import { coverageStatusNote, formatRepoCoverageCell, formatRepoCoverageDetail, + formatImageReference, } from "./formatting"; import { formatFriendlyDate } from "./output"; @@ -471,6 +472,22 @@ describe("isBeingAnalyzed", () => { }); }); +describe("formatImageReference", () => { + it("returns name:tag when both are present", () => { + expect(formatImageReference("nginx", "1.25.3")).toBe("nginx:1.25.3"); + }); + + it("returns the name alone when there is no tag", () => { + expect(formatImageReference("nginx", undefined)).toBe("nginx"); + expect(formatImageReference("nginx", "")).toBe("nginx"); + }); + + it("returns undefined when there is no image name", () => { + expect(formatImageReference(undefined, "1.25.3")).toBeUndefined(); + expect(formatImageReference("", undefined)).toBeUndefined(); + }); +}); + describe("formatVersionSegment", () => { it("returns null when there is no affected version", () => { expect(formatVersionSegment(undefined, ["1.0.1"])).toBeNull(); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index efab1b8..f8533a9 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -130,6 +130,20 @@ function dependencyChainBody(chain: string[], fixedVersion?: string[]): string { return `${formatDependencyChain(chain)}${suffix}`; } +/** + * The container image a finding was scanned from, as `name:tag` (just `name` + * when there is no tag), or undefined for a finding with no image. Shared by + * `findings` and `finding`, which show it in place of the repository. Returns + * the raw value: callers sanitize it before styling (CWE-150). + */ +export function formatImageReference( + imageName: string | undefined, + imageTag: string | undefined, +): string | undefined { + if (!imageName) return undefined; + return imageTag ? `${imageName}:${imageTag}` : imageName; +} + /** * Format the "affected → fixed" version segment shown on a finding's status line * for SCA findings that have no dependency chains. Returns null when there is no