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
5 changes: 5 additions & 0 deletions .changeset/gcm-auth-tag-length.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codacy/codacy-cloud-cli": patch
---

Stored credentials now require the full 16-byte AES-GCM authentication tag. Previously a truncated tag (as short as 4 bytes) still verified, which weakened the integrity check on `~/.codacy/credentials`; existing credential files keep working because the CLI has always written full-length tags. Also tidies a handful of lint findings (unused imports, `??` over `||` for nullish defaults, typed `catch` parameters) and adds a `.codacy.yaml` so Codacy skips test files for ESLint and generated Markdown for markdownlint.
17 changes: 17 additions & 0 deletions .codacy.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
exclude_paths:
# Already ignored in the Codacy UI; repeated here so the file and the UI agree
- "src/commands/issues.ts"
engines:
eslint-9:
exclude_paths:
# Mock-heavy tests: unbound-method on expect(svc.method), `as any`, no-unsafe-*
- "**/*.test.ts"
- "src/test-support.ts"
# changesets helper (.cjs) — ESLint has no Node globals configured for it
- ".changeset/**"
markdownlint:
exclude_paths:
- "CHANGELOG.md"
- "CODE_OF_CONDUCT.md"
- "SPECS/commands/tools-and-patterns.md"
8 changes: 0 additions & 8 deletions .codacy/codacy.yaml

This file was deleted.

2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,6 @@ When completing work, agents **must** update relevant documentation:

## Useful Context

