From 83336809be60bc88e67a8ff50e0acf30e15b87b6 Mon Sep 17 00:00:00 2001 From: Alqku Date: Fri, 26 Jun 2026 10:55:33 +0000 Subject: [PATCH] feat: implement Soroban finding ownership assignment (#509) - FindingOwnershipAssigner with filePattern, ruleIdPrefix, severity matching - Custom ownership rule registration via addRule() - OwnershipReport with per-owner breakdown and unassigned list - 10 tests covering all acceptance criteria --- src/findings/ownership/stellar/index.ts | 6 ++ .../stellar/ownership-assigner.spec.ts | 97 +++++++++++++++++++ .../ownership/stellar/ownership-assigner.ts | 96 ++++++++++++++++++ src/findings/ownership/stellar/types.ts | 43 ++++++++ 4 files changed, 242 insertions(+) create mode 100644 src/findings/ownership/stellar/index.ts create mode 100644 src/findings/ownership/stellar/ownership-assigner.spec.ts create mode 100644 src/findings/ownership/stellar/ownership-assigner.ts create mode 100644 src/findings/ownership/stellar/types.ts diff --git a/src/findings/ownership/stellar/index.ts b/src/findings/ownership/stellar/index.ts new file mode 100644 index 0000000..5c2c908 --- /dev/null +++ b/src/findings/ownership/stellar/index.ts @@ -0,0 +1,6 @@ +export { FindingOwnershipAssigner } from "./ownership-assigner"; +export type { + OwnershipRule, + OwnershipAssignment, + OwnershipReport, +} from "./types"; diff --git a/src/findings/ownership/stellar/ownership-assigner.spec.ts b/src/findings/ownership/stellar/ownership-assigner.spec.ts new file mode 100644 index 0000000..b7ca8cf --- /dev/null +++ b/src/findings/ownership/stellar/ownership-assigner.spec.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/findings/ownership/stellar/ownership-assigner.ts b/src/findings/ownership/stellar/ownership-assigner.ts new file mode 100644 index 0000000..4670838 --- /dev/null +++ b/src/findings/ownership/stellar/ownership-assigner.ts @@ -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 = {}; + + 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); + } +} diff --git a/src/findings/ownership/stellar/types.ts b/src/findings/ownership/stellar/types.ts new file mode 100644 index 0000000..9cb4a36 --- /dev/null +++ b/src/findings/ownership/stellar/types.ts @@ -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; + /** Findings with no matching rule. */ + unassigned: Finding[]; +}