Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/neutralize-control-chars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@codacy/codacy-cloud-cli": patch
---

Neutralize terminal control characters in human-readable output (CWE-150).
Repository-derived values shown by the CLI — PR and finding titles, author
names, branches, file paths, diff and file content, issue messages, and package
names — are now stripped of ANSI/OSC escape and other control bytes before being
printed, so a crafted pull request can no longer repaint or hide findings, spoof
gate status, or trigger terminal side effects (e.g. clipboard writes) when you
run the CLI against it. Offending bytes are shown in visible caret notation
(e.g. `^[`) instead of being interpreted. `--output json` is unaffected — it
still returns the original values, escaped by JSON encoding.
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@
- `ora` for loading spinners
- `dayjs` for date formatting — for "last updated" style dates, use `formatFriendlyDate()` from `utils/output.ts` (relative for today, "Yesterday", otherwise YYYY-MM-DD)
- **Output:** Default output is human readable with tables and colors, but can be overridden with the `--output json` flag.
- **Untrusted output (CWE-150 — improper neutralization of control sequences):** Any repository-derived value written to the human-readable output must be passed through `sanitizeText()` from `utils/sanitize.ts` **before** the CLI wraps it in `ansis` styling. This neutralizes terminal control characters (C0/C1/DEL) so a crafted repo can't inject ANSI/OSC escape sequences.

Check warning on line 78 in AGENTS.md

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

AGENTS.md#L78

Absolute rule without escape hatch: "- **Untrusted output (CWE-150 — improper neutralization of control seq"

Check notice on line 78 in AGENTS.md

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

AGENTS.md#L78

Overly complex sentence (48 words). Break into shorter instructions.

Check notice on line 78 in AGENTS.md

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

AGENTS.md#L78

Undefined acronym "CWE" — define on first use or add to glossary.
- **Sinks to sanitize:** PR/finding titles, author names, branches, file paths, diff/file content, issue messages, and package/version names.
- **Where NOT to sanitize:** do **not** sanitize at the `console.log` boundary (that strips the CLI's own legitimate colors), and do **not** sanitize the `--output json` path (JSON encoding already escapes control bytes).
- **When adding a command or render helper:** sanitize each untrusted field at the point the raw value enters the output string.
- **Pagination:** All commands calling paginated APIs must call `printPaginationWarning(response.pagination, hint)` from `utils/output.ts` after displaying results. The hint should suggest command-specific filtering options.
- **Polling / waiting:** Commands that wait on a remote operation (e.g. `--reanalyze-and-wait`) use the shared helpers in `utils/reanalyze-wait.ts`.
- Route polling delays through the exported `timers.sleep` so tests can stub it (`vi.spyOn(timers, "sleep").mockResolvedValue()`).
Expand Down
1 change: 1 addition & 0 deletions SPECS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ _No pending tasks._ All commands implemented.
| 2026-07-16 | `issues --ignored` (`-i`): new read-only mode listing issues marked as ignored on Codacy, via the dedicated `searchRepositoryIgnoredIssues` endpoint. Boolean flag modeled on `--false-positives` for consistency (not a `--state <value>` selector). Reuses `buildFilterBody` (all existing filters + `--false-positives` pass through) and the paginate-to-`--limit` loop; errors when combined with `--overview`/`--ignore`. New `printIgnoredIssueCard` in `utils/formatting.ts` renders the ignore metadata line (`Ignored as <reason> by <name> · <date>` + optional comment) and the string `issueId` (ignored issues have no numeric `resultDataId`). Omitting the flag keeps existing behavior unchanged (10 new tests, 475 total) |
| 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) |
26 changes: 26 additions & 0 deletions src/commands/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@

Each command is a single file that exports a `register<Name>Command(program: Command)` function. Commands are registered in `src/index.ts`.

## Neutralizing untrusted output (CWE-150)

