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
2 changes: 2 additions & 0 deletions src/correlation/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./types";
export * from "./soroban-finding-correlation-engine";
103 changes: 103 additions & 0 deletions src/correlation/stellar/soroban-finding-correlation-engine.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Finding, Severity } from "@engine/core";
import { SorobanFindingCorrelationEngine } from "./soroban-finding-correlation-engine";

function finding(overrides: Partial<Finding> & { ruleId: string; message: string }): Finding {
return {
ruleId: overrides.ruleId,
message: overrides.message,
severity: overrides.severity ?? Severity.MEDIUM,
location: {
file: overrides.location?.file ?? "contracts/payment/src/lib.rs",
startLine: overrides.location?.startLine ?? 12,
endLine: overrides.location?.endLine ?? 12,
},
metadata: overrides.metadata,
estimatedGasSavings: overrides.estimatedGasSavings,
suggestedFix: overrides.suggestedFix,
};
}

describe("SorobanFindingCorrelationEngine", () => {
const engine = new SorobanFindingCorrelationEngine();

it("groups findings that share an explicit root cause id", () => {
const report = engine.createReport([
finding({
ruleId: "stellar-access-control",
message: "Missing admin authorization before token mint",
severity: Severity.HIGH,
location: { file: "contracts/token/src/admin.rs", startLine: 20, endLine: 20 },
metadata: { rootCauseId: "admin-auth-missing", functionName: "mint" },
}),
finding({
ruleId: "stellar-event-traceability",
message: "Mint path does not emit an audit event",
severity: Severity.MEDIUM,
location: { file: "contracts/token/src/admin.rs", startLine: 34, endLine: 34 },
metadata: { rootCauseId: "admin-auth-missing", functionName: "mint" },
}),
]);

expect(report.summary.totalFindings).toBe(2);
expect(report.summary.correlatedGroups).toBe(1);
expect(report.groups[0].findingCount).toBe(2);
expect(report.groups[0].highestSeverity).toBe(Severity.HIGH);
expect(report.groups[0].sharedSignals).toEqual(
expect.arrayContaining(["rootCauseId:admin-auth-missing", "function:mint"]),
);
});

it("correlates same-function Soroban findings without explicit metadata keys", () => {
const report = engine.createReport([
finding({
ruleId: "stellar-storage-write",
message: "Repeated storage write increases ledger cost",
location: { file: "contracts/vault/src/lib.rs", startLine: 48, endLine: 48 },
metadata: { functionName: "deposit" },
}),
finding({
ruleId: "stellar-event-traceability",
message: "Deposit flow misses an event for the same state mutation",
location: { file: "contracts/vault/src/lib.rs", startLine: 55, endLine: 55 },
metadata: { functionName: "deposit" },
}),
finding({
ruleId: "stellar-network-validation",
message: "Network passphrase should be validated before execution",
location: { file: "contracts/oracle/src/lib.rs", startLine: 10, endLine: 10 },
metadata: { functionName: "initialize" },
}),
]);

expect(report.summary.correlatedGroups).toBe(1);
expect(report.summary.standaloneFindings).toBe(1);
expect(report.groups[0].affectedFiles).toEqual(["contracts/vault/src/lib.rs"]);
expect(report.groups[0].ruleIds).toEqual([
"stellar-event-traceability",
"stellar-storage-write",
]);
});

it("renders a concise markdown report for review artifacts", () => {
const report = engine.createReport([
finding({
ruleId: "stellar-access-control",
message: "Missing signer check before upgrade",
severity: Severity.CRITICAL,
metadata: { rootCauseId: "upgrade-auth", functionName: "upgrade" },
}),
finding({
ruleId: "stellar-event-traceability",
message: "Upgrade flow has no audit event",
metadata: { rootCauseId: "upgrade-auth", functionName: "upgrade" },
}),
]);

const markdown = engine.renderMarkdown(report);

expect(markdown).toContain("# Soroban Finding Correlation Report");
expect(markdown).toContain("upgrade-auth");
expect(markdown).toContain("stellar-access-control");
expect(markdown).toContain("Fix the shared root cause once");
});
});
244 changes: 244 additions & 0 deletions src/correlation/stellar/soroban-finding-correlation-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { Finding, Severity } from "@engine/core";
import {
SorobanCorrelatedFinding,
SorobanFindingCorrelationGroup,
SorobanFindingCorrelationOptions,
SorobanFindingCorrelationReport,
} from "./types";

