Skip to content
Merged
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
3 changes: 3 additions & 0 deletions apps/api/src/findings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ export * from './findings.repository';
export * from './findings.service';
export * from './findings.controller';
export * from './findings.routes';

// Symbol-aware finding context (#1164)
export * from './symbol-context';
156 changes: 156 additions & 0 deletions apps/api/src/findings/symbol-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Symbol-aware finding context (#1164).
*
* A finding that only carries a line number is hard to triage: the line may
* have moved, and reviewers need to know which symbol it landed in. This module
* attaches the innermost enclosing symbol to each finding, using the symbol
* spans the parser already produced, so a finding can be reported as
* `Token.transfer` rather than `line 118`.
*
* Spans are validated once and sorted by span size, which makes "innermost"
* well defined: the smallest span that contains the finding's line wins.
*/

export type SymbolKind =
| "function"
| "method"
| "class"
| "interface"
| "struct"
| "const"
| "unknown";

export interface SymbolSpan {
name: string;
kind: SymbolKind;
/** 1-based inclusive start line. */
startLine: number;
/** 1-based inclusive end line. */
endLine: number;
/** Enclosing symbol name, when the parser reported one. */
container?: string;
}

export interface SymbolContext {
name: string;
kind: SymbolKind;
qualifiedName: string;
startLine: number;
endLine: number;
}

export interface ContextualFinding<T> {
finding: T;
/** Undefined when the finding is outside every reported symbol. */
symbol?: SymbolContext;
}

export class SymbolSpanError extends Error {
constructor(message: string) {
super(message);
this.name = "SymbolSpanError";
}
}

export function validateSymbolSpan(span: SymbolSpan): void {
if (!span || typeof span.name !== "string" || span.name.trim() === "") {
throw new SymbolSpanError("symbol name must be a non-empty string");
}
if (!Number.isInteger(span.startLine) || span.startLine < 1) {
throw new SymbolSpanError(`symbol ${span.name} has an invalid startLine`);
}
if (!Number.isInteger(span.endLine) || span.endLine < span.startLine) {
throw new SymbolSpanError(`symbol ${span.name} ends before it starts`);
}
}

/** `Container.name` when a container is known, otherwise the bare name. */
export function qualifiedSymbolName(span: SymbolSpan): string {
const container = span.container?.trim();
return container ? `${container}.${span.name}` : span.name;
}

/**
* The innermost symbol containing `line`.
*
* On identical spans the later-declared symbol wins, which matches the common
* case of a nested re-declaration shadowing its parent.
*/
export function findEnclosingSymbol(
symbols: readonly SymbolSpan[],
line: number,
): SymbolSpan | undefined {
if (!Number.isInteger(line) || line < 1) {
throw new RangeError("line must be a positive integer");
}

const enclosing = symbols.filter((span) => {
validateSymbolSpan(span);
return span.startLine <= line && line <= span.endLine;
});

if (enclosing.length === 0) {
return undefined;
}

return enclosing.sort(
(left, right) =>
left.endLine - left.startLine - (right.endLine - right.startLine) ||
right.startLine - left.startLine,
)[0];
}

/** Attach symbol context to findings, preserving input order. */
export function attachSymbolContext<T extends { line: number }>(
findings: readonly T[],
symbols: readonly SymbolSpan[],
): Array<ContextualFinding<T>> {
return findings.map((finding) => {
const span = findEnclosingSymbol(symbols, finding.line);
if (!span) {
return { finding };
}

return {
finding,
symbol: {
name: span.name,
kind: span.kind,
qualifiedName: qualifiedSymbolName(span),
startLine: span.startLine,
endLine: span.endLine,
},
};
});
}

/** Group findings by their enclosing symbol, stable by qualified name. */
export function groupFindingsBySymbol<T extends { line: number }>(
findings: readonly T[],
symbols: readonly SymbolSpan[],
): Array<{ symbol?: string; findings: T[] }> {
const groups = new Map<string, T[]>();

for (const { finding, symbol } of attachSymbolContext(findings, symbols)) {
const key = symbol?.qualifiedName ?? "";
groups.set(key, [...(groups.get(key) ?? []), finding]);
}

return [...groups.entries()]
.map(([symbol, grouped]) => ({
...(symbol === "" ? {} : { symbol }),
findings: grouped,
}))
.sort((left, right) => {
if (left.symbol === right.symbol) {
return 0;
}
if (left.symbol === undefined) {
return 1;
}
if (right.symbol === undefined) {
return -1;
}
return left.symbol < right.symbol ? -1 : 1;
});
}
140 changes: 140 additions & 0 deletions apps/api/src/queue/worker-concurrency-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Worker concurrency configuration (#1088).
*
* Concurrency has to be tunable per deployment but must never let a single
* worker exhaust the host. The resolver reads `WORKER_CONCURRENCY` when it is
* set and otherwise derives a default from the CPU count, then clamps the
* result into a safe band. Invalid values fall back to the derived default
* rather than silently taking down the worker.
*
* Everything here is pure: the caller supplies the environment and CPU count.
*/

export interface WorkerConcurrencyConfig {
/** Jobs one worker processes at the same time. */
concurrency: number;
/** Lower bound applied to any configured value. */
minConcurrency: number;
/** Upper bound applied to any configured value. */
maxConcurrency: number;
/** Concurrent jobs a single repository may occupy. */
perRepositoryLimit: number;
/** Where the value came from, for logs and startup diagnostics. */
source: "environment" | "derived";
}

export const DEFAULT_WORKER_CONCURRENCY: Omit<WorkerConcurrencyConfig, "concurrency" | "source"> = {
minConcurrency: 1,
maxConcurrency: 16,
perRepositoryLimit: 2,
};

export class WorkerConcurrencyConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "WorkerConcurrencyConfigError";
}
}

