diff --git a/.changeset/findings-container-sca.md b/.changeset/findings-container-sca.md new file mode 100644 index 0000000..0a00482 --- /dev/null +++ b/.changeset/findings-container-sca.md @@ -0,0 +1,5 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +`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 34951f1..0cdfacc 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` 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 f19809c..d893608 100644 --- a/SPECS/commands/findings.md +++ b/SPECS/commands/findings.md @@ -28,11 +28,24 @@ 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 | +| `--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` 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 Card-style format: @@ -40,7 +53,7 @@ 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} @@ -73,6 +86,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 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` — 27 tests (24 + 3 for advisoryInformation). +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 95b8b55..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"); @@ -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 @@ -747,6 +777,253 @@ 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 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"); + }); + + 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(); + }); + + // 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"); + + 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 () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [{ ...containerFinding, imageTag: undefined }], + } as any); + + await runFindings("findings", "gh", "test-org"); + + 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 () => { + 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("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 a8504b4..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, @@ -55,6 +57,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", @@ -77,7 +85,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)]; @@ -95,7 +103,16 @@ 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. + 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))); + } const idLabel = ansis.hex("#555555")(item.id); return parts.join(pipe) + ` ${idLabel}`; @@ -176,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 @@ -210,10 +283,12 @@ 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 (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, 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( "after", @@ -224,6 +299,8 @@ 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 --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`, ) @@ -236,6 +313,12 @@ Examples: try { resolveAccountAuth(this, "Codacy does not accept repository tokens on the security findings endpoints"); + // 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(); + const containerImage = parseImageFilter(opts.image, opts.imageTag); + const argCount = [providerArg, organizationArg, repositoryArg].filter( (v) => v !== undefined, ).length; @@ -271,7 +354,6 @@ Examples: ); } - const opts = this.opts(); const format = getOutputFormat(this); const body: SearchSRMItems = {}; @@ -286,9 +368,13 @@ 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; + if (containerImage) body.containerImage = containerImage; const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 100, 1), 1000); @@ -342,6 +428,8 @@ Examples: "fixedVersion", "application", "affectedTargets", + "imageName", + "imageTag", "dependencyChains", "advisoryInformation.advisoryId", "advisoryInformation.vulnerableFunctions", @@ -357,7 +445,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) { 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