const SEVERITY_WEIGHT: Record<Severity, number> = {
[Severity.CRITICAL]: 5,
[Severity.HIGH]: 4,
[Severity.MEDIUM]: 3,
[Severity.LOW]: 2,
[Severity.INFO]: 1,
};

export class SorobanFindingCorrelationEngine {
public createReport(
findings: Finding[],
options: SorobanFindingCorrelationOptions = {},
): SorobanFindingCorrelationReport {
const minimumGroupSize = options.minimumGroupSize ?? 2;
const buckets = new Map<string, SorobanCorrelatedFinding[]>();
const standalone: Finding[] = [];

for (const finding of findings) {
const signals = this.buildSignals(finding);
const key = this.selectBucketKey(signals);

if (!key) {
standalone.push(finding);
continue;
}

const current = buckets.get(key) ?? [];
current.push({ finding, signals });
buckets.set(key, current);
}

const groups: SorobanFindingCorrelationGroup[] = [];

for (const [key, correlatedFindings] of buckets.entries()) {
if (correlatedFindings.length < minimumGroupSize) {
standalone.push(...correlatedFindings.map((entry) => entry.finding));
continue;
}

groups.push(this.createGroup(key, correlatedFindings));
}

groups.sort((a, b) => {
const severityDelta =
SEVERITY_WEIGHT[b.highestSeverity] - SEVERITY_WEIGHT[a.highestSeverity];
if (severityDelta !== 0) return severityDelta;
return b.findingCount - a.findingCount;
});

return {
generatedAt: (options.generatedAt ?? new Date()).toISOString(),
summary: {
totalFindings: findings.length,
correlatedGroups: groups.length,
standaloneFindings: standalone.length,
affectedFiles: this.uniqueSorted(findings.map((finding) => finding.location.file)),
highestSeverity: this.highestSeverity(findings),
},
groups,
standaloneFindings: standalone,
};
}

public renderMarkdown(report: SorobanFindingCorrelationReport): string {
const lines = [
"# Soroban Finding Correlation Report",
"",
`- Total findings: ${report.summary.totalFindings}`,
`- Correlated groups: ${report.summary.correlatedGroups}`,
`- Standalone findings: ${report.summary.standaloneFindings}`,
`- Highest severity: ${report.summary.highestSeverity ?? "none"}`,
"",
];

if (report.groups.length === 0) {
lines.push("No related finding groups were detected.");
return lines.join("\n");
}

lines.push("## Correlated groups", "");

for (const group of report.groups) {
lines.push(`### ${group.title}`);
lines.push(`- Findings: ${group.findingCount}`);
lines.push(`- Highest severity: ${group.highestSeverity}`);
lines.push(`- Files: ${group.affectedFiles.join(", ")}`);
if (group.affectedFunctions.length > 0) {
lines.push(`- Functions: ${group.affectedFunctions.join(", ")}`);
}
lines.push(`- Rules: ${group.ruleIds.join(", ")}`);
lines.push(`- Shared signals: ${group.sharedSignals.join(", ")}`);
lines.push(`- Recommendation: ${group.recommendation}`);
lines.push("");
}

return lines.join("\n").trimEnd();
}

private createGroup(
key: string,
correlatedFindings: SorobanCorrelatedFinding[],
): SorobanFindingCorrelationGroup {
const findings = correlatedFindings.map((entry) => entry.finding);
const sharedSignals = this.intersectSignals(correlatedFindings);
const affectedFiles = this.uniqueSorted(findings.map((finding) => finding.location.file));
const affectedFunctions = this.uniqueSorted(
correlatedFindings
.map((entry) => entry.signals.find((signal) => signal.startsWith("function:")))
.filter((signal): signal is string => Boolean(signal))
.map((signal) => signal.slice("function:".length)),
);
const ruleIds = this.uniqueSorted(findings.map((finding) => finding.ruleId));
const highestSeverity = this.highestSeverity(findings) ?? Severity.INFO;

return {
id: this.slugify(key),
title: this.titleForGroup(key, affectedFunctions),
findingCount: findings.length,
highestSeverity,
affectedFiles,
affectedFunctions,
ruleIds,
sharedSignals,
findings: correlatedFindings,
recommendation: this.recommendationForGroup(sharedSignals, ruleIds),
};
}

private buildSignals(finding: Finding): string[] {
const metadata = finding.metadata ?? {};
const rootCauseId = this.firstString(metadata.rootCauseId, metadata.rootCause, metadata.correlationId);
const functionName = this.firstString(metadata.functionName, metadata.function, metadata.symbol);
const contractName = this.firstString(metadata.contractName, metadata.contract);

const signals = [`file:${finding.location.file}`];

if (rootCauseId) {
signals.push(`rootCauseId:${rootCauseId}`);
}

if (functionName) {
signals.push(`function:${functionName}`);
}

if (contractName) {
signals.push(`contract:${contractName}`);
}

return signals;
}

private selectBucketKey(signals: string[]): string | null {
const explicitRoot = signals.find((signal) => signal.startsWith("rootCauseId:"));
if (explicitRoot) return explicitRoot;

const file = signals.find((signal) => signal.startsWith("file:"));
const functionName = signals.find((signal) => signal.startsWith("function:"));
if (file && functionName) {
return `${file}|${functionName}`;
}

const contract = signals.find((signal) => signal.startsWith("contract:"));
if (contract && functionName) {
return `${contract}|${functionName}`;
}

return null;
}

private intersectSignals(entries: SorobanCorrelatedFinding[]): string[] {
if (entries.length === 0) return [];

const [first, ...rest] = entries;
return first.signals
.filter((signal) => rest.every((entry) => entry.signals.includes(signal)))
.sort();
}

private highestSeverity(findings: Finding[]): Severity | null {
if (findings.length === 0) return null;

return findings.reduce((highest, finding) => {
return SEVERITY_WEIGHT[finding.severity] > SEVERITY_WEIGHT[highest]
? finding.severity
: highest;
}, findings[0].severity);
}

private recommendationForGroup(sharedSignals: string[], ruleIds: string[]): string {
const rootCause = sharedSignals.find((signal) => signal.startsWith("rootCauseId:"));
const functionSignal = sharedSignals.find((signal) => signal.startsWith("function:"));

if (rootCause) {
return `Fix the shared root cause once, then rerun the affected rules: ${ruleIds.join(", ")}.`;
}

if (functionSignal) {
return `Review the shared function once and address the related rule findings together: ${ruleIds.join(", ")}.`;
}

return `Review these findings together before patching so one change does not leave a related issue behind.`;
}

private titleForGroup(key: string, affectedFunctions: string[]): string {
if (key.startsWith("rootCauseId:")) {
return `Root cause ${key.slice("rootCauseId:".length)}`;
}

if (affectedFunctions.length > 0) {
return `Related findings in ${affectedFunctions.join(", ")}`;
}

return "Related Soroban findings";
}

private firstString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}

return undefined;
}

private uniqueSorted(values: string[]): string[] {
return Array.from(new Set(values)).sort();
}

private slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
}
39 changes: 39 additions & 0 deletions src/correlation/stellar/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Finding, Severity } from "@engine/core";

export interface SorobanFindingCorrelationOptions {
generatedAt?: Date;
minimumGroupSize?: number;
}

export interface SorobanCorrelatedFinding {
finding: Finding;
signals: string[];
}

export interface SorobanFindingCorrelationGroup {
id: string;
title: string;
findingCount: number;
highestSeverity: Severity;
affectedFiles: string[];
affectedFunctions: string[];
ruleIds: string[];
sharedSignals: string[];
findings: SorobanCorrelatedFinding[];
recommendation: string;
}

export interface SorobanFindingCorrelationSummary {
totalFindings: number;
correlatedGroups: number;
standaloneFindings: number;
affectedFiles: string[];
highestSeverity: Severity | null;
}

export interface SorobanFindingCorrelationReport {
generatedAt: string;
summary: SorobanFindingCorrelationSummary;
groups: SorobanFindingCorrelationGroup[];
standaloneFindings: Finding[];
}
Loading