Repository-derived values (attacker may control them via a PR) are neutralized
with `sanitizeText()` (`utils/sanitize.ts`) before being written to the
human-readable output, so embedded ESC/control bytes can't be interpreted as
ANSI/OSC control sequences (repaint/hide findings, spoof gate status, OSC 52
clipboard writes, OSC 8 hyperlink spoofing).

- **Apply before styling, not at the boundary.** The CLI itself emits legitimate
`ansis` SGR sequences, so sanitizing at `console.log` would strip its own
colors, and merely allow-listing SGR would still pass an attacker's SGR
through (the originally reported vector). Sanitize each raw untrusted value
*before* it is wrapped in `ansis`.
- **JSON output is left untouched** — JSON encoding already escapes control
bytes, so `--output json` consumers get the original values.
- **Where it lives.** Most sinks are covered centrally in the shared render
helpers (`utils/formatting.ts`: issue cards/detail, code context, CVE block,
dependency chains, version segments). Commands that render untrusted values
outside those helpers sanitize at the render point: `pull-request` (About
table, Files, diff-coverage, annotated diff line/hunk/path), `findings`/
`finding`, `issues` (overview tables + noise suggestions), `issue`,
`repository` (About, Open PRs, overview), `ls`/`directories` (dir/file names).
- **New commands/helpers** must sanitize each untrusted field (titles, author
names, branches, file paths, diff/file content, issue messages, package
names, etc.) at the point the raw value enters the output string.

## Command Aliases

