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
6 changes: 6 additions & 0 deletions src/findings/ownership/stellar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { FindingOwnershipAssigner } from "./ownership-assigner";
export type {
OwnershipRule,
OwnershipAssignment,
OwnershipReport,
} from "./types";
97 changes: 97 additions & 0 deletions src/findings/ownership/stellar/ownership-assigner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect } from "@jest/globals";
import { FindingOwnershipAssigner } from "./ownership-assigner";
import { Finding, Severity } from "@engine/core";

const makeFinding = (overrides: Partial<Finding> = {}): Finding => ({
ruleId: "stellar-storage-001",
message: "Inefficient storage access",
severity: Severity.MEDIUM,
location: { file: "contracts/token/main.rs", startLine: 10, endLine: 12 },
...overrides,
});

describe("FindingOwnershipAssigner", () => {
it("assigns finding to owner via ruleId prefix", () => {
const assigner = new FindingOwnershipAssigner([
{ id: "r1", name: "Storage Team", owner: "storage-team", ruleIdPrefix: "stellar-storage-" },
]);

const report = assigner.assign([makeFinding()]);

expect(report.assignments).toHaveLength(1);
expect(report.assignments[0]?.owner).toBe("storage-team");
expect(report.unassigned).toHaveLength(0);
});

it("assigns finding to owner via file pattern", () => {
const assigner = new FindingOwnershipAssigner([
{ id: "r1", name: "Token Team", owner: "token-team", filePattern: "contracts/token/**" },
]);

const report = assigner.assign([makeFinding()]);

expect(report.assignments[0]?.owner).toBe("token-team");
});

it("assigns finding to owner via severity filter", () => {
const assigner = new FindingOwnershipAssigner([
{ id: "r1", name: "Critical Handler", owner: "security-team", severities: [Severity.CRITICAL, Severity.HIGH] },
]);

const critical = makeFinding({ severity: Severity.CRITICAL });
const medium = makeFinding({ severity: Severity.MEDIUM });
const report = assigner.assign([critical, medium]);

expect(report.assignments).toHaveLength(1);
expect(report.assignments[0]?.owner).toBe("security-team");
expect(report.unassigned).toHaveLength(1);
});

it("puts unmatched findings in unassigned list", () => {
const assigner = new FindingOwnershipAssigner([]);
const report = assigner.assign([makeFinding()]);

expect(report.unassigned).toHaveLength(1);
expect(report.assignments).toHaveLength(0);
});

it("builds ownerBreakdown correctly", () => {
const assigner = new FindingOwnershipAssigner([
{ id: "r1", name: "Storage Team", owner: "storage-team", ruleIdPrefix: "stellar-storage-" },
]);

const report = assigner.assign([makeFinding(), makeFinding()]);

expect(report.ownerBreakdown["storage-team"]).toBe(2);
});

it("assignOne returns null when no rule matches", () => {
const assigner = new FindingOwnershipAssigner([]);
expect(assigner.assignOne(makeFinding())).toBeNull();
});

it("addRule registers a new rule that is applied", () => {
const assigner = new FindingOwnershipAssigner([]);
assigner.addRule({ id: "r1", name: "Ops", owner: "ops-team", ruleIdPrefix: "stellar-" });

const result = assigner.assignOne(makeFinding());
expect(result?.owner).toBe("ops-team");
});

it("uses first matching rule (rule priority)", () => {
const assigner = new FindingOwnershipAssigner([
{ id: "r1", name: "First", owner: "first-team", ruleIdPrefix: "stellar-" },
{ id: "r2", name: "Second", owner: "second-team", ruleIdPrefix: "stellar-storage-" },
]);

const result = assigner.assignOne(makeFinding());
expect(result?.owner).toBe("first-team");
expect(result?.matchedRule).toBe("r1");
});

it("reports total count including unassigned", () => {
const assigner = new FindingOwnershipAssigner([]);
const report = assigner.assign([makeFinding(), makeFinding()]);
expect(report.total).toBe(2);
});
});
96 changes: 96 additions & 0 deletions src/findings/ownership/stellar/ownership-assigner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Soroban Finding Ownership Assigner
*
* Maps findings to responsible modules or teams using configurable
* ownership rules. Supports file-pattern, ruleId-prefix, and
* severity-based matching.
*/

