diff --git a/apps/api-service/src/analyzer/analyzer.service.ts b/apps/api-service/src/analyzer/analyzer.service.ts index 93107cda..ad2c86d4 100644 Binary files a/apps/api-service/src/analyzer/analyzer.service.ts and b/apps/api-service/src/analyzer/analyzer.service.ts differ diff --git a/apps/api-service/src/analyzer/dto/analyze-request.dto.ts b/apps/api-service/src/analyzer/dto/analyze-request.dto.ts index 4534efcc..0918bd3a 100644 --- a/apps/api-service/src/analyzer/dto/analyze-request.dto.ts +++ b/apps/api-service/src/analyzer/dto/analyze-request.dto.ts @@ -1,4 +1,52 @@ -import { IsString, IsNotEmpty, IsOptional } from "class-validator"; +import { IsString, IsNotEmpty, IsOptional, IsObject, IsIn, IsInt, Min, IsArray, ValidateNested, IsBoolean } from "class-validator"; +import { Type } from "class-transformer"; + +export const POLICY_SEVERITIES = ["critical", "high", "medium", "low", "info"] as const; +export type PolicySeverity = (typeof POLICY_SEVERITIES)[number]; + +/** + * A single policy-as-code rule. A build fails when any finding matches the + * rule's criteria (severity, rule id, path prefix) and the optional count + * threshold is reached. + */ +export class PolicyRuleDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsOptional() + @IsIn(POLICY_SEVERITIES as unknown as string[]) + severity?: PolicySeverity; + + @IsOptional() + @IsString() + ruleId?: string; + + @IsOptional() + @IsString() + pathPrefix?: string; + + /** Fail when the number of matching findings is >= this value (default 1). */ + @IsOptional() + @IsInt() + @Min(1) + maxFindings?: number = 1; +} + +/** + * Policy configuration that controls whether a scan result should fail a build. + */ +export class PolicyConfigDto { + @IsOptional() + @IsBoolean() + enabled?: boolean = true; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PolicyRuleDto) + rules?: PolicyRuleDto[] = []; +} export class AnalyzeRequestDto { @IsString() @@ -8,4 +56,11 @@ export class AnalyzeRequestDto { @IsString() @IsOptional() source?: string = "remote-analysis"; + + /** Optional policy-as-code configuration evaluated against the analysis findings. */ + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => PolicyConfigDto) + policy?: PolicyConfigDto; } diff --git a/apps/api-service/src/analyzer/interfaces/analyzer.interface.ts b/apps/api-service/src/analyzer/interfaces/analyzer.interface.ts index 40bdbaed..ad2c3a56 100644 --- a/apps/api-service/src/analyzer/interfaces/analyzer.interface.ts +++ b/apps/api-service/src/analyzer/interfaces/analyzer.interface.ts @@ -19,3 +19,51 @@ export interface AnalysisReport { storageSavings: StorageSavings; recommendations: string[]; } + +/** + * Severity levels used by policy-as-code evaluation. + */ +export type PolicySeverity = "critical" | "high" | "medium" | "low" | "info"; + +/** + * A single policy condition set. A policy fails the build when any configured + * condition is matched by one or more violations in the report. + */ +export interface PolicyRule { + /** Human-readable identifier for the policy, used in evaluation output. */ + name: string; + /** Fail when any violation has a severity at or above this level. */ + failOnSeverity?: PolicySeverity; + /** Fail when a violation matches one of these rule identifiers. */ + ruleIds?: string[]; + /** Fail when a violation's source path matches one of these glob-style patterns. */ + paths?: string[]; + /** Fail when the total number of matching violations meets or exceeds this count. */ + maxFindings?: number; +} + +/** + * Top-level policy configuration document (e.g. loaded from a policy file). + */ +export interface PolicyConfig { + version: 1; + policies: PolicyRule[]; +} + +/** + * Result of evaluating a single policy against an analysis report. + */ +export interface PolicyEvaluationResult { + policy: string; + passed: boolean; + matchedViolations: FormattedViolation[]; + reasons: string[]; +} + +/** + * Aggregate result of evaluating all policies in a configuration. + */ +export interface PolicyEvaluationReport { + passed: boolean; + results: PolicyEvaluationResult[]; +} diff --git a/apps/api-service/src/rules/interfaces/rules.interface.ts b/apps/api-service/src/rules/interfaces/rules.interface.ts index da358af5..15f934ee 100644 --- a/apps/api-service/src/rules/interfaces/rules.interface.ts +++ b/apps/api-service/src/rules/interfaces/rules.interface.ts @@ -13,3 +13,79 @@ export interface RuleDefinition { enabled: boolean; documentationUrl?: string; } + +/** + * A single finding produced by rule evaluation. + */ +export interface RuleFinding { + rule: string; + severity: RuleSeverity; + category: RuleCategory; + path: string; + message: string; +} + +/** + * Policy-as-code configuration. A policy fails the build when any of the + * configured thresholds are exceeded. + */ +export interface PolicyConfig { + /** Human-readable name for the policy, used in reports. */ + name: string; + /** Optional description of what the policy enforces. */ + description?: string; + /** Whether this policy is active. Defaults to true when omitted. */ + enabled?: boolean; + /** + * Maximum number of findings allowed per severity level. A value of + * `undefined` means no limit for that severity. A limit of 0 fails on any + * finding of that severity. + */ + maxFindingsBySeverity?: Partial>; + /** Maximum total number of findings across all severities. */ + maxTotalFindings?: number; + /** Rule names that must cause the build to fail when triggered. */ + failOnRules?: string[]; + /** Rule names to ignore when evaluating this policy. */ + ignoreRules?: string[]; + /** + * Glob-style path patterns. Findings whose path matches any of these + * patterns are excluded from evaluation. + */ + ignorePaths?: string[]; + /** + * Glob-style path patterns. When set, only findings whose path matches at + * least one pattern are considered. + */ + includePaths?: string[]; +} + +/** + * Top-level policy file structure (e.g. `gasguard.policy.json`). + */ +export interface PolicyFile { + version: 1; + policies: PolicyConfig[]; +} + +/** + * Result of evaluating a single policy against a set of findings. + */ +export interface PolicyViolation { + policy: string; + reason: + | "severity-threshold" + | "total-threshold" + | "rule-triggered"; + detail: string; + findings: RuleFinding[]; +} + +/** + * Aggregate result of evaluating all policies. + */ +export interface PolicyEvaluationResult { + passed: boolean; + violations: PolicyViolation[]; + evaluatedAt: string; +} diff --git a/apps/api-service/src/rules/rules.controller.ts b/apps/api-service/src/rules/rules.controller.ts index 439c38da..26d5b0f6 100644 --- a/apps/api-service/src/rules/rules.controller.ts +++ b/apps/api-service/src/rules/rules.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param } from "@nestjs/common"; +import { Body, Controller, Get, Param, Post } from "@nestjs/common"; import { RulesService } from "./rules.service"; import { RuleDefinition } from "./interfaces/rules.interface"; @@ -15,4 +15,52 @@ export class RulesController { getRule(@Param("name") name: string): RuleDefinition | undefined { return this.rulesService.getRule(name); } + + @Post("evaluate-policy") + evaluatePolicy( + @Body() + body: { + policy: { + failOn?: { + severities?: string[]; + rules?: string[]; + paths?: string[]; + minFindingCount?: number; + }; + }; + findings: Array<{ + severity: string; + rule: string; + path: string; + }>; + }, + ): { passed: boolean; violations: string[] } { + const failOn = body?.policy?.failOn ?? {}; + const findings = Array.isArray(body?.findings) ? body.findings : []; + const violations: string[] = []; + + const matched = findings.filter((f) => { + const sevMatch = + !failOn.severities?.length || failOn.severities.includes(f.severity); + const ruleMatch = !failOn.rules?.length || failOn.rules.includes(f.rule); + const pathMatch = + !failOn.paths?.length || + failOn.paths.some((p) => f.path.startsWith(p)); + return sevMatch && ruleMatch && pathMatch; + }); + + if (matched.length > 0) { + violations.push(`${matched.length} finding(s) match failure criteria`); + } + if ( + typeof failOn.minFindingCount === "number" && + matched.length >= failOn.minFindingCount + ) { + violations.push( + `finding count ${matched.length} reached threshold ${failOn.minFindingCount}`, + ); + } + + return { passed: violations.length === 0, violations }; + } } diff --git a/apps/api-service/src/rules/rules.service.ts b/apps/api-service/src/rules/rules.service.ts index 4545e07a..a451eb1e 100644 Binary files a/apps/api-service/src/rules/rules.service.ts and b/apps/api-service/src/rules/rules.service.ts differ diff --git a/apps/api-service/src/scanner/dto/scan-request.dto.ts b/apps/api-service/src/scanner/dto/scan-request.dto.ts index 4b0609e2..5c484206 100644 --- a/apps/api-service/src/scanner/dto/scan-request.dto.ts +++ b/apps/api-service/src/scanner/dto/scan-request.dto.ts @@ -1,6 +1,6 @@ import { IsString, IsNotEmpty, IsOptional, MaxLength } from "class-validator"; -export const MAX_SCAN_CODE_BYTES = 1MB * 1024 * 1024; +export const MAX_SCAN_CODE_BYTES = 1 * 1024 * 1024; export class ScanRequestDto { @IsString() diff --git a/apps/api-service/src/scanner/scanner.service.ts b/apps/api-service/src/scanner/scanner.service.ts index f2b6d967..01be9833 100644 --- a/apps/api-service/src/scanner/scanner.service.ts +++ b/apps/api-service/src/scanner/scanner.service.ts @@ -8,6 +8,33 @@ import { assertArchiveSizeWithinLimits, } from "./archive-size.util"; +export type PolicySeverity = "error" | "warning" | "info"; + +export interface PolicyConfig { + /** Fail when any violation has a severity at or above this level. */ + failOnSeverity?: PolicySeverity; + /** Fail when any violation matches one of these rule identifiers. */ + failOnRules?: string[]; + /** Glob-like path prefixes; violations whose source starts with one of these fail the policy. */ + failOnPaths?: string[]; + /** Fail when the total number of violations exceeds this count. */ + maxFindings?: number; + /** Fail when the number of error-severity violations exceeds this count. */ + maxErrors?: number; +} + +export interface PolicyEvaluationResult { + passed: boolean; + failures: string[]; + evaluatedAt: string; +} + +const SEVERITY_RANK: Record = { + info: 0, + warning: 1, + error: 2, +}; + @Injectable() export class ScannerService { private readonly logger = new Logger(ScannerService.name); @@ -38,6 +65,86 @@ export class ScannerService { return results; } + /** + * Evaluates scan results against a configurable policy. Returns a result + * indicating whether the build should pass, with human-readable failure reasons. + */ + evaluatePolicy( + results: ScanResult[], + policy: PolicyConfig, + ): PolicyEvaluationResult { + const failures: string[] = []; + const allViolations: RuleViolation[] = results.flatMap( + (r) => r.violations, + ); + + if (policy.failOnSeverity) { + const threshold = SEVERITY_RANK[policy.failOnSeverity]; + const matching = allViolations.filter( + (v) => SEVERITY_RANK[v.severity as PolicySeverity] >= threshold, + ); + if (matching.length > 0) { + failures.push( + `${matching.length} violation(s) at or above severity "${policy.failOnSeverity}"`, + ); + } + } + + if (policy.failOnRules && policy.failOnRules.length > 0) { + const blocked = new Set(policy.failOnRules); + const matching = allViolations.filter((v) => blocked.has(v.rule)); + if (matching.length > 0) { + const ids = [...new Set(matching.map((v) => v.rule))].join(", "); + failures.push(`Blocked rule(s) triggered: ${ids}`); + } + } + + if (policy.failOnPaths && policy.failOnPaths.length > 0) { + for (const result of results) { + const hit = policy.failOnPaths.find((prefix) => + result.source.startsWith(prefix), + ); + if (hit && result.violations.length > 0) { + failures.push( + `${result.violations.length} violation(s) under restricted path "${hit}" (source: ${result.source})`, + ); + } + } + } + + if ( + policy.maxFindings !== undefined && + allViolations.length > policy.maxFindings + ) { + failures.push( + `Finding count ${allViolations.length} exceeds maximum ${policy.maxFindings}`, + ); + } + + if (policy.maxErrors !== undefined) { + const errorCount = allViolations.filter( + (v) => v.severity === "error", + ).length; + if (errorCount > policy.maxErrors) { + failures.push( + `Error count ${errorCount} exceeds maximum ${policy.maxErrors}`, + ); + } + } + + const evaluation: PolicyEvaluationResult = { + passed: failures.length === 0, + failures, + evaluatedAt: new Date().toISOString(), + }; + + if (!evaluation.passed) { + this.logger.warn(`Policy evaluation failed: ${failures.join("; ")}`); + } + + return evaluation; + } + /** * Validates the size of an uploaded archive against configured limits. * Throws ArchiveLimitError when any limit is violated. @@ -51,13 +158,11 @@ export class ScannerService { this.logger.log( `Archive upload validated successfully (${metadata.archiveBytes} bytes): ${ metadata.entries ?? "unknown" - } entries), + } entries`, ); } catch (error) { if (error instanceof Error) { - this.logger.warn( - `Archive upload rejected: ${error.message}`, - ); + this.logger.warn(`Archive upload rejected: ${error.message}`); } throw error; }