Every command must declare a short alias via `.alias()`. Keep aliases short (2–4 characters) and intuitive:
Expand Down
5 changes: 3 additions & 2 deletions src/commands/directories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import pluralize from "pluralize";
import { checkApiToken } from "../utils/auth";
import { handleError } from "../utils/error";
import { createTable, getOutputFormat, printJson } from "../utils/output";
import { sanitizeText } from "../utils/sanitize";
import { resolveRepoArgs } from "../utils/resolve-repo-args";
import {
fetchAllDirectories,
Expand Down Expand Up @@ -152,10 +153,10 @@ function renderDir(ctx: DirContext, dirs: DirectoryNode[]): void {
console.log(ansis.bold(`\n${dirHeader(ctx, dirs)}\n`));
const table = createTable({ head: TABLE_HEAD });
for (const d of dirs) {
table.push([`${FOLDER_GLYPH} ${d.name}`, ...metricCells(d)]);
table.push([`${FOLDER_GLYPH} ${sanitizeText(d.name)}`, ...metricCells(d)]);
for (const child of d.children ?? []) {
table.push([
`${ansis.dim(CHILD_CONNECTOR)}${child.name}`,
`${ansis.dim(CHILD_CONNECTOR)}${sanitizeText(child.name)}`,
...metricCells(child),
]);
}
Expand Down
23 changes: 12 additions & 11 deletions src/commands/finding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
printCveBlock,
printIssueCodeContext,
} from "../utils/formatting";
import { sanitizeText } from "../utils/sanitize";
import { SecurityService } from "../api/client/services/SecurityService";
import { IgnoreSRMItemBody } from "../api/client/models/IgnoreSRMItemBody";
import { AnalysisService } from "../api/client/services/AnalysisService";
Expand Down Expand Up @@ -49,8 +50,8 @@ function printFindingDetail(
const line1Parts: string[] = [colorPriority(item.priority)];

const catParts = [
item.securityCategory,
item.scanType ? ansis.dim(item.scanType) : undefined,
sanitizeText(item.securityCategory),
item.scanType ? ansis.dim(sanitizeText(item.scanType)) : undefined,
]
.filter(Boolean)
.join(" ");
Expand All @@ -61,13 +62,13 @@ function printFindingDetail(
) as string[];
if (penTestParts.length > 0) line1Parts.push(penTestParts.join(" "));

if (item.repository) line1Parts.push(ansis.dim(item.repository));
if (item.repository) line1Parts.push(ansis.dim(sanitizeText(item.repository)));

const idLabel = ansis.hex("#555555")(item.id);
console.log(line1Parts.join(pipe) + ` ${idLabel}`);

// Title
console.log(item.title);
console.log(sanitizeText(item.title));
console.log();

// Line 2: Status DueAt | CVE/CWE | AffectedVersion → FixedVersion | Application | AffectedTargets
Expand All @@ -89,8 +90,8 @@ function printFindingDetail(
if (versionSegment) line2Parts.push(ansis.dim(versionSegment));
}

if (item.application) line2Parts.push(ansis.dim(item.application));
if (item.affectedTargets) line2Parts.push(ansis.dim(item.affectedTargets));
if (item.application) line2Parts.push(ansis.dim(sanitizeText(item.application)));
if (item.affectedTargets) line2Parts.push(ansis.dim(sanitizeText(item.affectedTargets)));

console.log(line2Parts.join(pipe));

Expand All @@ -108,9 +109,9 @@ function printFindingDetail(
const ig = item.ignored;
console.log();
console.log(
ansis.dim(`Ignored by ${ig.authorName} on ${formatDueDate(ig.at)}`),
ansis.dim(`Ignored by ${sanitizeText(ig.authorName)} on ${formatDueDate(ig.at)}`),
);
if (ig.reason) console.log(ansis.dim(ig.reason));
if (ig.reason) console.log(ansis.dim(sanitizeText(ig.reason)));
}

// CVE block: shown here only when there is no linked Codacy issue.
Expand All @@ -123,18 +124,18 @@ function printFindingDetail(
// Optional prose fields
if (item.summary) {
console.log();
console.log(item.summary);
console.log(sanitizeText(item.summary));
}

if (item.additionalInfo) {
console.log();
console.log(item.additionalInfo);
console.log(sanitizeText(item.additionalInfo));
}

if (item.remediation) {
console.log();
console.log(ansis.bold("Remediation:"));
console.log(item.remediation);
console.log(sanitizeText(item.remediation));
}

// Codacy source: show linked quality issue context + pattern info.
Expand Down
15 changes: 9 additions & 6 deletions src/commands/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
formatVersionSegment,
formatDependencyChainsLine,
} 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";
Expand Down Expand Up @@ -84,7 +85,10 @@ function printFindingCard(item: SrmItem, showRepo: boolean): void {
// Line 1: Priority | SecurityCategory ScanType | Likelihood EffortToFix | Repository
const line1Parts: string[] = [colorPriority(item.priority)];

const catParts = [item.securityCategory, ansis.dim(item.scanType)]
const catParts = [
sanitizeText(item.securityCategory),
item.scanType ? ansis.dim(sanitizeText(item.scanType)) : undefined,
]
.filter(Boolean)
.join(" ");
if (catParts) line1Parts.push(catParts);
Expand All @@ -94,14 +98,14 @@ function printFindingCard(item: SrmItem, showRepo: boolean): void {
) as string[];
if (penTestParts.length > 0) line1Parts.push(penTestParts.join(" "));

if (showRepo && item.repository) line1Parts.push(ansis.dim(item.repository));
if (showRepo && item.repository) line1Parts.push(ansis.dim(sanitizeText(item.repository)));

const idLabel = ansis.hex("#555555")(item.id);
console.log(line1Parts.join(pipe) + ` ${idLabel}`);

// Line 2: Title
console.log(item.title);
if (item.affectedTargets) console.log(ansis.dim(item.affectedTargets));
console.log(sanitizeText(item.title));
if (item.affectedTargets) console.log(ansis.dim(sanitizeText(item.affectedTargets)));
console.log();

// Line 3: Status DueAt | CVE/CWE | AffectedVersion → FixedVersion | Application | AffectedTargets
Expand All @@ -124,8 +128,7 @@ function printFindingCard(item: SrmItem, showRepo: boolean): void {
if (versionSegment) line3Parts.push(ansis.dim(versionSegment));
}

if (item.application) line3Parts.push(ansis.dim(item.application));
//if (item.affectedTargets) line3Parts.push(ansis.dim(item.affectedTargets));
if (item.application) line3Parts.push(ansis.dim(sanitizeText(item.application)));

console.log(line3Parts.join(pipe));

Expand Down
3 changes: 2 additions & 1 deletion src/commands/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { handleError } from "../utils/error";
import { resolveRepoArgs } from "../utils/resolve-repo-args";
import { getOutputFormat, pickDeep, printJson } from "../utils/output";
import { printIssueDetail } from "../utils/formatting";
import { sanitizeText } from "../utils/sanitize";
import { AnalysisService } from "../api/client/services/AnalysisService";
import { ToolsService } from "../api/client/services/ToolsService";
import { FileService } from "../api/client/services/FileService";
Expand Down Expand Up @@ -96,7 +97,7 @@ Examples:
startLine,
endLine,
).catch((e) => {
console.log("File path: ", issue.filePath);
console.error(ansis.red(`File path: ${sanitizeText(issue.filePath)}`));
console.error(ansis.red(`Error fetching file content: ${e}`));
return null;
}),
Expand Down
7 changes: 4 additions & 3 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
resolveToolUuids,
formatCount,
} from "../utils/formatting";
import { sanitizeText } from "../utils/sanitize";
import { AnalysisService } from "../api/client/services/AnalysisService";
import { ToolsService } from "../api/client/services/ToolsService";
import { Tool } from "../api/client/models/Tool";
Expand Down Expand Up @@ -176,7 +177,7 @@ function printCountTable(title: string, counts: Count[]): void {
const sorted = [...counts].sort((a, b) => b.total - a.total);
const table = createTable({ head: [title, "Count"] });
for (const c of sorted) {
table.push([c.name, String(c.total)]);
table.push([sanitizeText(c.name), String(c.total)]);
}
console.log(table.toString());
}
Expand All @@ -186,7 +187,7 @@ function printPatternsTable(patterns: PatternsCount[]): void {
const sorted = [...patterns].sort((a, b) => b.total - a.total);
const table = createTable({ head: ["Pattern", "Count"] });
for (const p of sorted) {
table.push([`${p.title} ${ansis.dim(p.id)}`, String(p.total)]);
table.push([`${sanitizeText(p.title)} ${ansis.dim(p.id)}`, String(p.total)]);
}
console.log(table.toString());
}
Expand Down Expand Up @@ -436,7 +437,7 @@ function printNoiseSuggestions(
for (const s of suggestions) {
const label = s.total === 1 ? "issue" : "issues";
const reduction = ansis.green(`(-${formatCount(s.total)} ${label})`);
console.log(` Disable ${ansis.bold(`"${s.title}"`)} ${reduction}`);
console.log(` Disable ${ansis.bold(`"${sanitizeText(s.title)}"`)} ${reduction}`);
if (s.command) {
console.log(` ${ansis.dim(">")} ${s.command}`);
} else if (s.action) {
Expand Down
5 changes: 3 additions & 2 deletions src/commands/ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import pluralize from "pluralize";
import { checkApiToken } from "../utils/auth";
import { handleError } from "../utils/error";
import { createTable, getOutputFormat, printJson } from "../utils/output";
import { sanitizeText } from "../utils/sanitize";
import {
fetchAllDirectories,
fetchAllFiles,
Expand Down Expand Up @@ -184,11 +185,11 @@ function renderLs(
console.log(ansis.bold(`\n${lsHeader(ctx, dirs.length, files.length)}\n`));
const table = createTable({ head: TABLE_HEAD });
for (const d of dirs) {
table.push([`${FOLDER_GLYPH} ${d.name}`, ...metricCells(d)]);
table.push([`${FOLDER_GLYPH} ${sanitizeText(d.name)}`, ...metricCells(d)]);
}
for (const f of files) {
table.push([
`${ansis.dim(FILE_GLYPH)} ${fileLabel(ctx, f.path)}`,
`${ansis.dim(FILE_GLYPH)} ${sanitizeText(fileLabel(ctx, f.path))}`,
...metricCells(f),
]);
}
Expand Down
Loading
Loading