import { Finding } from "@engine/core";
import {
OwnershipRule,
OwnershipAssignment,
OwnershipReport,
} from "./types";

export class FindingOwnershipAssigner {
private rules: OwnershipRule[];

constructor(rules: OwnershipRule[] = []) {
this.rules = rules;
}

/** Register a new ownership rule. */
addRule(rule: OwnershipRule): void {
this.rules.push(rule);
}

/** Assign ownership to a single finding. Returns null if no rule matches. */
assignOne(finding: Finding): OwnershipAssignment | null {
for (const rule of this.rules) {
if (this.matches(finding, rule)) {
return {
finding,
owner: rule.owner,
matchedRule: rule.id,
assignedAt: Date.now(),
};
}
}
return null;
}

/**
* Assign ownership to a list of findings and produce a report.
*/
assign(findings: Finding[]): OwnershipReport {
const assignments: OwnershipAssignment[] = [];
const unassigned: Finding[] = [];
const ownerBreakdown: Record<string, number> = {};

for (const finding of findings) {
const assignment = this.assignOne(finding);
if (assignment) {
assignments.push(assignment);
ownerBreakdown[assignment.owner] =
(ownerBreakdown[assignment.owner] ?? 0) + 1;
} else {
unassigned.push(finding);
}
}

return {
total: findings.length,
assignments,
ownerBreakdown,
unassigned,
};
}

private matches(finding: Finding, rule: OwnershipRule): boolean {
if (rule.severities && rule.severities.length > 0) {
if (!rule.severities.includes(finding.severity)) return false;
}

if (rule.ruleIdPrefix && !finding.ruleId.startsWith(rule.ruleIdPrefix)) {
return false;
}

if (rule.filePattern) {
const file = finding.location.file;
if (!this.matchGlob(rule.filePattern, file)) return false;
}

return true;
}

/** Minimal glob: supports `*` (single segment) and `**` (any depth). */
private matchGlob(pattern: string, str: string): boolean {
const re = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*\*/g, "§DOUBLE§")
.replace(/\*/g, "[^/]*")
.replace(/§DOUBLE§/g, ".*");
return new RegExp(`^${re}$`).test(str);
}
}
43 changes: 43 additions & 0 deletions src/findings/ownership/stellar/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Types for Soroban Finding Ownership Assignment
*
* Defines the type system for mapping findings to responsible modules/teams
* and generating ownership assignment metadata.
*/

import { Finding } from "@engine/core";

/** A rule for assigning ownership based on finding attributes. */
export interface OwnershipRule {
/** Unique identifier for this rule. */
id: string;
/** Human-readable name. */
name: string;
/** Team or module that owns findings matched by this rule. */
owner: string;
/** Glob-style file pattern to match (e.g. "contracts/token/**"). */
filePattern?: string;
/** Rule ID prefix to match (e.g. "stellar-storage-"). */
ruleIdPrefix?: string;
/** Severity levels this rule applies to. Empty means all. */
severities?: Finding["severity"][];
}

/** Ownership metadata attached to a finding. */
export interface OwnershipAssignment {
finding: Finding;
owner: string;
/** The rule that produced this assignment. */
matchedRule: string;
assignedAt: number;
}

/** Summary of ownership assignments for a set of findings. */
export interface OwnershipReport {
total: number;
assignments: OwnershipAssignment[];
/** Counts per owner. */
ownerBreakdown: Record<string, number>;
/** Findings with no matching rule. */
unassigned: Finding[];
}
Loading