diff --git a/src/suppressions/stellar/index.ts b/src/suppressions/stellar/index.ts new file mode 100644 index 0000000..6728f91 --- /dev/null +++ b/src/suppressions/stellar/index.ts @@ -0,0 +1,18 @@ +/** + * Soroban Rule Suppression Framework Module + * + * @see Issue #476 — Implement Soroban Rule Suppression Framework + */ + +export { RuleSuppressionFramework, parseInlineSuppressions } from './rule-suppression-framework'; + +export type { + SuppressionSource, + InlineSuppression, + ConfigSuppression, + Suppression, + Finding, + SuppressionRecord, + SuppressionFilterResult, + SuppressionFrameworkConfig, +} from './types'; diff --git a/src/suppressions/stellar/rule-suppression-framework.ts b/src/suppressions/stellar/rule-suppression-framework.ts new file mode 100644 index 0000000..ec73720 --- /dev/null +++ b/src/suppressions/stellar/rule-suppression-framework.ts @@ -0,0 +1,250 @@ +/** + * Soroban Rule Suppression Framework + * + * Supports inline suppressions (code annotations) and configuration-based + * suppressions. Records suppression reasons and excludes suppressed findings + * from reports. + * + * @see Issue #476 — Implement Soroban Rule Suppression Framework + */ + +import type { + ConfigSuppression, + Finding, + InlineSuppression, + Suppression, + SuppressionFilterResult, + SuppressionFrameworkConfig, + SuppressionRecord, +} from './types'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const DEFAULT_ANNOTATION_PREFIX = 'gasguard-suppress:'; + +/** + * Parse inline suppression annotations from a block of source text. + * + * Recognised formats (in single-line comments): + * // gasguard-suppress: GG001 + * // gasguard-suppress: GG001 -- developer reason here + * // gasguard-suppress: GG001,GG002 -- multiple rules + */ +export function parseInlineSuppressions( + source: string, + filePath: string, + annotationPrefix = DEFAULT_ANNOTATION_PREFIX, +): InlineSuppression[] { + const results: InlineSuppression[] = []; + const lines = source.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Look for single-line comment containing the annotation + const commentIdx = line.indexOf('//'); + if (commentIdx === -1) continue; + + const comment = line.slice(commentIdx + 2).trim(); + if (!comment.toLowerCase().startsWith(annotationPrefix.toLowerCase())) continue; + + const rest = comment.slice(annotationPrefix.length).trim(); + // Split on "--" to separate rule IDs from reason + const [rulesPart, ...reasonParts] = rest.split('--'); + const reason = reasonParts.join('--').trim() || 'No reason provided'; + const ruleIds = rulesPart + .split(',') + .map((r) => r.trim()) + .filter(Boolean); + + for (const ruleId of ruleIds) { + results.push({ + source: 'inline', + ruleId, + filePath, + line: i + 1, + reason, + }); + } + } + + return results; +} + +/** + * Check if a file path matches a suppression scope glob. + * Supports simple glob patterns using `*` and `**`. + */ +function matchesScope(filePath: string, scope: string): boolean { + if (scope === 'global') return true; + // Convert glob to regex: ** matches any path segment, * matches non-separator + const pattern = scope + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex specials except * and ? + .replace(/\*\*/g, '§GLOBSTAR§') + .replace(/\*/g, '[^/]*') + .replace(/§GLOBSTAR§/g, '.*'); + return new RegExp(`^${pattern}$`).test(filePath); +} + +// ─── Framework ──────────────────────────────────────────────────────────────── + +export class RuleSuppressionFramework { + private readonly inlineSuppressions: InlineSuppression[] = []; + private readonly configSuppressions: ConfigSuppression[] = []; + private readonly annotationPrefix: string; + private readonly warnOnSuppression: boolean; + private readonly onSuppressed?: SuppressionFrameworkConfig['onSuppressed']; + + constructor(config: SuppressionFrameworkConfig = {}) { + this.annotationPrefix = config.inlineAnnotationPrefix ?? DEFAULT_ANNOTATION_PREFIX; + this.warnOnSuppression = config.warnOnSuppression ?? false; + this.onSuppressed = config.onSuppressed; + } + + // ─── Registration ───────────────────────────────────────────────────────── + + /** + * Register inline suppressions parsed from a source file. + */ + addInlineSuppressionsFromSource(source: string, filePath: string): void { + const parsed = parseInlineSuppressions(source, filePath, this.annotationPrefix); + this.inlineSuppressions.push(...parsed); + } + + /** + * Register inline suppressions directly (e.g. pre-parsed by a scanner). + */ + addInlineSuppressions(suppressions: InlineSuppression[]): void { + this.inlineSuppressions.push(...suppressions); + } + + /** + * Register configuration-based suppressions. + * These are typically loaded from gasguard.config.json. + */ + addConfigSuppressions(suppressions: ConfigSuppression[]): void { + this.configSuppressions.push(...suppressions); + } + + /** + * Register a single configuration-based suppression. + */ + addConfigSuppression(suppression: ConfigSuppression): void { + this.configSuppressions.push(suppression); + } + + // ─── Querying ───────────────────────────────────────────────────────────── + + /** + * Return all registered suppressions (both inline and config). + */ + getAllSuppressions(): Suppression[] { + return [...this.inlineSuppressions, ...this.configSuppressions]; + } + + /** + * Return all inline suppressions. + */ + getInlineSuppressions(): InlineSuppression[] { + return [...this.inlineSuppressions]; + } + + /** + * Return all config suppressions. + */ + getConfigSuppressions(): ConfigSuppression[] { + return [...this.configSuppressions]; + } + + /** + * Clear all registered suppressions. + */ + clear(): void { + this.inlineSuppressions.length = 0; + this.configSuppressions.length = 0; + } + + // ─── Matching ───────────────────────────────────────────────────────────── + + /** + * Check if a finding is suppressed by any registered suppression. + * Returns the matching suppression if found, or null. + */ + findSuppression(finding: Finding): Suppression | null { + // 1. Inline suppressions: must match ruleId and be in the same file + // (the annotation line must be at or immediately before the finding line) + for (const s of this.inlineSuppressions) { + if (s.ruleId === finding.ruleId && s.filePath === finding.filePath) { + // Allow the annotation to appear on the line above or on the same line + if (s.line === finding.line || s.line === finding.line - 1) { + return s; + } + } + } + + // 2. Config suppressions: match ruleId and scope + for (const s of this.configSuppressions) { + if (s.ruleId === finding.ruleId && matchesScope(finding.filePath, s.scope)) { + return s; + } + } + + return null; + } + + /** + * Return true if a finding is suppressed. + */ + isSuppressed(finding: Finding): boolean { + return this.findSuppression(finding) !== null; + } + + // ─── Filtering ──────────────────────────────────────────────────────────── + + /** + * Filter a list of findings through all registered suppressions. + * + * Returns a `SuppressionFilterResult` with: + * - `active`: findings that are NOT suppressed (should appear in reports) + * - `suppressed`: findings that were suppressed, with their suppression records + */ + filter(findings: Finding[]): SuppressionFilterResult { + const active: Finding[] = []; + const suppressed: SuppressionRecord[] = []; + const now = new Date().toISOString(); + + for (const finding of findings) { + const suppression = this.findSuppression(finding); + if (suppression) { + const record: SuppressionRecord = { finding, suppression, suppressedAt: now }; + suppressed.push(record); + + if (this.warnOnSuppression) { + process.stderr.write( + `[GasGuard] Suppressed ${finding.ruleId} at ${finding.filePath}:${finding.line}` + + ` — reason: ${suppression.reason}\n`, + ); + } + + this.onSuppressed?.(record); + } else { + active.push(finding); + } + } + + return { active, suppressed }; + } + + /** + * Return only the active (non-suppressed) findings. + */ + filterActive(findings: Finding[]): Finding[] { + return this.filter(findings).active; + } + + /** + * Return only the suppressed findings with their records. + */ + filterSuppressed(findings: Finding[]): SuppressionRecord[] { + return this.filter(findings).suppressed; + } +} diff --git a/src/suppressions/stellar/types.ts b/src/suppressions/stellar/types.ts new file mode 100644 index 0000000..5960369 --- /dev/null +++ b/src/suppressions/stellar/types.ts @@ -0,0 +1,113 @@ +/** + * Soroban Rule Suppression Framework — Types + * + * Defines the shapes for inline suppressions, configuration-based suppressions, + * and suppression records with reasons. + * + * @see Issue #476 — Implement Soroban Rule Suppression Framework + */ + +// ─── Suppression Source ─────────────────────────────────────────────────────── + +/** How the suppression was declared. */ +export type SuppressionSource = 'inline' | 'config'; + +// ─── Inline Suppression ─────────────────────────────────────────────────────── + +/** + * Inline suppression parsed from a source annotation. + * + * Example annotation in Soroban contract source: + * // gasguard-suppress: GG001 -- acceptable in single-owner contract + */ +export interface InlineSuppression { + source: 'inline'; + /** The rule ID being suppressed (e.g. "GG001"). */ + ruleId: string; + /** File path where the annotation was found. */ + filePath: string; + /** Line number of the suppression annotation (1-indexed). */ + line: number; + /** Human-readable reason provided by the developer. */ + reason: string; +} + +// ─── Config Suppression ─────────────────────────────────────────────────────── + +/** + * Config-based suppression declared in gasguard.config.json or equivalent. + * + * Example config entry: + * { "ruleId": "GG002", "scope": "global", "reason": "Not applicable for this contract type" } + */ +export interface ConfigSuppression { + source: 'config'; + /** The rule ID being suppressed. */ + ruleId: string; + /** + * Scope of the suppression. + * - "global": applies to all findings for this rule across the project. + * - A file glob (e.g. "contracts/legacy/**"): applies only to matching files. + */ + scope: 'global' | string; + /** Human-readable reason. */ + reason: string; +} + +/** Union type covering both suppression kinds. */ +export type Suppression = InlineSuppression | ConfigSuppression; + +// ─── Finding ───────────────────────────────────────────────────────────────── + +/** A rule finding produced by the analyser. */ +export interface Finding { + /** Rule ID that produced this finding. */ + ruleId: string; + /** Short description of the finding. */ + message: string; + /** File path where the finding was detected. */ + filePath: string; + /** Line number within the file (1-indexed). */ + line: number; + /** Severity level. */ + severity: 'high' | 'medium' | 'low'; +} + +// ─── Suppression Record ─────────────────────────────────────────────────────── + +/** A finding that was actively suppressed, with a record of why. */ +export interface SuppressionRecord { + finding: Finding; + suppression: Suppression; + /** When the suppression was applied (ISO string). */ + suppressedAt: string; +} + +// ─── Filter Result ──────────────────────────────────────────────────────────── + +/** Output of the suppression engine's filter operation. */ +export interface SuppressionFilterResult { + /** Findings that were NOT suppressed (should appear in reports). */ + active: Finding[]; + /** Findings that were suppressed, with their suppression records. */ + suppressed: SuppressionRecord[]; +} + +// ─── Engine Config ──────────────────────────────────────────────────────────── + +/** Configuration for RuleSuppressionFramework. */ +export interface SuppressionFrameworkConfig { + /** + * Inline suppression annotation prefix. + * Default: "gasguard-suppress:" + */ + inlineAnnotationPrefix?: string; + /** + * If true, log a warning when a suppression is applied. + * Useful for audit trails during CI. + * Default: false + */ + warnOnSuppression?: boolean; + /** Callback invoked every time a finding is suppressed. */ + onSuppressed?: (record: SuppressionRecord) => void; +}