/** Default concurrency for a host: one job per CPU, clamped to the band. */
export function derivedConcurrency(
cpuCount: number,
limits: Pick<WorkerConcurrencyConfig, "minConcurrency" | "maxConcurrency"> = DEFAULT_WORKER_CONCURRENCY,
): number {
if (!Number.isInteger(cpuCount) || cpuCount <= 0) {
throw new WorkerConcurrencyConfigError("cpuCount must be a positive integer");
}
return clamp(cpuCount, limits.minConcurrency, limits.maxConcurrency);
}

export function resolveWorkerConcurrency(
env: Record<string, string | undefined> = {},
cpuCount = 1,
overrides: Partial<Omit<WorkerConcurrencyConfig, "concurrency" | "source">> = {},
): WorkerConcurrencyConfig {
const limits = { ...DEFAULT_WORKER_CONCURRENCY, ...overrides };
validateLimits(limits);

const derived = derivedConcurrency(cpuCount, limits);
const raw = env.WORKER_CONCURRENCY?.trim();

if (!raw) {
return { ...limits, concurrency: derived, source: "derived" };
}

const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
return { ...limits, concurrency: derived, source: "derived" };
}

return {
...limits,
concurrency: clamp(parsed, limits.minConcurrency, limits.maxConcurrency),
source: "environment",
};
}

