From e6781b08bd7aaf60432df566fcee768e37541bd6 Mon Sep 17 00:00:00 2001 From: "Deborah (Precious) Aregbesola" Date: Fri, 23 Oct 2026 16:00:47 +0100 Subject: [PATCH] security: policy-as-code evaluation (#977) --- .../src/analyzer/analyzer.service.ts | Bin 3809 -> 8114 bytes .../src/analyzer/dto/analyze-request.dto.ts | 57 ++++++++- .../analyzer/interfaces/analyzer.interface.ts | 48 ++++++++ .../src/rules/interfaces/rules.interface.ts | 76 ++++++++++++ .../api-service/src/rules/rules.controller.ts | 50 +++++++- apps/api-service/src/rules/rules.service.ts | Bin 7656 -> 10350 bytes .../src/scanner/dto/scan-request.dto.ts | 2 +- .../src/scanner/scanner.service.ts | 113 +++++++++++++++++- 8 files changed, 339 insertions(+), 7 deletions(-) diff --git a/apps/api-service/src/analyzer/analyzer.service.ts b/apps/api-service/src/analyzer/analyzer.service.ts index 93107cda36ce1c2951d8b1fe2d3d8636b543e4a7..ad2c86d430e115c0776caf7326249c2632da9b28 100644 GIT binary patch literal 8114 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 delta 22 ecmdmF|4?>=BID*mEccl<>+|t5Zhjzoi4y>6F9@Fi 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 4545e07a94ba1f757ed8a8da1aa5c9323336fa40..a451eb1e4e8477d4bd5476aa214751f5a4f21549 100644 GIT binary patch delta 2720 zcmaJ@O>f&q5Y4TH?!8Y1!68+bN_vzl7ir)CL4d@NjR1utS1W2MZbNYi?ozg)5c~^0 z^p6xR`kUHIe?w>XgZfJ2izO}R&6_uGX8HHUzt_M1)$oTxmKyY9!QonFB7VH#8(s?i z2qWlF4o_w{M0p=CLf7-yizw+|j&5gKZm3MJ5}#GsQ@ zd2BqmYB2Uz+OvRwe8C~HrwAjEt&Xdh6?}=j=}9CA5S6^h(E7n(5`A;m+txw&aWb7v zr?bK89J)M;>rfmOmk~MZaop0@>^S>-Dnu!`yDZvkqyA5^7D@l z!2M!R5|MElt9GjBXY8GYHw8C)b~nug;)*q&SR5Nm({ECW1Ols3bhw5>hv89lT5^@j zjBIgW|A`B44&%*wp`t7anIzoIFNW`Qh_EhPs#YF)IM{?Rx;#p-iIiWTxJzxxep}AK zY*ggd5IL(BN<+?}KdP_I9c4UUVH+>u*{-~H2Si1s(j66md2f3hT0>&jP@qD<9gM4z zE#!uy7aDh+Jnd~iDQNw4?%D0}s|`b#f=%ZHPdm*OVaY7lNN-^q@C(lam$Nr>O&(X*E|SHZsaI= zq`A^Tn=z#Pdn4pM-F(K>^LEuJNbpWu;%xy7CtQJh1<y)vyMh`?%n~==JdMI+wNmGX1440B?7_4x?0n+ zZzpmuVckxnE7r|m$<1}M^@X}u22aSTJ;GYeO-{D5XU~-%@0OK&eQ3Y?;jj(^b 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; }