From b345ae79acfa8e5f291bb41c05cb5531048f21d6 Mon Sep 17 00:00:00 2001 From: Pedro Pereira Date: Tue, 28 Jul 2026 17:00:16 +0100 Subject: [PATCH 1/3] feat: Add vulnerable functions to CLI issue/finding OD-296 Surfaces advisoryInformation (advisory ID, vulnerable functions, published date) across issue, issues, pull-request --issue, finding, and findings: compact one-liners on list/card views, full blocks on detail views. finding skips its own block when a linked Codacy issue already renders the same data via printIssueCodeContext, so SCA/dependency findings (which have no linked issue) are the case this closes out, now that SrmItem carries advisoryInformation directly (server-side, API 57.3.9). --- .changeset/vulnerable-functions-issues.md | 5 + SPECS/README.md | 12 ++- SPECS/commands/finding.md | 11 ++- SPECS/commands/findings.md | 9 +- SPECS/commands/issue.md | 21 ++++- SPECS/commands/issues.md | 12 ++- SPECS/commands/pull-request.md | 9 +- package.json | 2 +- src/commands/AGENTS.md | 5 + src/commands/finding.test.ts | 54 +++++++++++ src/commands/finding.ts | 12 +++ src/commands/findings.test.ts | 54 +++++++++++ src/commands/findings.ts | 11 +++ src/commands/issue.test.ts | 53 +++++++++++ src/commands/issue.ts | 3 + src/commands/issues.test.ts | 70 ++++++++++++++ src/commands/issues.ts | 3 + src/commands/pull-request.test.ts | 108 ++++++++++++++++++++++ src/commands/pull-request.ts | 3 + src/utils/formatting.ts | 39 ++++++++ 20 files changed, 480 insertions(+), 16 deletions(-) create mode 100644 .changeset/vulnerable-functions-issues.md diff --git a/.changeset/vulnerable-functions-issues.md b/.changeset/vulnerable-functions-issues.md new file mode 100644 index 0000000..decffb8 --- /dev/null +++ b/.changeset/vulnerable-functions-issues.md @@ -0,0 +1,5 @@ +--- +"@codacy/codacy-cloud-cli": minor +--- + +`issue`, `issues`, `pull-request --issue`, `finding`, and `findings` now show vulnerable/affected functions for SCA issues and findings with a linked OSV advisory (`CommitIssue.advisoryInformation` / `SrmItem.advisoryInformation`). Card views show a compact one-line summary; detail views show the full list with advisory ID and published date. Included in `--output json` for all five commands. diff --git a/SPECS/README.md b/SPECS/README.md index 8ba0195..4f86560 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -17,11 +17,11 @@ _No pending tasks._ All commands implemented. | `repository` | `repo` | ✅ Done (actions added) | [repository.md](commands/repository.md) | | `ls` | N/A | ✅ Done | [ls.md](commands/ls.md) | | `directories` | `dirs` | ✅ Done | [directories.md](commands/directories.md) | -| `pull-request` | `pr` | ✅ Done (--diff + Diff Coverage Summary added) | [pull-request.md](commands/pull-request.md) | -| `issues` | `is` | ✅ Done | [issues.md](commands/issues.md) | -| `issue` | `iss` | ✅ Done | [issue.md](commands/issue.md) | -| `findings` | `fins` | ✅ Done | [findings.md](commands/findings.md) | -| `finding` | `fin` | ✅ Done (CVE enrichment included) | [finding.md](commands/finding.md) | +| `pull-request` | `pr` | ✅ Done (--diff + Diff Coverage Summary + vulnerable functions added) | [pull-request.md](commands/pull-request.md) | +| `issues` | `is` | ✅ Done (vulnerable functions added) | [issues.md](commands/issues.md) | +| `issue` | `iss` | ✅ Done (vulnerable functions added) | [issue.md](commands/issue.md) | +| `findings` | `fins` | ✅ Done (advisoryInformation added) | [findings.md](commands/findings.md) | +| `finding` | `fin` | ✅ Done (CVE enrichment + advisoryInformation included) | [finding.md](commands/finding.md) | | `tools` | `tls` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) | | `tool` | `tl` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) | | `patterns` | `pats` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) | @@ -78,3 +78,5 @@ _No pending tasks._ All commands implemented. | 2026-07-08 | `issues --overview` noise suggestions tuned to stop firing on low-volume repos: added a `NOISE_MIN_TOTAL` (200) floor on the repo's total issues that suppresses the whole "reduce noise" section below it, and a `NOISE_MIN_PATTERN` (100) absolute floor on each pattern's own count (AND-gated with the relative rules) so a long tail of tiny patterns can't drag the median down and make a ~9-issue pattern look noisy — the total floor is kept above the per-pattern floor so it isn't dead code; the ≥10% share rule now only applies with ≥11 distinct patterns (`NOISE_MIN_PATTERNS_FOR_SHARE` — an even split only drops below 10% once N > 10, so 8–10 balanced patterns would otherwise all be flagged); and the ≥3× multiple rule (`NOISE_MEDIAN_MULTIPLE`) now measures against the **median** (via new `medianOf()`) instead of the mean, so a single huge pattern can no longer inflate the baseline and mask smaller disproportionate patterns (5 new tests, 465 total) | | 2026-07-17 | `issues --ignore` now confirms before bulk-ignoring: `executeBulkIgnore` prints the match count then prompts via shared `confirmAction` (`utils/prompt.ts`), proceeding only on `y`. New `--skip-confirmation` (`-y`) bypasses the prompt for CI/scripts (same short flag as `tools --import --skip-approval`); non-TTY without the flag aborts rather than ignoring by accident. Confirmation runs after the fetch (count is shown) but before any `bulkIgnoreIssues` call (3 new tests, 478 total) | | 2026-07-24 | Security (CWE-150, HackerOne): neutralize terminal control characters in human-readable output. New `src/utils/sanitize.ts` (`sanitizeText`) strips C0 (0x00–0x1F except TAB/LF), DEL (0x7F) and C1 (0x80–0x9F) — CR included — replacing each with visible caret/`\xNN` notation so a crafted PR can't inject ANSI/OSC sequences to repaint or hide findings, spoof gate status, or drive terminal side effects (OSC 52 clipboard, OSC 8 hyperlinks). Applied *before* the CLI's own `ansis` styling (can't sanitize at the console boundary — that would strip the CLI's legitimate colours, and allow-listing SGR would still pass attacker SGR through). Covers every render path: shared helpers in `utils/formatting.ts` (issue cards/detail, code context, CVE block, dependency chains, version segments) plus `pull-request` (About table, Files, diff-coverage, annotated diff line/hunk/path), `findings`/`finding`, `issues` (overview tables, noise suggestions), `issue`, `repository` (About, PRs, overview), `ls`/`directories`. JSON output left intact (JSON encoding already escapes control bytes). New `src/utils/sanitize.test.ts` + pull-request table/diff regression tests (12 new tests, 490 total) | +| 2026-07-24 | (OD-296) Vulnerable/affected functions for SCA issues: `CommitIssue.advisoryInformation` (added server-side in API `57.3.0`, bumped from pinned `56.2.9`) is now read and rendered. `issues`/`issue`/`pull-request` default cards show a compact "Vulnerable functions: fn1, fn2 (+N more)" line (`printIssueCard`); `issue`/`pull-request --issue` detail views show the full block with advisory ID + published date (`printAdvisoryBlock`, wired into `printIssueCodeContext`). Not shown for ignored issues (`IgnoredIssue` has no `advisoryInformation`). Gated purely on `advisoryInformation` presence. Added to all three commands' JSON `pickDeep` whitelists (10 new tests, 488 total). Originally scoped to the `findings` command, but `SrmItem` has no equivalent field anywhere server-side — redirected to `issues`, where the data already existed in the public API. | +| 2026-07-28 | (OD-296, findings side) `SrmItem` gained its own `advisoryInformation` field server-side (bumped pinned API `57.3.0` → `57.3.9`), closing the gap noted on 2026-07-24. `findings` (list) now shows the same compact "Vulnerable functions: fn1, fn2 (+N more)" line as `issues`, via the newly-exported `summarizeFunctions`. `finding` (detail) shows the full `printAdvisoryBlock` — but only when there's no linked Codacy issue, since `printIssueCodeContext` already renders the equivalent block from `issue.advisoryInformation` in that case; this is what makes vulnerable functions visible for SCA/dependency findings (and any other non-Codacy-source finding) that have no linked issue to borrow it from at all. Added to both commands' JSON `pickDeep` whitelists (6 new tests, 494 total) | diff --git a/SPECS/commands/finding.md b/SPECS/commands/finding.md index a2631b6..7fa114d 100644 --- a/SPECS/commands/finding.md +++ b/SPECS/commands/finding.md @@ -1,6 +1,6 @@ # `finding` Command Spec -**Status:** ✅ Done (2026-02-24); CVE enrichment ✅ Done (2026-02-24) +**Status:** ✅ Done (2026-02-24); CVE enrichment ✅ Done (2026-02-24); vulnerable functions (advisoryInformation) ✅ Done (2026-07-28) ## Purpose @@ -36,6 +36,7 @@ The `findingId` is the UUID shown in dim gray at the end of each findings card. - Then in parallel: `ToolsService.getPattern(toolUuid, patternId)` + `FileService.getFileContent(...)` - Failures at steps 2/3 are silently caught — the finding is still shown 3. When `item.cve` is present, fetch CVE data from `https://cveawg.mitre.org/api/cve/{CVE-ID}` in parallel with step 2 +4. `item.advisoryInformation` (vulnerable functions), when present, comes inline on the `getSecurityItem` response itself — no extra request ## Output Format @@ -55,6 +56,8 @@ The `findingId` is the UUID shown in dim gray at the end of each findings card. {Optional: Remediation:} {Optional: remediation} +{Optional: Vulnerable Functions block, from item.advisoryInformation — only when there is no linked Codacy issue} + {For Codacy-source: shared printIssueCodeContext output — file context + pattern docs} ``` @@ -70,6 +73,10 @@ When `item.cve` is present, fetch CVE data from `https://cveawg.mitre.org/api/cv For Codacy-source findings, the CVE block is injected between the code context and the pattern documentation. For non-Codacy-source findings, it follows the prose fields. +## Vulnerable functions (advisoryInformation) + +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}`. + ## Dependency import chains (SCA) When a finding carries `dependencyChains` (`string[][]`), **all** chains are listed @@ -83,4 +90,4 @@ to ` → ... N more ... → `). See `SPECS/commands/findings.md`. ## Tests -File: `src/commands/finding.test.ts` — 23 tests. +File: `src/commands/finding.test.ts` — 26 tests (23 + 3 for advisoryInformation). diff --git a/SPECS/commands/findings.md b/SPECS/commands/findings.md index 4b0c330..f19809c 100644 --- a/SPECS/commands/findings.md +++ b/SPECS/commands/findings.md @@ -1,6 +1,6 @@ # `findings` Command Spec -**Status:** ✅ Done (2026-02-20) +**Status:** ✅ Done (2026-02-20); vulnerable functions (advisoryInformation) ✅ Done (2026-07-28) ## Purpose @@ -46,6 +46,7 @@ Card-style format: {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} ──────────────────────────────────────── ``` @@ -68,6 +69,10 @@ is dropped from the status line (it would duplicate the chain line). - Chains with **4+ packages** collapse their middle: ` → ... N more ... → ` (N = length − 2). - Multiple chains append `... and X more` (X = chains − 1). +### Vulnerable functions (advisoryInformation) + +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. + ## Tests -File: `src/commands/findings.test.ts` — 24 tests. +File: `src/commands/findings.test.ts` — 27 tests (24 + 3 for advisoryInformation). diff --git a/SPECS/commands/issue.md b/SPECS/commands/issue.md index a0c1b47..aab44ee 100644 --- a/SPECS/commands/issue.md +++ b/SPECS/commands/issue.md @@ -1,6 +1,6 @@ # `issue` Command Spec -**Status:** ✅ Done (2026-02-23); ignore/unignore added 2026-03-02 +**Status:** ✅ Done (2026-02-23); ignore/unignore added 2026-03-02; vulnerable functions block added 2026-07-24 ## Purpose @@ -66,6 +66,23 @@ Detected by: {tool name} {pattern title} ({pattern id}) ``` +**Vulnerable Functions block** — shown between the false-positive warning and the pattern +docs whenever `issue.advisoryInformation` is present (SCA issues linked to an OSV advisory): + +``` +Vulnerable Functions ({advisoryId}) +Published: {publishedAt, formatted YYYY-MM-DD} + + • {vulnerableFunctions[0]} + • {vulnerableFunctions[1]} + ... +``` + +Rendered via `printAdvisoryBlock` in `utils/formatting.ts`, called from `printIssueCodeContext` +(so it's also shared with the `pull-request --issue` detail view). No conditional on issue +category — gated purely on `advisoryInformation` being present, mirroring how the CVE block +is gated on `cve` for `finding`. + ## Tests -File: `src/commands/issue.test.ts` — 8 tests. +File: `src/commands/issue.test.ts` — 20 tests (17 + 3 for the vulnerable functions block). diff --git a/SPECS/commands/issues.md b/SPECS/commands/issues.md index 01ec62e..6bcd0f8 100644 --- a/SPECS/commands/issues.md +++ b/SPECS/commands/issues.md @@ -1,6 +1,6 @@ # `issues` Command Spec -**Status:** ✅ Done (2026-02-19) +**Status:** ✅ Done (2026-02-19); vulnerable functions line added 2026-07-24 ## Purpose @@ -62,12 +62,20 @@ Card-style format, sorted by severity (Error > High > Warning > Info): {FilePath}:{LineNumber} {LineText} {Optional: Potential false positive warning} +{Optional: Vulnerable functions: fn1, fn2, fn3 (+N more)} ──────────────────────────────────────── ``` Severity colors: Error=red, High=orange, Warning=yellow, Info=blue. +The "Vulnerable functions" line is shown when `issue.advisoryInformation` is present (SCA +issues linked to an OSV advisory), listing up to 3 function names with a "(+N more)" suffix +for longer lists. Rendered via `printIssueCard` in `utils/formatting.ts`. `--output json` +includes the full `advisoryInformation` object (`advisoryId`, `vulnerableFunctions`, +`publishedAt`) — no truncation there. Not shown for ignored issues (`IgnoredIssue` has no +`advisoryInformation` field). + Shows pagination warning if more results exist. ### Overview mode (`--overview`) @@ -177,4 +185,4 @@ Cannot be combined with `--overview` or `--limit`. ## Tests -File: `src/commands/issues.test.ts` — 64 tests. +File: `src/commands/issues.test.ts` — 68 tests (64 + 4 for the vulnerable functions line). diff --git a/SPECS/commands/pull-request.md b/SPECS/commands/pull-request.md index 846559a..48a9dc0 100644 --- a/SPECS/commands/pull-request.md +++ b/SPECS/commands/pull-request.md @@ -1,6 +1,6 @@ # `pull-request` Command Spec -**Status:** ✅ Done (2026-02-18); `--issue` added 2026-02-23; `--diff` + Diff Coverage Summary added 2026-02-25; ignore/unignore options added 2026-03-02; analysis status + `--reanalyze` added 2026-03-05 +**Status:** ✅ Done (2026-02-18); `--issue` added 2026-02-23; `--diff` + Diff Coverage Summary added 2026-02-25; ignore/unignore options added 2026-03-02; analysis status + `--reanalyze` added 2026-03-05; vulnerable functions (default cards + `--issue` detail) added 2026-07-24 ## Purpose @@ -45,7 +45,12 @@ When `--issue ` is provided: 1. Fetch all PR issues (confirmed + potential) using `fetchAllPrIssues()` pagination helper 2. Find the issue by `resultDataId` 3. Fetch `getPattern` + `getFileContent` in parallel -4. Render using shared `printIssueDetail` from `utils/formatting.ts` +4. Render using shared `printIssueDetail` from `utils/formatting.ts` — includes the Vulnerable + Functions block (see `issue.md`) when `advisoryInformation` is present, since it's shared + via `printIssueCodeContext` + +Default-mode issue cards (and `--issue` cards) also show the compact "Vulnerable functions:" +line — see `issues.md` for the format, shared via `printIssueCard`. ## `--diff` mode diff --git a/package.json b/package.json index ba8d546..bd08dde 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepublishOnly": "npm run update-api && npm run build", "start": "npx ts-node src/index.ts", "start:dist": "node dist/index.js", - "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/56.2.9/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", + "fetch-api": "curl https://artifacts.codacy.com/api/codacy-api/57.3.9/apiv3-bundled.yaml -o ./api-v3/api-swagger.yaml --create-dirs", "generate-api": "rm -rf ./src/api/client && openapi --input ./api-v3/api-swagger.yaml --output ./src/api/client --useUnionTypes --indent 2 --client fetch", "update-api": "npm run fetch-api && npm run generate-api", "check-types": "tsc --noEmit" diff --git a/src/commands/AGENTS.md b/src/commands/AGENTS.md index 9379ade..7d05238 100644 --- a/src/commands/AGENTS.md +++ b/src/commands/AGENTS.md @@ -213,6 +213,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 +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 + Pattern helpers shared between `patterns.ts` (list) and `pattern.ts` (single info): - `printPatternCard(cp)` — the configured-pattern card (icons, enforced-by line, metadata, why/how, parameters) - `PATTERN_JSON_FIELDS` — `pickDeep` paths for the JSON projection of a `ConfiguredPattern` @@ -312,6 +316,7 @@ Keeps the two command handlers thin: they only supply the API-specific callbacks - `-m, --ignore-comment`: optional free-text comment - **`--unignore` mode** (`-U`): calls `SecurityService.unignoreSecurityItem`; skips rendering finding details - **Dependency import chains** (SCA findings): when `item.dependencyChains` (`string[][]`) is present, both `finding` (detail) and `findings` (list) render the vulnerable dependency's import path. A chain with a single package is a **direct** dependency (`Direct - Update to `); 2+ packages is **transitive** (`Transitive - (Fixed in )`). Chains with **4+ packages** collapse the middle to ` → ... N more ... → ` (N = length − 2). The list shows only the first chain + `... and X more`; the detail lists **all** chains with the Direct/Transitive label shown once and continuation lines indented so the `-` aligns. When chains are present, the redundant `AffectedVersion → FixedVersion` segment is dropped from the status line. Mixed direct/transitive chains (rare) take their label from the first chain. Rendering lives in `formatDependencyChainsLine` / `formatDependencyChainsBlock` (see Shared Formatting Utilities). +- **Vulnerable functions** (`item.advisoryInformation`, both `finding` and `findings`): `findings` (list) shows the compact `Vulnerable functions: fn1, fn2 (+N more)` line via `summarizeFunctions` (same helper `issue`/`pull-request`'s card view uses). `finding` (detail) shows the full `printAdvisoryBlock` — but **only when there is no linked Codacy issue**; when there is one, `printIssueCodeContext` already renders the equivalent block from `issue.advisoryInformation`, so `finding` skips its own to avoid 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 at all. ## pull-request command (`pull-request.ts`) diff --git a/src/commands/finding.test.ts b/src/commands/finding.test.ts index 886af9f..bc0effa 100644 --- a/src/commands/finding.test.ts +++ b/src/commands/finding.test.ts @@ -599,6 +599,60 @@ describe("finding command", () => { mockExit.mockRestore(); }); + describe("advisory information (vulnerable functions)", () => { + const mockAdvisoryInformation = { + advisoryId: "CVE-2021-23337", + vulnerableFunctions: ["lodash.merge", "lodash.mergeWith"], + publishedAt: "2021-02-15T00:00:00.000Z", + }; + + it("should show vulnerable functions block for a non-Codacy finding with advisoryInformation", async () => { + vi.mocked(SecurityService.getSecurityItem).mockResolvedValue({ + data: { ...mockScaFinding, advisoryInformation: mockAdvisoryInformation }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "finding", "gh", "test-org", "abc-123-sca", + ]); + + const output = getAllOutput(); + expect(output).toContain("Vulnerable Functions (CVE-2021-23337)"); + expect(output).toContain("lodash.merge"); + expect(output).toContain("lodash.mergeWith"); + }); + + it("should not show vulnerable functions block when advisoryInformation is absent", async () => { + vi.mocked(SecurityService.getSecurityItem).mockResolvedValue({ + data: mockScaFinding, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "finding", "gh", "test-org", "abc-123-sca", + ]); + + const output = getAllOutput(); + expect(output).not.toContain("Vulnerable Functions"); + }); + + it("should include advisoryInformation in JSON output", async () => { + vi.mocked(SecurityService.getSecurityItem).mockResolvedValue({ + data: { ...mockScaFinding, advisoryInformation: mockAdvisoryInformation }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", + "finding", "gh", "test-org", "abc-123-sca", + ]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"lodash.merge"'), + ); + }); + }); + describe("--ignore option", () => { beforeEach(() => { vi.mocked(SecurityService.getSecurityItem).mockResolvedValue({ diff --git a/src/commands/finding.ts b/src/commands/finding.ts index 6847ca1..8c3ca2a 100644 --- a/src/commands/finding.ts +++ b/src/commands/finding.ts @@ -11,6 +11,7 @@ import { formatVersionSegment, formatDependencyChainsBlock, printCveBlock, + printAdvisoryBlock, printIssueCodeContext, } from "../utils/formatting"; import { sanitizeText } from "../utils/sanitize"; @@ -121,6 +122,14 @@ function printFindingDetail( printCveBlock(cveData); } + // Advisory block (vulnerable functions): shown here only when there is no linked + // Codacy issue — when there IS one, printIssueCodeContext already renders the same + // enrichment from issue.advisoryInformation. This covers SCA/dependency findings + // (and any other non-Codacy-source finding), which have no linked issue at all. + if (item.advisoryInformation && !issue) { + printAdvisoryBlock(item.advisoryInformation); + } + // Optional prose fields if (item.summary) { console.log(); @@ -271,6 +280,9 @@ Examples: "finding.remediation", "finding.itemSource", "finding.itemSourceId", + "finding.advisoryInformation.advisoryId", + "finding.advisoryInformation.vulnerableFunctions", + "finding.advisoryInformation.publishedAt", // Issue (Codacy source) "issue.patternInfo.severityLevel", "issue.patternInfo.category", diff --git a/src/commands/findings.test.ts b/src/commands/findings.test.ts index 3f2fba9..b1e5ac1 100644 --- a/src/commands/findings.test.ts +++ b/src/commands/findings.test.ts @@ -725,6 +725,60 @@ describe("findings command", () => { mockExit.mockRestore(); }); + describe("advisory information (vulnerable functions)", () => { + const mockAdvisoryInformation = { + advisoryId: "CVE-2026-27903", + vulnerableFunctions: ["minimatch.braceExpand", "minimatch.makeRe"], + publishedAt: "2026-01-15T00:00:00.000Z", + }; + + it("should show a compact vulnerable functions line when advisoryInformation is present", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [{ ...mockScaDirect, advisoryInformation: mockAdvisoryInformation }], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "findings", "gh", "test-org", "test-repo", + ]); + + const output = getAllOutput(); + expect(output).toContain( + "Vulnerable functions: minimatch.braceExpand, minimatch.makeRe", + ); + }); + + it("should not show a vulnerable functions line when advisoryInformation is absent", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [mockScaDirect], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "findings", "gh", "test-org", "test-repo", + ]); + + const output = getAllOutput(); + expect(output).not.toContain("Vulnerable functions:"); + }); + + it("should include advisoryInformation in JSON output", async () => { + vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ + data: [{ ...mockScaDirect, advisoryInformation: mockAdvisoryInformation }], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", + "findings", "gh", "test-org", "test-repo", + ]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"minimatch.braceExpand"'), + ); + }); + }); + describe("auto-detect from git remote", () => { it("should auto-detect provider/org/repo when no positional args are provided", async () => { vi.mocked(SecurityService.searchSecurityItems).mockResolvedValue({ diff --git a/src/commands/findings.ts b/src/commands/findings.ts index 357c8fa..da5629c 100644 --- a/src/commands/findings.ts +++ b/src/commands/findings.ts @@ -17,6 +17,7 @@ import { formatDueDate, formatVersionSegment, formatDependencyChainsLine, + summarizeFunctions, } from "../utils/formatting"; import { sanitizeText } from "../utils/sanitize"; import { SecurityService } from "../api/client/services/SecurityService"; @@ -141,6 +142,13 @@ function printFindingCard(item: SrmItem, showRepo: boolean): void { if (chainLine) console.log(ansis.dim(chainLine)); } + // Vulnerable functions (findings with an OSV-linked advisory), compact form + if (item.advisoryInformation) { + console.log( + ansis.dim(`Vulnerable functions: ${summarizeFunctions(item.advisoryInformation.vulnerableFunctions)}`), + ); + } + console.log(); console.log(separator); } @@ -326,6 +334,9 @@ Examples: "application", "affectedTargets", "dependencyChains", + "advisoryInformation.advisoryId", + "advisoryInformation.vulnerableFunctions", + "advisoryInformation.publishedAt", ])), total, }); diff --git a/src/commands/issue.test.ts b/src/commands/issue.test.ts index 417d642..2e2772a 100644 --- a/src/commands/issue.test.ts +++ b/src/commands/issue.test.ts @@ -322,6 +322,59 @@ describe("issue command", () => { mockExit.mockRestore(); }); + describe("advisory information (vulnerable functions)", () => { + const mockAdvisoryInformation = { + advisoryId: "GHSA-xxxx-yyyy-zzzz", + vulnerableFunctions: ["pkg.Class.method", "pkg.Class.otherMethod"], + publishedAt: "2024-01-15T00:00:00.000Z", + }; + + it("should show vulnerable functions block when advisoryInformation is present", async () => { + vi.mocked(AnalysisService.getIssue).mockResolvedValue({ + data: { ...mockIssue, advisoryInformation: mockAdvisoryInformation }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issue", "gh", "test-org", "test-repo", "42", + ]); + + const output = getAllOutput(); + expect(output).toContain("Vulnerable Functions (GHSA-xxxx-yyyy-zzzz)"); + expect(output).toContain("Published: 2024-01-15"); + expect(output).toContain("pkg.Class.method"); + expect(output).toContain("pkg.Class.otherMethod"); + }); + + it("should not show vulnerable functions block when advisoryInformation is absent", async () => { + const program = createProgram(); + await program.parseAsync([ + "node", "test", "issue", "gh", "test-org", "test-repo", "42", + ]); + + const output = getAllOutput(); + expect(output).not.toContain("Vulnerable Functions"); + }); + + it("should include advisoryInformation in JSON output", async () => { + vi.mocked(AnalysisService.getIssue).mockResolvedValue({ + data: { ...mockIssue, advisoryInformation: mockAdvisoryInformation }, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", "issue", "gh", "test-org", "test-repo", "42", + ]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"GHSA-xxxx-yyyy-zzzz"'), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"pkg.Class.method"'), + ); + }); + }); + describe("--ignore option", () => { beforeEach(() => { vi.mocked(AnalysisService.updateIssueState).mockResolvedValue( diff --git a/src/commands/issue.ts b/src/commands/issue.ts index fdfe5b6..a303d53 100644 --- a/src/commands/issue.ts +++ b/src/commands/issue.ts @@ -127,6 +127,9 @@ Examples: "issue.falsePositiveProbability", "issue.falsePositiveThreshold", "issue.falsePositiveReason", + "issue.advisoryInformation.advisoryId", + "issue.advisoryInformation.vulnerableFunctions", + "issue.advisoryInformation.publishedAt", // Pattern "pattern.id", "pattern.title", diff --git a/src/commands/issues.test.ts b/src/commands/issues.test.ts index f3e337d..1b044a0 100644 --- a/src/commands/issues.test.ts +++ b/src/commands/issues.test.ts @@ -1660,6 +1660,76 @@ describe("issues command", () => { }); }); + describe("advisory information (vulnerable functions)", () => { + const mockAdvisoryInformation = { + advisoryId: "GHSA-xxxx-yyyy-zzzz", + vulnerableFunctions: ["pkg.Class.method", "pkg.Class.otherMethod"], + publishedAt: "2024-01-15T00:00:00.000Z", + }; + + it("should show a compact vulnerable functions line on the card when present", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [{ ...mockIssues[0], advisoryInformation: mockAdvisoryInformation }], + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "issues", "gh", "test-org", "test-repo"]); + + const output = getAllOutput(); + expect(output).toContain( + "Vulnerable functions: pkg.Class.method, pkg.Class.otherMethod", + ); + }); + + it("should truncate long vulnerable functions lists on the card", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [{ + ...mockIssues[0], + advisoryInformation: { + ...mockAdvisoryInformation, + vulnerableFunctions: ["fn1", "fn2", "fn3", "fn4", "fn5"], + }, + }], + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "issues", "gh", "test-org", "test-repo"]); + + const output = getAllOutput(); + expect(output).toContain("Vulnerable functions: fn1, fn2, fn3 (+2 more)"); + }); + + it("should not show the vulnerable functions line when absent", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: mockIssues, + } as any); + + const program = createProgram(); + await program.parseAsync(["node", "test", "issues", "gh", "test-org", "test-repo"]); + + const output = getAllOutput(); + expect(output).not.toContain("Vulnerable functions:"); + }); + + it("should include advisoryInformation in JSON output", async () => { + vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ + data: [{ ...mockIssues[0], advisoryInformation: mockAdvisoryInformation }], + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", "issues", "gh", "test-org", "test-repo", + ]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"GHSA-xxxx-yyyy-zzzz"'), + ); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"pkg.Class.method"'), + ); + }); + }); + describe("auto-detect from git remote", () => { it("should auto-detect repo when no positional args are provided", async () => { vi.mocked(AnalysisService.searchRepositoryIssues).mockResolvedValue({ diff --git a/src/commands/issues.ts b/src/commands/issues.ts index 397fe34..7d6e90b 100644 --- a/src/commands/issues.ts +++ b/src/commands/issues.ts @@ -915,6 +915,9 @@ Examples: "falsePositiveProbability", "falsePositiveThreshold", "falsePositiveReason", + "advisoryInformation.advisoryId", + "advisoryInformation.vulnerableFunctions", + "advisoryInformation.publishedAt", ]), ), }); diff --git a/src/commands/pull-request.test.ts b/src/commands/pull-request.test.ts index 5042046..7e97c0a 100644 --- a/src/commands/pull-request.test.ts +++ b/src/commands/pull-request.test.ts @@ -950,6 +950,114 @@ describe("pull-request command", () => { ); }); + describe("advisory information (vulnerable functions)", () => { + const mockAdvisoryInformation = { + advisoryId: "GHSA-xxxx-yyyy-zzzz", + vulnerableFunctions: ["pkg.Class.method", "pkg.Class.otherMethod"], + publishedAt: "2024-01-15T00:00:00.000Z", + }; + + it("should show a compact vulnerable functions line on the default view's issue cards", async () => { + vi.mocked(AnalysisService.listPullRequestIssues) + .mockResolvedValueOnce({ + data: [ + { + ...mockNewIssues.data[2], + commitIssue: { + ...mockNewIssues.data[2].commitIssue, + advisoryInformation: mockAdvisoryInformation, + }, + }, + ], + pagination: undefined, + } as any) + .mockResolvedValueOnce({ data: [], pagination: undefined } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "pull-request", "gh", "test-org", "test-repo", "42", + ]); + + const output = getAllOutput(); + expect(output).toContain( + "Vulnerable functions: pkg.Class.method, pkg.Class.otherMethod", + ); + }); + + it("should show the full vulnerable functions block in --issue detail view", async () => { + vi.mocked(AnalysisService.listPullRequestIssues) + .mockResolvedValueOnce({ + data: [ + { + ...mockNewIssues.data[2], + commitIssue: { + ...mockNewIssues.data[2].commitIssue, + advisoryInformation: mockAdvisoryInformation, + }, + }, + ], + pagination: undefined, + } as any) + .mockResolvedValueOnce({ + data: mockPotentialIssues.data, + pagination: undefined, + } as any); + vi.mocked(ToolsService.getPattern).mockResolvedValue({ + data: mockPattern, + } as any); + vi.mocked(FileService.getFileContent).mockResolvedValue({ + data: mockFileLines, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "pull-request", "gh", "test-org", "test-repo", "42", + "--issue", "3", + ]); + + const output = getAllOutput(); + expect(output).toContain("Vulnerable Functions (GHSA-xxxx-yyyy-zzzz)"); + expect(output).toContain("pkg.Class.method"); + expect(output).toContain("pkg.Class.otherMethod"); + }); + + it("should include advisoryInformation in --issue JSON output", async () => { + vi.mocked(AnalysisService.listPullRequestIssues) + .mockResolvedValueOnce({ + data: [ + { + ...mockNewIssues.data[2], + commitIssue: { + ...mockNewIssues.data[2].commitIssue, + advisoryInformation: mockAdvisoryInformation, + }, + }, + ], + pagination: undefined, + } as any) + .mockResolvedValueOnce({ + data: mockPotentialIssues.data, + pagination: undefined, + } as any); + vi.mocked(ToolsService.getPattern).mockResolvedValue({ + data: mockPattern, + } as any); + vi.mocked(FileService.getFileContent).mockResolvedValue({ + data: mockFileLines, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "--output", "json", "pull-request", "gh", "test-org", "test-repo", "42", + "--issue", "3", + ]); + + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"GHSA-xxxx-yyyy-zzzz"'), + ); + }); + }); + // ─── Diff Coverage Summary ───────────────────────────────────────────── it("should show Diff Coverage Summary when coverage data is available", async () => { diff --git a/src/commands/pull-request.ts b/src/commands/pull-request.ts index ec77c92..7be9f69 100644 --- a/src/commands/pull-request.ts +++ b/src/commands/pull-request.ts @@ -1091,6 +1091,9 @@ Examples: "issue.falsePositiveThreshold", "issue.falsePositiveReason", "issue.commitInfo.sha", + "issue.advisoryInformation.advisoryId", + "issue.advisoryInformation.vulnerableFunctions", + "issue.advisoryInformation.publishedAt", // Pattern "pattern.id", "pattern.title", diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index febc28b..0c9b208 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -6,6 +6,7 @@ import { PullRequestWithAnalysis } from "../api/client/models/PullRequestWithAna import { AnalysisResultReason } from "../api/client/models/AnalysisResultReason"; import { CommitIssue } from "../api/client/models/CommitIssue"; import { IgnoredIssue } from "../api/client/models/IgnoredIssue"; +import { AdvisoryInformation } from "../api/client/models/AdvisoryInformation"; import { SeverityLevel } from "../api/client/models/SeverityLevel"; import { Pattern } from "../api/client/models/Pattern"; import { ConfiguredPattern } from "../api/client/models/ConfiguredPattern"; @@ -262,10 +263,26 @@ export function printIssueCard( console.log(ansis.yellow(`Potential false positive: ${reason}`)); } + // Vulnerable functions (SCA issues with an OSV-linked advisory), compact form + if (issue.advisoryInformation) { + console.log(); + console.log(ansis.dim(`Vulnerable functions: ${summarizeFunctions(issue.advisoryInformation.vulnerableFunctions)}`)); + } + console.log(); console.log(CARD_SEPARATOR); } +/** + * Summarize a list of vulnerable function names for compact card display, + * capping at 3 entries with a "+N more" suffix for longer lists. + */ +export function summarizeFunctions(fns: string[], limit = 3): string { + const shown = fns.slice(0, limit).join(", "); + const more = fns.length > limit ? ` (+${fns.length - limit} more)` : ""; + return `${shown}${more}`; +} + /** * Card renderer for an ignored issue. Reuses `printIssueCardBody` for the shared * layout, then appends the ignore metadata line (reason / who / when) and an @@ -610,6 +627,23 @@ export function printCveBlock(cve: CveRecord): void { } } +/** + * Print an advisory enrichment block: the vulnerable functions and published + * date for the OSV advisory linked to an SCA issue. Shown inside + * `printIssueCodeContext` whenever `CommitIssue.advisoryInformation` is present. + */ +export function printAdvisoryBlock(advisory: AdvisoryInformation): void { + console.log(); + console.log(ansis.bold(`Vulnerable Functions (${advisory.advisoryId})`)); + if (advisory.publishedAt) { + console.log(ansis.dim(`Published: ${formatDueDate(advisory.publishedAt)}`)); + } + console.log(); + for (const fn of advisory.vulnerableFunctions) { + console.log(` • ${fn}`); + } +} + /** * Format and print the ±5 line code context around the issue. * The issue line is shown in bold; an optional suggestion is shown @@ -683,6 +717,11 @@ export function printIssueCodeContext( printCveBlock(cveData); } + // Advisory enrichment — vulnerable functions for SCA issues with an OSV-linked advisory + if (issue.advisoryInformation) { + printAdvisoryBlock(issue.advisoryInformation); + } + if (!pattern) { return; } From 5838a71105fe6e14c22be88ce4374967c5d92031 Mon Sep 17 00:00:00 2001 From: Pedro Pereira Date: Tue, 28 Jul 2026 17:40:35 +0100 Subject: [PATCH 2/3] doc: Simplify command inventory rows OD-296 Drop the vulnerable-functions/advisoryInformation qualifiers from the pull-request/issues/issue/findings/finding rows in the command inventory. --- SPECS/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/SPECS/README.md b/SPECS/README.md index 4f86560..41d52e4 100644 --- a/SPECS/README.md +++ b/SPECS/README.md @@ -17,11 +17,11 @@ _No pending tasks._ All commands implemented. | `repository` | `repo` | ✅ Done (actions added) | [repository.md](commands/repository.md) | | `ls` | N/A | ✅ Done | [ls.md](commands/ls.md) | | `directories` | `dirs` | ✅ Done | [directories.md](commands/directories.md) | -| `pull-request` | `pr` | ✅ Done (--diff + Diff Coverage Summary + vulnerable functions added) | [pull-request.md](commands/pull-request.md) | -| `issues` | `is` | ✅ Done (vulnerable functions added) | [issues.md](commands/issues.md) | -| `issue` | `iss` | ✅ Done (vulnerable functions added) | [issue.md](commands/issue.md) | -| `findings` | `fins` | ✅ Done (advisoryInformation added) | [findings.md](commands/findings.md) | -| `finding` | `fin` | ✅ Done (CVE enrichment + advisoryInformation included) | [finding.md](commands/finding.md) | +| `pull-request` | `pr` | ✅ Done (--diff + Diff Coverage Summary added) | [pull-request.md](commands/pull-request.md) | +| `issues` | `is` | ✅ Done | [issues.md](commands/issues.md) | +| `issue` | `iss` | ✅ Done | [issue.md](commands/issue.md) | +| `findings` | `fins` | ✅ Done | [findings.md](commands/findings.md) | +| `finding` | `fin` | ✅ Done (CVE enrichment included) | [finding.md](commands/finding.md) | | `tools` | `tls` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) | | `tool` | `tl` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) | | `patterns` | `pats` | ✅ Done | [tools-and-patterns.md](commands/tools-and-patterns.md) | From 74cbbec8bd0a86f27d2ee3e00f053a6efe02bc1a Mon Sep 17 00:00:00 2001 From: Pedro Pereira Date: Wed, 29 Jul 2026 10:18:07 +0100 Subject: [PATCH 3/3] fix: Sanitize advisory data in vulnerable functions output OD-296 External OSV advisory strings (function names, advisory ID) were printed raw, letting a crafted advisory smuggle terminal control sequences (CWE-150); route them through sanitizeText() like other repo-derived output. Also gate the vulnerable-functions line on a non-empty list and split printFindingCard into two helpers to stay under the line-count limit, and add the missing test for advisory-block suppression when a finding has a linked issue. --- .changeset/sanitize-advisory-functions.md | 5 ++ src/commands/finding.test.ts | 24 ++++++++++ src/commands/findings.ts | 58 +++++++++++++---------- src/utils/formatting.ts | 14 +++--- 4 files changed, 70 insertions(+), 31 deletions(-) create mode 100644 .changeset/sanitize-advisory-functions.md diff --git a/.changeset/sanitize-advisory-functions.md b/.changeset/sanitize-advisory-functions.md new file mode 100644 index 0000000..91cc531 --- /dev/null +++ b/.changeset/sanitize-advisory-functions.md @@ -0,0 +1,5 @@ +--- +"@codacy/codacy-cloud-cli": patch +--- + +Sanitize vulnerable/affected function names and the advisory ID (`CommitIssue.advisoryInformation` / `SrmItem.advisoryInformation`) before printing them in `issue`, `issues`, `pull-request --issue`, `finding`, and `findings`. These values come from the linked OSV advisory, so — like other repository-derived output — they are now passed through `sanitizeText()` to strip ANSI/OSC control bytes (CWE-150) instead of being printed raw. diff --git a/src/commands/finding.test.ts b/src/commands/finding.test.ts index bc0effa..44c647e 100644 --- a/src/commands/finding.test.ts +++ b/src/commands/finding.test.ts @@ -651,6 +651,30 @@ describe("finding command", () => { expect.stringContaining('"lodash.merge"'), ); }); + + it("should suppress its own advisory block for a Codacy-source finding with a linked issue, deferring to the issue's block", async () => { + vi.mocked(SecurityService.getSecurityItem).mockResolvedValue({ + data: { ...mockCodacyFinding, advisoryInformation: mockAdvisoryInformation }, + } as any); + vi.mocked(AnalysisService.getIssue).mockResolvedValue({ + data: { ...mockQualityIssue, advisoryInformation: mockAdvisoryInformation }, + } as any); + vi.mocked(ToolsService.getPattern).mockResolvedValue({ + data: mockPattern, + } as any); + vi.mocked(FileService.getFileContent).mockResolvedValue({ + data: mockFileLines, + } as any); + + const program = createProgram(); + await program.parseAsync([ + "node", "test", "finding", "gh", "test-org", "def-456-codacy", + ]); + + const output = getAllOutput(); + // Rendered once via printIssueCodeContext (the linked issue's block), not duplicated by finding.ts's own printAdvisoryBlock call + expect(output.match(/Vulnerable Functions \(CVE-2021-23337\)/g)).toHaveLength(1); + }); }); describe("--ignore option", () => { diff --git a/src/commands/findings.ts b/src/commands/findings.ts index da5629c..5fce393 100644 --- a/src/commands/findings.ts +++ b/src/commands/findings.ts @@ -77,14 +77,10 @@ function normalizeScanType(input: string): string { ); } -function printFindingCard(item: SrmItem, showRepo: boolean): void { - const separator = ansis.dim("─".repeat(40)); +// Line 1: Priority | SecurityCategory ScanType | Likelihood EffortToFix | Repository +function buildFindingHeaderLine(item: SrmItem, showRepo: boolean): string { const pipe = ` ${ansis.dim("|")} `; - - console.log(); - - // Line 1: Priority | SecurityCategory ScanType | Likelihood EffortToFix | Repository - const line1Parts: string[] = [colorPriority(item.priority)]; + const parts: string[] = [colorPriority(item.priority)]; const catParts = [ sanitizeText(item.securityCategory), @@ -92,46 +88,58 @@ function printFindingCard(item: SrmItem, showRepo: boolean): void { ] .filter(Boolean) .join(" "); - if (catParts) line1Parts.push(catParts); + if (catParts) parts.push(catParts); const penTestParts = [item.likelihood, item.effortToFix].filter( (v) => v && v !== "not_applicable", ) as string[]; - if (penTestParts.length > 0) line1Parts.push(penTestParts.join(" ")); + if (penTestParts.length > 0) parts.push(penTestParts.join(" ")); - if (showRepo && item.repository) line1Parts.push(ansis.dim(sanitizeText(item.repository))); + if (showRepo && item.repository) parts.push(ansis.dim(sanitizeText(item.repository))); const idLabel = ansis.hex("#555555")(item.id); - console.log(line1Parts.join(pipe) + ` ${idLabel}`); - - // Line 2: Title - console.log(sanitizeText(item.title)); - if (item.affectedTargets) console.log(ansis.dim(sanitizeText(item.affectedTargets))); - console.log(); + return parts.join(pipe) + ` ${idLabel}`; +} - // Line 3: Status DueAt | CVE/CWE | AffectedVersion → FixedVersion | Application | AffectedTargets - const line3Parts: string[] = [ +// Line 3: Status DueAt | CVE/CWE | AffectedVersion → FixedVersion | Application +function buildFindingStatusLine(item: SrmItem, hasChains: boolean): string { + const pipe = ` ${ansis.dim("|")} `; + const parts: string[] = [ `${colorStatus(item.status)} ${ansis.dim(formatDueDate(item.dueAt))}`, ]; - if (item.cve) line3Parts.push(ansis.dim(item.cve)); - else if (item.cwe) line3Parts.push(ansis.dim(`CWE-${item.cwe}`)); + if (item.cve) parts.push(ansis.dim(item.cve)); + else if (item.cwe) parts.push(ansis.dim(`CWE-${item.cwe}`)); // When dependency chains are present they carry the vulnerable package and // fixed version on their own line, so the redundant version segment is dropped. - const hasChains = !!item.dependencyChains?.length; if (!hasChains) { const versionSegment = formatVersionSegment( item.affectedVersion, item.fixedVersion, { includeUpdatePrefix: true }, ); - if (versionSegment) line3Parts.push(ansis.dim(versionSegment)); + if (versionSegment) parts.push(ansis.dim(versionSegment)); } - if (item.application) line3Parts.push(ansis.dim(sanitizeText(item.application))); + if (item.application) parts.push(ansis.dim(sanitizeText(item.application))); - console.log(line3Parts.join(pipe)); + return parts.join(pipe); +} + +function printFindingCard(item: SrmItem, showRepo: boolean): void { + const separator = ansis.dim("─".repeat(40)); + + console.log(); + console.log(buildFindingHeaderLine(item, showRepo)); + + // Line 2: Title + console.log(sanitizeText(item.title)); + if (item.affectedTargets) console.log(ansis.dim(sanitizeText(item.affectedTargets))); + console.log(); + + const hasChains = !!item.dependencyChains?.length; + console.log(buildFindingStatusLine(item, hasChains)); // Line 4: dependency import chain (SCA findings with dependencyChains) if (hasChains) { @@ -143,7 +151,7 @@ function printFindingCard(item: SrmItem, showRepo: boolean): void { } // Vulnerable functions (findings with an OSV-linked advisory), compact form - if (item.advisoryInformation) { + if (item.advisoryInformation?.vulnerableFunctions?.length) { console.log( ansis.dim(`Vulnerable functions: ${summarizeFunctions(item.advisoryInformation.vulnerableFunctions)}`), ); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 0c9b208..bbea005 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -264,7 +264,7 @@ export function printIssueCard( } // Vulnerable functions (SCA issues with an OSV-linked advisory), compact form - if (issue.advisoryInformation) { + if (issue.advisoryInformation?.vulnerableFunctions?.length) { console.log(); console.log(ansis.dim(`Vulnerable functions: ${summarizeFunctions(issue.advisoryInformation.vulnerableFunctions)}`)); } @@ -278,7 +278,7 @@ export function printIssueCard( * capping at 3 entries with a "+N more" suffix for longer lists. */ export function summarizeFunctions(fns: string[], limit = 3): string { - const shown = fns.slice(0, limit).join(", "); + const shown = fns.slice(0, limit).map(sanitizeText).join(", "); const more = fns.length > limit ? ` (+${fns.length - limit} more)` : ""; return `${shown}${more}`; } @@ -634,13 +634,15 @@ export function printCveBlock(cve: CveRecord): void { */ export function printAdvisoryBlock(advisory: AdvisoryInformation): void { console.log(); - console.log(ansis.bold(`Vulnerable Functions (${advisory.advisoryId})`)); + console.log(ansis.bold(`Vulnerable Functions (${sanitizeText(advisory.advisoryId)})`)); if (advisory.publishedAt) { console.log(ansis.dim(`Published: ${formatDueDate(advisory.publishedAt)}`)); } - console.log(); - for (const fn of advisory.vulnerableFunctions) { - console.log(` • ${fn}`); + if (advisory.vulnerableFunctions.length > 0) { + console.log(); + for (const fn of advisory.vulnerableFunctions) { + console.log(` • ${sanitizeText(fn)}`); + } } }