/** Split the worker's slots across named queues, largest remainders first. */
export function distributeConcurrency(
concurrency: number,
queueWeights: Record<string, number>,
): Record<string, number> {
if (!Number.isInteger(concurrency) || concurrency <= 0) {
throw new WorkerConcurrencyConfigError("concurrency must be a positive integer");
}

const entries = Object.entries(queueWeights).sort(([left], [right]) =>
left < right ? -1 : 1,
);
if (entries.length === 0) {
return {};
}

const totalWeight = entries.reduce((sum, [, weight]) => sum + Math.max(0, weight), 0);
if (totalWeight <= 0) {
throw new WorkerConcurrencyConfigError("queueWeights must contain a positive weight");
}

const allocation: Record<string, number> = {};
let assigned = 0;
const remainders: Array<{ queue: string; remainder: number }> = [];

for (const [queue, weight] of entries) {
const exact = (concurrency * Math.max(0, weight)) / totalWeight;
const whole = Math.floor(exact);
allocation[queue] = whole;
assigned += whole;
remainders.push({ queue, remainder: exact - whole });
}

remainders.sort((left, right) => right.remainder - left.remainder || (left.queue < right.queue ? -1 : 1));
for (const { queue } of remainders) {
if (assigned >= concurrency) {
break;
}
allocation[queue] += 1;
assigned += 1;
}

return allocation;
}

function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

function validateLimits(limits: {
minConcurrency: number;
maxConcurrency: number;
perRepositoryLimit: number;
}): void {
if (!Number.isInteger(limits.minConcurrency) || limits.minConcurrency < 1) {
throw new WorkerConcurrencyConfigError("minConcurrency must be a positive integer");
}
if (!Number.isInteger(limits.maxConcurrency) || limits.maxConcurrency < limits.minConcurrency) {
throw new WorkerConcurrencyConfigError("maxConcurrency must be >= minConcurrency");
}
if (!Number.isInteger(limits.perRepositoryLimit) || limits.perRepositoryLimit < 1) {
throw new WorkerConcurrencyConfigError("perRepositoryLimit must be a positive integer");
}
}
57 changes: 57 additions & 0 deletions apps/api/src/scanner/secret-false-positive-suppression.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {
filterSuppressedSecrets,
isSuppressedSecret,
type SuppressionInput,
} from "./secret-false-positive-suppression";

describe("secret false-positive suppression (#1175)", () => {
const detection = (overrides: Partial<SuppressionInput> = {}): SuppressionInput => ({
value: "Zx9Qm2Kd7Vp1Lr4Tb8Nw",
filePath: "src/config/secrets.ts",
line: 'const apiKey = "Zx9Qm2Kd7Vp1Lr4Tb8Nw";',
...overrides,
});

it("keeps a credential in first-party source", () => {
expect(isSuppressedSecret(detection())).toEqual({ suppressed: false });
});

it("suppresses test fixtures", () => {
const decision = isSuppressedSecret(detection({ filePath: "src/__tests__/secrets.spec.ts" }));
expect(decision.suppressed).toBe(true);
expect(decision.ruleId).toBe("documentation-path");
});

it("suppresses documentation", () => {
expect(isSuppressedSecret(detection({ filePath: "docs/secrets.md" })).suppressed).toBe(true);
});

it("suppresses placeholder values", () => {
const decision = isSuppressedSecret(
detection({ value: "changeme-please", line: 'const token = "changeme-please";' }),
);
expect(decision.suppressed).toBe(true);
expect(decision.ruleId).toBe("placeholder-value");
});

it("honours the inline allowance marker", () => {
const decision = isSuppressedSecret(
detection({ line: 'const apiKey = "Zx9Qm2Kd7Vp1Lr4Tb8Nw"; // gasguard:allow-secret' }),
);
expect(decision.suppressed).toBe(true);
expect(decision.ruleId).toBe("inline-allowance");
});

it("splits detections into kept and suppressed without leaking values", () => {
const report = filterSuppressedSecrets([
{ id: "a", ...detection() },
{ id: "b", ...detection({ filePath: "test/fixtures/keys.json" }) },
]);

expect(report.kept.map((item) => item.id)).toEqual(["a"]);
expect(report.suppressed).toEqual([
{ id: "b", ruleId: "documentation-path", reason: "path is documentation or an example fixture" },
]);
expect(JSON.stringify(report)).not.toContain("Zx9Qm2Kd7Vp1Lr4Tb8Nw");
});
});
Loading
Loading