- Codacy API docs: https://api.codacy.com/api/api-docs
- Codacy API docs: <https://api.codacy.com/api/api-docs>
- The CLI targets Codacy Cloud (app.codacy.com), not self-hosted instances
- Provider shortcodes used in commands: `gh` (GitHub), `gl` (GitLab), `bb` (Bitbucket)
1 change: 0 additions & 1 deletion src/commands/finding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
} 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";
import { ToolsService } from "../api/client/services/ToolsService";
import { FileService } from "../api/client/services/FileService";
Expand Down
2 changes: 1 addition & 1 deletion src/commands/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export function parseImageFilter(
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()}").`);
throw new Error(`--image expects name or name:tag (got "${image?.trim()}").`);
}
if (tag && tag !== refTag) {
throw new Error(
Expand Down
2 changes: 1 addition & 1 deletion src/commands/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Examples:
encodeURIComponent(issue.filePath),
startLine,
endLine,
).catch((e) => {
).catch((e: unknown) => {
console.error(ansis.red(`File path: ${sanitizeText(issue.filePath)}`));
console.error(ansis.red(`Error fetching file content: ${e}`));
return null;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
/** Reads the account token from `--token`, or prompts for it interactively. */
const acquireToken = async (tokenOption?: string): Promise<string> => {
if (tokenOption) {
const token = String(tokenOption).trim();
const token = tokenOption.trim();
if (!token) throw new Error("Token cannot be empty.");
return token;
}
Expand Down
13 changes: 6 additions & 7 deletions src/commands/pull-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
truncate,
buildGateStatus,
formatStandards,
colorByGate,
colorSeverity,
formatDelta,
formatPrCoverage,
Expand Down Expand Up @@ -207,8 +206,8 @@ function printAbout(
function buildGateHints(pr: PullRequestWithAnalysis): Record<string, string> {
const hints: Record<string, string> = {};
const reasons: AnalysisResultReason[] = [
...(pr.quality?.resultReasons || []),
...(pr.coverage?.resultReasons || []),
...(pr.quality?.resultReasons ?? []),
...(pr.coverage?.resultReasons ?? []),
];
for (const r of reasons) {
const key = gateKeyFromReason(r.gate);
Expand Down Expand Up @@ -325,7 +324,7 @@ function printFilesList(files: FileDeltaAnalysis[]): void {
q.deltaFixedIssues > 0 ||
q.deltaComplexity ||
q.deltaClonesCount)) ||
(c && c.deltaCoverage)
(c?.deltaCoverage)
);
});

Expand Down Expand Up @@ -573,7 +572,7 @@ function printAnnotatedDiff(
if (!issuesByFile.has(fp)) issuesByFile.set(fp, new Map());
const m = issuesByFile.get(fp)!;
if (!m.has(ln)) m.set(ln, []);
m.get(ln)!.push(tagged);
m.get(ln)?.push(tagged);
}

const { files } = parseDiff(diffText, true);
Expand Down Expand Up @@ -1267,7 +1266,7 @@ Examples:
pullRequest: prData,
newIssues: newIssues.data,
potentialIssues: potentialIssues.data,
files: filesData.data || [],
files: filesData.data ?? [],
}, [
// About
"pullRequest.pullRequest.repository",
Expand Down Expand Up @@ -1325,7 +1324,7 @@ Examples:
printDiffCoverageSummary(fileCoverageList);

// Files
printFilesList(filesData.data || []);
printFilesList(filesData.data ?? []);
printPaginationWarning(
filesData.pagination,
"Not all files are shown.",
Expand Down
3 changes: 1 addition & 2 deletions src/commands/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
truncate,
buildGateStatus,
formatStandards,
colorByGate,
formatDelta,
formatPrCoverage,
formatRepoCoverageDetail,
Expand Down Expand Up @@ -540,7 +539,7 @@ Examples:
organization,
repository,
),
fetchIfAccountToken(auth, noPullRequests(), () =>
fetchIfAccountToken(auth, noPullRequests(), async () =>
AnalysisService.listRepositoryPullRequests(
provider,
organization,
Expand Down
2 changes: 1 addition & 1 deletion src/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export function resolveAccountAuth(command: Command, because: string): AccountAu
* hang off a non-whitelisted endpoint — see the pull request table in
* `commands/repository.ts`.
*/
export function fetchIfAccountToken<T, F>(
export async function fetchIfAccountToken<T, F>(
auth: RemoteAuth,
fallback: F,
fetch: () => Promise<T>,
Expand Down
17 changes: 17 additions & 0 deletions src/utils/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ describe("credentials", () => {
expect(parsed).toHaveProperty("encrypted");
});

it("should write a full 16-byte auth tag that decryption accepts", () => {
const encrypted = encryptToken("test-token");
const parsed = JSON.parse(encrypted);
// decryptToken rejects anything shorter, so the writer must emit 16 bytes
expect(Buffer.from(parsed.authTag, "hex")).toHaveLength(16);
expect(decryptToken(encrypted)).toBe("test-token");
});

it("should produce different ciphertexts for the same token (random salt/iv)", () => {
const token = "same-token";
const a = encryptToken(token);
Expand All @@ -71,6 +79,15 @@ describe("credentials", () => {
expect(() => decryptToken(JSON.stringify(parsed))).toThrow();
});

it("should reject a truncated auth tag", () => {
// A prefix of a valid GCM tag still verifies unless the tag length is
// pinned, so this would decrypt successfully without authTagLength.
const encrypted = encryptToken("my-secret");
const parsed = JSON.parse(encrypted);
parsed.authTag = parsed.authTag.slice(0, 8);
expect(() => decryptToken(JSON.stringify(parsed))).toThrow();
});

it("should throw on invalid JSON", () => {
expect(() => decryptToken("not-json")).toThrow();
});
Expand Down
20 changes: 16 additions & 4 deletions src/utils/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const ALGORITHM = "aes-256-gcm";
const KEY_LENGTH = 32;
const IV_LENGTH = 16;
const SALT_LENGTH = 32;
// GCM tags can legally be truncated (down to 4 bytes), and a truncated prefix of
// a valid tag still verifies unless the expected length is pinned. We always
// write the full 16-byte tag, so require exactly that on read.
const AUTH_TAG_LENGTH = 16;
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_DIGEST = "sha512";

Expand Down Expand Up @@ -40,7 +44,9 @@ export function encryptToken(token: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const key = deriveKey(getMachineKey(), salt);

const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
const encrypted = Buffer.concat([
cipher.update(token, "utf8"),
cipher.final(),
Expand All @@ -66,7 +72,13 @@ export function decryptToken(payloadJson: string): string {
const encrypted = Buffer.from(payload.encrypted, "hex");
const key = deriveKey(getMachineKey(), salt);

const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
if (authTag.length !== AUTH_TAG_LENGTH) {
throw new Error("Invalid authentication tag length");
}

const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
decipher.setAuthTag(authTag);

const decrypted = Buffer.concat([
Expand Down Expand Up @@ -123,7 +135,7 @@ export function deleteCredentials(): boolean {
}
}

export function promptForToken(prompt: string): Promise<string> {
export async function promptForToken(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!process.stdin.isTTY) {
reject(new Error("Interactive input required. Use --token <value> instead."));
Expand All @@ -138,7 +150,7 @@ export function promptForToken(prompt: string): Promise<string> {
let token = "";

const onData = (char: string) => {
const chunk = char.toString();
const chunk = char;

for (const c of chunk) {
if (c === "\n" || c === "\r") {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/diff.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import gitDiffParser, { File } from "gitdiff-parser";
import gitDiffParser from "gitdiff-parser";
import { sortBy } from "lodash";

// TODO: remove this when the API has the correct diff for commits coming from Git
Expand Down
12 changes: 5 additions & 7 deletions src/utils/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ export function formatRepoCoverageCell(
function hasCoverageValue(coverage: Coverage | undefined): boolean {
return (
coverage?.coveragePercentage !== undefined &&
coverage?.coveragePercentage !== null
coverage.coveragePercentage !== null
);
}

Expand Down Expand Up @@ -703,8 +703,8 @@ export function formatDelta(
export function buildGateStatus(pr: PullRequestWithAnalysis): GateStatusMap {
const status: GateStatusMap = {};
const reasons: AnalysisResultReason[] = [
...(pr.quality?.resultReasons || []),
...(pr.coverage?.resultReasons || []),
...(pr.quality?.resultReasons ?? []),
...(pr.coverage?.resultReasons ?? []),
];
for (const r of reasons) {
const gate = r.gate.toLowerCase();
Expand Down Expand Up @@ -1249,9 +1249,7 @@ export async function resolveToolUuids(
continue;
}

if (!allTools) {
allTools = await fetchTools();
}
allTools ??= await fetchTools();

const lower = input.toLowerCase();

Expand All @@ -1274,7 +1272,7 @@ export async function resolveToolUuids(
return (
t.name.toLowerCase().includes(lower) ||
t.shortName.toLowerCase().includes(lower) ||
(t.prefix && t.prefix.toLowerCase().includes(lower))
(t.prefix?.toLowerCase().includes(lower))
);
});

Expand Down
2 changes: 1 addition & 1 deletion src/utils/git-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function stripUserinfo(host: string): string {
}

export function parseGitRemoteUrl(url: string): RepoContext | null {
const match = url.match(SSH_REGEX) || url.match(HTTPS_REGEX);
const match = (SSH_REGEX.exec(url)) ?? (HTTPS_REGEX.exec(url));
if (!match) return null;

const [, rawHost, org, repo] = match;
Expand Down
8 changes: 4 additions & 4 deletions src/utils/import-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function resolveToolId(

// Match by prefix (strip trailing _ before comparing)
const byPrefix = allTools.find(
(t) => t.prefix && t.prefix.replace(/_$/, "").toLowerCase() === id,
(t) => t.prefix?.replace(/_$/, "").toLowerCase() === id,
);
if (byPrefix) return byPrefix;

Expand All @@ -125,7 +125,7 @@ export async function getLocalSupportedToolIds(): Promise<string[] | null> {
const info = JSON.parse(stdout);
if (!info.tools || !Array.isArray(info.tools)) return null;
return info.tools
.filter((t: any) => t && t.supported && typeof t.id === "string")
.filter((t: any) => t?.supported && typeof t.id === "string")
.map((t: any) => t.id as string);
} catch {
return null;
Expand Down Expand Up @@ -193,12 +193,12 @@ export function buildImportPreview(

// Tools in the config that need enabling (currently disabled or not present)
const toolsToEnable = resolved.filter(
(r) => !r.repoTool || !r.repoTool.settings.isEnabled,
(r) => !r.repoTool?.settings.isEnabled,
);

// Tools in the config that are already enabled (need reconfiguration)
const toolsToReconfigure = resolved.filter(
(r) => r.repoTool && r.repoTool.settings.isEnabled,
(r) => r.repoTool?.settings.isEnabled,
);

const { toolsToDisable, cloudOnlyTools } = splitEnabledNotInConfig(
Expand Down
2 changes: 1 addition & 1 deletion src/utils/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import * as readline from "readline";
* to answer must abort rather than delete by accident. `-y` is how a caller
* says yes ahead of time.
*/
export function confirmAction(message: string): Promise<boolean> {
export async function confirmAction(message: string): Promise<boolean> {
return new Promise((resolve) => {
if (!process.stdin.isTTY) {
resolve(false);
Expand Down
4 changes: 2 additions & 2 deletions src/utils/reanalyze-wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ export const PATTERN_LIMIT = 20;
* resolver (`vi.spyOn(timers, "sleep").mockResolvedValue()`).
*/
export const timers = {
sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
async sleep(ms: number): Promise<void> {
return new Promise((resolve) => void setTimeout(resolve, ms));
},
};

Expand Down
2 changes: 1 addition & 1 deletion src/utils/repo-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function mapWithConcurrency<T>(
}
};
const size = Math.min(limit, items.length);
await Promise.all(Array.from({ length: size }, () => worker()));
await Promise.all(Array.from({ length: size }, async () => worker()));
}

/**
Expand Down
Loading