diff --git a/packages/validation/src/MonokleValidator.ts b/packages/validation/src/MonokleValidator.ts index 63f5feee7..75b852a58 100644 --- a/packages/validation/src/MonokleValidator.ts +++ b/packages/validation/src/MonokleValidator.ts @@ -3,7 +3,7 @@ import difference from 'lodash/difference.js'; import uniqueId from 'lodash/uniqueId.js'; import isEqual from 'react-fast-compare'; import {ResourceParser} from './common/resourceParser.js'; -import type {BaseLineState, Tool, ValidationResponse, ValidationResult, ValidationRun} from './common/sarif.js'; +import type {Tool, ValidationResponse, ValidationResult, ValidationRun} from './common/sarif.js'; import type {CustomSchema, Incremental, Plugin, Resource} from './common/types.js'; import {Config, PluginMap} from './config/parse.js'; import {CIS_TAXONOMY} from './taxonomies/cis.js'; @@ -14,12 +14,14 @@ import {extractSchema, findDefaultVersion} from './utils/customResourceDefinitio import {PluginLoadError} from './utils/error.js'; import invariant from './utils/invariant.js'; import {isDefined} from './utils/isDefined.js'; +import {AnnotationSuppressor, Suppressor} from './sarif/suppressions/index.js'; +import {SuppressEngine} from './sarif/suppressions/engine.js'; export type PluginLoader = (name: string, settings?: Record) => Promise; -export type CustomPluginLoader = (name: string, parser: ResourceParser) => Promise; +export type CustomPluginLoader = (name: string, parser: ResourceParser, suppressor?: Suppressor) => Promise; -export function createMonokleValidator(loader: PluginLoader, fallback?: PluginMap) { - return new MonokleValidator(loader, fallback); +export function createMonokleValidator(loader: PluginLoader, suppressors: Suppressor[], fallback?: PluginMap) { + return new MonokleValidator(loader, suppressors, fallback); } /** @@ -32,6 +34,8 @@ const DEFAULT_PLUGIN_MAP = { 'kubernetes-schema': true, }; +const DEFAULT_SUPPRESSORS = [new AnnotationSuppressor()]; + type ValidateParams = { /** * The resources that will be validated. @@ -83,9 +87,15 @@ export class MonokleValidator implements Validator { _plugins: Plugin[] = []; _failedPlugins: string[] = []; _customSchemas: Set = new Set(); + private _suppressor: SuppressEngine; - constructor(loader: PluginLoader, fallback: PluginMap = DEFAULT_PLUGIN_MAP) { + constructor( + loader: PluginLoader, + suppressors: Suppressor[] = DEFAULT_SUPPRESSORS, + fallback: PluginMap = DEFAULT_PLUGIN_MAP + ) { this._loader = loader; + this._suppressor = new SuppressEngine(suppressors); this._fallback = {plugins: fallback}; this._config = this._fallback; } @@ -291,7 +301,9 @@ export class MonokleValidator implements Validator { this.preprocessCustomResourceDefinitions(resources); - const allRuns = await Promise.allSettled(validators.map(v => v.validate(resources, incremental))); + new SuppressEngine().preload(); + + const allRuns = await Promise.allSettled(validators.map(v => v.validate(resources, {incremental}))); throwIfAborted(loadAbortSignal, externalAbortSignal); const results = allRuns @@ -321,6 +333,11 @@ export class MonokleValidator implements Validator { this.compareWithBaseline(result, baseline); } + await this._suppressor.suppress(result, resources, { + noInSourceSuppressions: this._config?.settings?.noInSourceSuppressions, + noExternalSuppressions: this._config?.settings?.noExternalSuppressions, + }); + return result; } diff --git a/packages/validation/src/__tests__/sarif/assets/deployment.yaml b/packages/validation/src/__tests__/resources/basic-deployment/deployment.yaml similarity index 100% rename from packages/validation/src/__tests__/sarif/assets/deployment.yaml rename to packages/validation/src/__tests__/resources/basic-deployment/deployment.yaml diff --git a/packages/validation/src/__tests__/sarif/fingerprint.test.ts b/packages/validation/src/__tests__/sarif/fingerprint.test.ts index ad8df795c..b417bfd3d 100644 --- a/packages/validation/src/__tests__/sarif/fingerprint.test.ts +++ b/packages/validation/src/__tests__/sarif/fingerprint.test.ts @@ -18,7 +18,7 @@ it('should have fingerprints & baseline', async () => { }, }); - const files = await readDirectory('./src/__tests__/sarif/assets'); + const files = await readDirectory('./src/__tests__/resources/basic-deployment'); const resourceBad = extractK8sResources(files); const badResponse = await validator.validate({resources: resourceBad}); @@ -44,12 +44,6 @@ it('should have fingerprints & baseline', async () => { baseline: badResponse, }); - editedResponse.runs.forEach(r => - r.results.forEach(result => { - console.error(result.ruleId, result.fingerprints?.['monokleHash/v1']); - }) - ); - const run = editedResponse.runs[0]; expect(run.baselineGuid).toBeDefined(); const unchangedCount = run.results.reduce((sum, r) => sum + (r.baselineState === 'unchanged' ? 1 : 0), 0); diff --git a/packages/validation/src/__tests__/sarif/suppression.test.ts b/packages/validation/src/__tests__/sarif/suppression.test.ts new file mode 100644 index 000000000..2a7111e5f --- /dev/null +++ b/packages/validation/src/__tests__/sarif/suppression.test.ts @@ -0,0 +1,83 @@ +import 'isomorphic-fetch'; +import {expect, it} from 'vitest'; +import {createDefaultMonokleValidator} from '../../index.js'; + +import {PRACTICES_ALL_DISABLED, extractK8sResources, readDirectory} from '../testUtils.js'; +import {FakeSuppressor} from '../../sarif/suppressions/plugins/FakeSuppressor.js'; +import YAML from 'yaml'; +import {set} from 'lodash'; + +it('supports suppress requests', async () => { + const suppressor = new FakeSuppressor(); + const validator = createDefaultMonokleValidator(undefined, undefined, [suppressor]); + + await validator.preload({ + plugins: { + practices: true, + }, + rules: { + ...PRACTICES_ALL_DISABLED, + 'practices/no-latest-image': 'err', + }, + }); + + // Given a problem + const files = await readDirectory('./src/__tests__/resources/basic-deployment'); + const badResponse = await validator.validate({ + resources: extractK8sResources(files), + }); + const problemCount = badResponse.runs[0].results.length; + expect(problemCount).toBe(1); + + // When it is suppressed + suppressor.addSuppressionRequest(badResponse.runs[0].results[0]); + + // Then the next validate response will mark the suppress request + const editedResponse = await validator.validate({ + resources: extractK8sResources(files), + }); + + editedResponse.runs.forEach(r => + r.results.forEach(result => { + console.error(result.ruleId, result.suppressions); + }) + ); + + const problem = editedResponse.runs[0].results[0]; + expect(problem.suppressions?.length).toBe(1); +}); + +it('in-line annotation suppressions by default', async () => { + const validator = createDefaultMonokleValidator(); + + await validator.preload({ + plugins: { + practices: true, + }, + rules: { + ...PRACTICES_ALL_DISABLED, + 'practices/no-latest-image': 'err', + }, + }); + + // Given a problem + const files = await readDirectory('./src/__tests__/resources/basic-deployment'); + const badResponse = await validator.validate({ + resources: extractK8sResources(files), + }); + const problemCount = badResponse.runs[0].results.length; + expect(problemCount).toBe(1); + + // When an annotation is added + const content = YAML.parse(files[0].content); + set(content, 'metadata.annotations["monokle.io/suppress.kbp.no-latest-image"]', true); + files[0].content = YAML.stringify(content); + + // Then the next validate response will mark the suppress request + const editedResponse = await validator.validate({ + resources: extractK8sResources(files), + }); + + const problem = editedResponse.runs[0].results[0]; + expect(problem.suppressions?.length).toBe(1); +}); diff --git a/packages/validation/src/common/AbstractPlugin.ts b/packages/validation/src/common/AbstractPlugin.ts index b4d7a1754..579c2fdf3 100644 --- a/packages/validation/src/common/AbstractPlugin.ts +++ b/packages/validation/src/common/AbstractPlugin.ts @@ -5,7 +5,7 @@ import {PluginMetadataWithConfig, RuleMetadataWithConfig} from '../types.js'; import invariant from '../utils/invariant.js'; import {getResourceId} from '../utils/sarif.js'; import {ValidationResult, RuleMetadata, RuleConfig, ToolPlugin} from './sarif.js'; -import {Incremental, Resource, Plugin, PluginMetadata, CustomSchema} from './types.js'; +import {Incremental, Resource, Plugin, PluginMetadata, CustomSchema, ValidateOptions} from './types.js'; import {fingerprint} from '../utils/fingerprint.js'; const DEFAULT_RULE_CONFIG: RuleConfig = { @@ -123,7 +123,7 @@ export abstract class AbstractPlugin implements Plugin { protected createValidationResult( ruleId: string, - args: Omit + args: Omit ): ValidationResult | undefined { const index = this._ruleReverseLookup.get(ruleId); invariant(index !== undefined, 'rules misconfigured'); @@ -145,6 +145,7 @@ export abstract class AbstractPlugin implements Plugin { index: this.toolComponentIndex, }, }, + suppressions: [], taxa, level: ruleConfig.level, ...args, @@ -235,13 +236,13 @@ export abstract class AbstractPlugin implements Plugin { return; } - async validate(resources: Resource[], incremental?: Incremental): Promise { + async validate(resources: Resource[], options: ValidateOptions = {}): Promise { invariant(this.configured, NOT_CONFIGURED_ERR_MSG(this.name)); - let results = await this.doValidate(resources, incremental); + let results = await this.doValidate(resources, options); - if (incremental) { - results = this.merge(this._previous, results, incremental); + if (options.incremental) { + results = this.merge(this._previous, results, options.incremental); } this._previous = results; @@ -249,7 +250,7 @@ export abstract class AbstractPlugin implements Plugin { return results; } - protected abstract doValidate(resources: Resource[], incremental?: Incremental): Promise; + protected abstract doValidate(resources: Resource[], options: ValidateOptions): Promise; protected getRuleConfig(ruleId: string): RuleConfig { const ruleConfig = this._ruleConfig.get(ruleId); diff --git a/packages/validation/src/common/sarif.ts b/packages/validation/src/common/sarif.ts index 870dfd57c..ded0ed26a 100644 --- a/packages/validation/src/common/sarif.ts +++ b/packages/validation/src/common/sarif.ts @@ -57,6 +57,20 @@ export type RunAutomationDetails = { */ export type Artifact = ConfigurationArtifact; +/** + * A request to suppress a result. + * + * @see https://docs.oasis-open.org/sarif/sarif/v2.1.0/csprd01/sarif-v2.1.0-csprd01.html#_Toc10541171 + */ +export type Suppression = { + guid?: string; + kind: SuppressionKind; + status: SuppressionStatus; + justification?: string; +}; +export type SuppressionKind = 'inSource' | 'external'; +export type SuppressionStatus = 'underReview' | 'accept' | 'rejected'; // todo accepted!? + /** * A Monokle Validation configuration file. * @@ -298,6 +312,7 @@ export type ValidationResult = { }; fingerprints?: FingerPrints; baselineState?: BaseLineState; + suppressions?: Suppression[]; /** * The location of the error. diff --git a/packages/validation/src/common/types.ts b/packages/validation/src/common/types.ts index 4c7287c9a..73af866ae 100644 --- a/packages/validation/src/common/types.ts +++ b/packages/validation/src/common/types.ts @@ -4,6 +4,7 @@ import {PluginMetadataWithConfig, RuleMetadataWithConfig} from '../types.js'; import {ResourceSchema} from '../validators/kubernetes-schema/schemaLoader.js'; import {ResourceParser} from './resourceParser.js'; import {ToolPlugin, ValidationPolicy, ValidationResult} from './sarif.js'; +import {Suppressor} from '../sarif/suppressions/types.js'; export type YamlPath = Array; @@ -173,6 +174,10 @@ export type PluginConfig = { enabled?: boolean; }; +export type ValidateOptions = { + incremental?: Incremental; +}; + export interface Plugin { /** * The name of this plugin. @@ -232,7 +237,7 @@ export interface Plugin { registerCustomSchema(schema: CustomSchema): Promise | void; unregisterCustomSchema(schema: Omit): Promise | void; - validate(resources: Resource[], incremental?: Incremental): Promise; + validate(resources: Resource[], options: ValidateOptions): Promise; clear(): Promise; unload(): Promise; diff --git a/packages/validation/src/config/parse.ts b/packages/validation/src/config/parse.ts index 6cb019258..940442fa4 100644 --- a/packages/validation/src/config/parse.ts +++ b/packages/validation/src/config/parse.ts @@ -18,6 +18,8 @@ export type ArrayRuleValue = [PrimitiveRuleValue] | [PrimitiveRuleValue, any]; export type ObjectRuleValue = {severity: PrimitiveRuleValue; config: any}; export type Settings = Record & { debug?: boolean; + noInSourceSuppressions?: boolean; + noExternalSuppressions?: boolean; }; /** diff --git a/packages/validation/src/createDefaultMonokleValidator.browser.ts b/packages/validation/src/createDefaultMonokleValidator.browser.ts index 76e249441..555eaae1a 100644 --- a/packages/validation/src/createDefaultMonokleValidator.browser.ts +++ b/packages/validation/src/createDefaultMonokleValidator.browser.ts @@ -10,12 +10,14 @@ import {MetadataValidator} from './validators/metadata/validator.js'; import {MonokleValidator} from './MonokleValidator.js'; import kbpPlugin from './validators/practices/plugin.js'; import pssPlugin from './validators/pod-security-standards/plugin.js'; +import {AnnotationSuppressor, Suppressor} from './sarif/suppressions/index.js'; export function createDefaultMonokleValidator( parser: ResourceParser = new ResourceParser(), - schemaLoader: SchemaLoader = new SchemaLoader() + schemaLoader: SchemaLoader = new SchemaLoader(), + suppressors: Suppressor[] = [new AnnotationSuppressor()] ) { - return new MonokleValidator(createDefaultPluginLoader(parser, schemaLoader)); + return new MonokleValidator(createDefaultPluginLoader(parser, schemaLoader), suppressors); } export function createDefaultPluginLoader( diff --git a/packages/validation/src/createDefaultMonokleValidator.node.ts b/packages/validation/src/createDefaultMonokleValidator.node.ts index 49e5cacba..fd054c4e2 100644 --- a/packages/validation/src/createDefaultMonokleValidator.node.ts +++ b/packages/validation/src/createDefaultMonokleValidator.node.ts @@ -14,12 +14,14 @@ import {MonokleValidator} from './MonokleValidator.js'; import {bundlePluginCode} from './utils/loadCustomPlugin.node.js'; import practicesPlugin from './validators/practices/plugin.js'; import pssPlugin from './validators/pod-security-standards/plugin.js'; +import {AnnotationSuppressor, Suppressor} from './sarif/suppressions/index.js'; export function createDefaultMonokleValidator( parser: ResourceParser = new ResourceParser(), - schemaLoader: SchemaLoader = new SchemaLoader() + schemaLoader: SchemaLoader = new SchemaLoader(), + suppressors: Suppressor[] = [new AnnotationSuppressor()] ) { - return new MonokleValidator(createDefaultPluginLoader(parser, schemaLoader)); + return new MonokleValidator(createDefaultPluginLoader(parser, schemaLoader), suppressors); } export function createDefaultPluginLoader( diff --git a/packages/validation/src/createExtensibleMonokleValidator.browser.ts b/packages/validation/src/createExtensibleMonokleValidator.browser.ts index 8fcd0c585..80918286e 100644 --- a/packages/validation/src/createExtensibleMonokleValidator.browser.ts +++ b/packages/validation/src/createExtensibleMonokleValidator.browser.ts @@ -14,6 +14,8 @@ import kbpPlugin from './validators/practices/plugin.js'; import pssPlugin from './validators/pod-security-standards/plugin.js'; import {dynamicImportCustomPluginLoader} from './pluginLoaders/dynamicImportLoader.js'; import {CUSTOM_PLUGINS_URL_BASE} from './constants.js'; +import {Suppressor} from './sarif/suppressions/types.js'; +import {AnnotationSuppressor} from './sarif/suppressions/plugins/AnnotationSuppressor.js'; /** * Creates a Monokle validator that can dynamically fetch custom plugins. @@ -21,6 +23,7 @@ import {CUSTOM_PLUGINS_URL_BASE} from './constants.js'; export function createExtensibleMonokleValidator( parser: ResourceParser = new ResourceParser(), schemaLoader: SchemaLoader = new SchemaLoader(), + suppressors: Suppressor[] = [new AnnotationSuppressor()], customPluginLoader: CustomPluginLoader = dynamicImportCustomPluginLoader ) { return new MonokleValidator(async (pluginName: string, settings?: Record) => { @@ -65,5 +68,5 @@ export function createExtensibleMonokleValidator( ); } } - }); + }, suppressors); } diff --git a/packages/validation/src/createExtensibleMonokleValidator.node.ts b/packages/validation/src/createExtensibleMonokleValidator.node.ts index 8bc257b0f..19d7a95ee 100644 --- a/packages/validation/src/createExtensibleMonokleValidator.node.ts +++ b/packages/validation/src/createExtensibleMonokleValidator.node.ts @@ -16,6 +16,7 @@ import kbpPlugin from './validators/practices/plugin.js'; import pssPlugin from './validators/pod-security-standards/plugin.js'; import {requireFromStringCustomPluginLoader} from './pluginLoaders/requireFromStringLoader.node.js'; import {CUSTOM_PLUGINS_URL_BASE} from './constants.js'; +import {AnnotationSuppressor, Suppressor} from './sarif/suppressions/index.js'; /** * Creates a Monokle validator that can dynamically fetch custom plugins. @@ -23,6 +24,7 @@ import {CUSTOM_PLUGINS_URL_BASE} from './constants.js'; export function createExtensibleMonokleValidator( parser: ResourceParser = new ResourceParser(), schemaLoader: SchemaLoader = new SchemaLoader(), + suppressors: Suppressor[] = [new AnnotationSuppressor()], customPluginLoader: CustomPluginLoader = requireFromStringCustomPluginLoader ) { return new MonokleValidator(async (pluginNameOrUrl: string, settings?: Record) => { @@ -61,7 +63,7 @@ export function createExtensibleMonokleValidator( ); } } - }); + }, suppressors); } async function getPlugin(path: string) { diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 4d17b1533..560f7e282 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -3,6 +3,7 @@ */ export * from './commonExports.js'; +export * from './sarif/suppressions/index.js'; export * from './pluginLoaders/index.js'; export * from './createExtensibleMonokleValidator.browser.js'; diff --git a/packages/validation/src/pluginLoaders/dynamicImportLoader.ts b/packages/validation/src/pluginLoaders/dynamicImportLoader.ts index 6550ecdc5..bc2fbc2eb 100644 --- a/packages/validation/src/pluginLoaders/dynamicImportLoader.ts +++ b/packages/validation/src/pluginLoaders/dynamicImportLoader.ts @@ -2,8 +2,8 @@ import {CustomPluginLoader} from '../MonokleValidator.js'; import {CUSTOM_PLUGINS_URL_BASE} from '../constants.js'; import {SimpleCustomValidator} from '../validators/custom/simpleValidator.js'; -export const dynamicImportCustomPluginLoader: CustomPluginLoader = async (pluginName, parser) => { +export const dynamicImportCustomPluginLoader: CustomPluginLoader = async (pluginName, parser, suppressor) => { const url = `${CUSTOM_PLUGINS_URL_BASE}/${pluginName}/latest.js`; const customPlugin = await import(/* @vite-ignore */ url); - return new SimpleCustomValidator(customPlugin.default, parser); + return new SimpleCustomValidator(customPlugin.default, parser, suppressor); }; diff --git a/packages/validation/src/sarif/suppressions/AnnotationSuppressor.ts b/packages/validation/src/sarif/suppressions/AnnotationSuppressor.ts new file mode 100644 index 000000000..965e3f1f6 --- /dev/null +++ b/packages/validation/src/sarif/suppressions/AnnotationSuppressor.ts @@ -0,0 +1,36 @@ +import {RuleMetadata, Suppression, ValidationResult} from '../../common/sarif'; +import {Resource} from '../../common/types'; +import {Suppressor} from './Suppressor'; + +const SUPPRESSION_ANNOTATION_PREFIX = 'suppress.monokle.io'; + +export class AnnotationSuppressor implements Suppressor { + kind: 'external' | 'inline' = 'inline'; + + suppress(problem: ValidationResult, rule: RuleMetadata, resource?: Resource): Suppression[] | Promise { + if (!resource) { + return []; + } + + const annotations = resource.content.annotations ?? {}; + const idAnnotation = `${SUPPRESSION_ANNOTATION_PREFIX}/${rule.id}`; + const nameAnnotation = `${SUPPRESSION_ANNOTATION_PREFIX}/${rule.name}`; + const fullAnnotation = `${SUPPRESSION_ANNOTATION_PREFIX}/${problem.rule.toolComponent.name}.${rule.id}`; + const value = annotations[idAnnotation] ?? annotations[fullAnnotation] ?? annotations[nameAnnotation]; + + if (!value || value === 'false') { + return []; + } + + const suppression: Suppression = { + kind: 'inSource', + status: 'accept', + }; + + if (value !== 'false' && value !== 'true') { + suppression.justification = value; + } + + return [suppression]; + } +} diff --git a/packages/validation/src/sarif/suppressions/Suppressor.ts b/packages/validation/src/sarif/suppressions/Suppressor.ts new file mode 100644 index 000000000..395108282 --- /dev/null +++ b/packages/validation/src/sarif/suppressions/Suppressor.ts @@ -0,0 +1,7 @@ +import {RuleMetadata, Suppression, ValidationResult} from '../../common/sarif'; +import {Resource} from '../../common/types'; + +export interface Suppressor { + kind: 'inline' | 'external'; + suppress(problem: ValidationResult, rule: RuleMetadata, resource?: Resource): Suppression[] | Promise; +} diff --git a/packages/validation/src/sarif/suppressions/createSuppressor.ts b/packages/validation/src/sarif/suppressions/createSuppressor.ts new file mode 100644 index 000000000..154f3a7d9 --- /dev/null +++ b/packages/validation/src/sarif/suppressions/createSuppressor.ts @@ -0,0 +1,33 @@ +import {RuleMetadata, Suppression, ValidationResult} from '../../common/sarif'; +import {AnnotationSuppressor} from './AnnotationSuppressor'; +import {Suppressor} from './Suppressor'; + +const DEFAULT_SUPPRESSORS = [new AnnotationSuppressor()]; + +type SuppressOptions = { + noInlineSuppressions?: boolean; + noExternalSuppressions?: boolean; +}; + +export function createSuppressor(suppressors: Suppressor[] = DEFAULT_SUPPRESSORS) { + return { + async suppress(problem: ValidationResult, rule: RuleMetadata, options: SuppressOptions = {}) { + const allSuppressions: Suppression[] = []; + + for (const suppressor of suppressors) { + if (options.noExternalSuppressions && suppressor.kind === 'external') { + continue; + } + if (options.noInlineSuppressions && suppressor.kind === 'inline') { + continue; + } + + const suppressions = await suppressor.suppress(problem, rule, resource); + if (!suppressions) continue; + allSuppressions.push(...suppressions); + } + + return allSuppressions; + }, + }; +} diff --git a/packages/validation/src/sarif/suppressions/engine.ts b/packages/validation/src/sarif/suppressions/engine.ts new file mode 100644 index 000000000..51d66fdf6 --- /dev/null +++ b/packages/validation/src/sarif/suppressions/engine.ts @@ -0,0 +1,64 @@ +import {Suppression, ValidationResponse} from '../../common/sarif.js'; +import {AnnotationSuppressor} from './plugins/AnnotationSuppressor.js'; +import {Suppressor} from './types.js'; +import type {Resource} from '../../common/types.js'; +import keyBy from 'lodash/keyBy.js'; + +const DEFAULT_SUPPRESSORS = [new AnnotationSuppressor()]; + +type SuppressOptions = { + noInSourceSuppressions?: boolean; + noExternalSuppressions?: boolean; +}; + +export class SuppressEngine { + constructor(private suppressors: Suppressor[] = DEFAULT_SUPPRESSORS) {} + + async preload() { + return Promise.allSettled(this.suppressors.map(s => s.preload())); + } + + private getSuppressors(options: SuppressOptions) { + return this.suppressors.filter(s => { + return s.kind === 'external' ? !options.noExternalSuppressions : !options.noInSourceSuppressions; + }); + } + + /** + * Add suppression requests to all results. + * + * @remark Execution will mutate the response and add `result.suppressions`. + * @remark If no suppressions are found, `result.suppressions` is an empty array. + */ + async suppress(response: ValidationResponse, resources: Resource[], options: SuppressOptions = {}): Promise { + const suppressors = this.getSuppressors(options); + if (!suppressors) { + return; + } + + const hasInSource = suppressors.some(s => s.kind === 'inSource'); + const resourceMap = hasInSource ? keyBy(resources, r => r.id) : {}; + + for (const run of response.runs) { + for (const problem of run.results) { + const allSuppressions: Suppression[] = []; + + for (const suppressor of suppressors) { + const suppressions = await suppressor.suppress(problem, run, { + getResource() { + const resourceId = problem.locations[1].physicalLocation?.artifactLocation.uri; + return resourceId ? resourceMap[resourceId] : undefined; + }, + getAllResources() { + return resources; + }, + }); + + allSuppressions.push(...suppressions); + } + + problem.suppressions = allSuppressions; + } + } + } +} diff --git a/packages/validation/src/sarif/suppressions/index.ts b/packages/validation/src/sarif/suppressions/index.ts new file mode 100644 index 000000000..0eb6be96c --- /dev/null +++ b/packages/validation/src/sarif/suppressions/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './plugins/AnnotationSuppressor.js'; diff --git a/packages/validation/src/sarif/suppressions/plugins/AnnotationSuppressor.ts b/packages/validation/src/sarif/suppressions/plugins/AnnotationSuppressor.ts new file mode 100644 index 000000000..005449ff6 --- /dev/null +++ b/packages/validation/src/sarif/suppressions/plugins/AnnotationSuppressor.ts @@ -0,0 +1,48 @@ +import {Suppression, SuppressionKind, ValidationResult, ValidationRun} from '../../../common/sarif.js'; +import {getPluginForResult, getRuleForResultV2} from '../../../node.js'; +import {ResourceApi, Suppressor} from '../types.js'; + +const SUPPRESSION_ANNOTATION_PREFIX = 'monokle.io/suppress'; + +export class AnnotationSuppressor implements Suppressor { + kind: SuppressionKind = 'inSource'; + + preload() { + return Promise.resolve(); + } + + suppress(problem: ValidationResult, run: ValidationRun, {getResource}: ResourceApi): Suppression[] { + const resource = getResource(); + + if (!resource) { + return []; + } + + const annotations = resource.content.metadata.annotations; + + if (!annotations) { + return []; + } + + const rule = getRuleForResultV2(run, problem); + const pluginId = rule.id.substring(0, 3).toLowerCase(); + const fullAnnotation = `${SUPPRESSION_ANNOTATION_PREFIX}.${pluginId}.${rule.name}`; + const idAnnotation = `${SUPPRESSION_ANNOTATION_PREFIX}.${rule.id}`; + const value = annotations[idAnnotation] ?? annotations[fullAnnotation] ?? annotations[idAnnotation.toLowerCase()]; + + if (!value || value === 'false') { + return []; + } + + const suppression: Suppression = { + kind: 'inSource', + status: 'accept', + }; + + if (value !== 'false' && value !== 'true') { + suppression.justification = value; + } + + return [suppression]; + } +} diff --git a/packages/validation/src/sarif/suppressions/plugins/FakeSuppressor.ts b/packages/validation/src/sarif/suppressions/plugins/FakeSuppressor.ts new file mode 100644 index 000000000..700a8869a --- /dev/null +++ b/packages/validation/src/sarif/suppressions/plugins/FakeSuppressor.ts @@ -0,0 +1,24 @@ +import {Suppression, SuppressionKind, ValidationResult} from '../../../common/sarif.js'; +import {Suppressor} from '../types.js'; + +export class FakeSuppressor implements Suppressor { + kind: SuppressionKind = 'external'; + + private _suppressions = new Set(); + + addSuppressionRequest(problem: ValidationResult) { + this._suppressions.add(problem.fingerprints?.['monokleHash/v1']); + } + + preload() { + return Promise.resolve(); + } + + suppress(problem: ValidationResult): Suppression[] { + if (!this._suppressions.has(problem.fingerprints?.['monokleHash/v1'])) { + return []; + } + + return [{kind: 'external', status: 'accept', justification: 'Suppressed by john.doe on 6th June.'}]; + } +} diff --git a/packages/validation/src/sarif/suppressions/types.ts b/packages/validation/src/sarif/suppressions/types.ts new file mode 100644 index 000000000..86826f737 --- /dev/null +++ b/packages/validation/src/sarif/suppressions/types.ts @@ -0,0 +1,13 @@ +import {Suppression, SuppressionKind, ValidationResult, ValidationRun} from '../../common/sarif'; +import {Resource} from '../../common/types'; + +export type ResourceApi = { + getResource: () => Resource | undefined; + getAllResources: () => Resource[]; +}; + +export interface Suppressor { + kind: SuppressionKind; + preload(): Promise; + suppress(problem: ValidationResult, run: ValidationRun, api: ResourceApi): Suppression[] | Promise; +} diff --git a/packages/validation/src/utils/getRule.ts b/packages/validation/src/utils/getRule.ts index 97629cef1..a16e87cb3 100644 --- a/packages/validation/src/utils/getRule.ts +++ b/packages/validation/src/utils/getRule.ts @@ -1,4 +1,11 @@ -import {RuleMetadata, Taxonomy, ValidationResponse, ValidationResult, ValidationRun} from '../common/sarif.js'; +import { + RuleMetadata, + Taxonomy, + ToolComponent, + ValidationResponse, + ValidationResult, + ValidationRun, +} from '../common/sarif.js'; import invariant from './invariant.js'; import {getResourceId} from './sarif.js'; @@ -18,14 +25,19 @@ export function getRuleForResult(response: ValidationResponse, result: Validatio } export function getRuleForResultV2(run: ValidationRun | undefined, result: ValidationResult): RuleMetadata { + const plugin = getPluginForResult(run, result); + const ruleIndex = result.rule.index; + const rule = plugin?.rules?.[ruleIndex]; + invariant(rule, 'rule not found'); + return rule as RuleMetadata; +} + +export function getPluginForResult(run: ValidationRun | undefined, result: ValidationResult): ToolComponent { const toolPluginIndex = result.rule.toolComponent.index ?? -1; const toolPluginName = result.rule.toolComponent.name; const extensions = run?.tool.extensions ?? []; const plugin = extensions[toolPluginIndex] ?? extensions.find(plugin => plugin.name === toolPluginName); - const ruleIndex = result.rule.index; - const rule = plugin?.rules[ruleIndex]; - invariant(rule, 'rule not found'); - return rule as RuleMetadata; + return plugin; } export function getTaxonomiesForRule(run: ValidationRun, rule: RuleMetadata): Taxonomy[] { diff --git a/packages/validation/src/validators/custom/devValidator.ts b/packages/validation/src/validators/custom/devValidator.ts index cf38fe993..6b9b458e6 100644 --- a/packages/validation/src/validators/custom/devValidator.ts +++ b/packages/validation/src/validators/custom/devValidator.ts @@ -1,7 +1,8 @@ import {ResourceParser} from '../../common/resourceParser.js'; import {ToolPlugin, ValidationResult, ValidationRun} from '../../common/sarif.js'; -import {CustomSchema, Incremental, Plugin, PluginMetadata, Resource} from '../../common/types.js'; +import {CustomSchema, Incremental, Plugin, PluginMetadata, Resource, ValidateOptions} from '../../common/types.js'; import {RuleMap} from '../../config/parse.js'; +import {Suppressor} from '../../sarif/suppressions/index.js'; import {PluginMetadataWithConfig, RuleMetadataWithConfig} from '../../types.js'; import {DEV_MODE_TOKEN} from './constants.js'; import {SimpleCustomValidator} from './simpleValidator.js'; @@ -31,7 +32,7 @@ export class DevCustomValidator implements Plugin { private _debug: boolean = false; protected _toolComponentIndex: number = -1; - constructor(private parser: ResourceParser) { + constructor(private parser: ResourceParser, private suppressor?: Suppressor) { this.hmr(); } @@ -62,7 +63,7 @@ export class DevCustomValidator implements Plugin { const dataUrl = `data:text/javascript;base64,${encodedSource}`; import(/* @vite-ignore */ dataUrl).then(module => { const pluginInit = module.default; - const validator = new SimpleCustomValidator(pluginInit, this.parser); + const validator = new SimpleCustomValidator(pluginInit, this.parser, this.suppressor); this._currentValidator = validator; if (this._lastConfig) { const entries = Object.entries(this._lastConfig.rules ?? {}).map(([key, value]) => { @@ -185,11 +186,11 @@ export class DevCustomValidator implements Plugin { settings: config.settings, }); } - validate(resources: Resource[], incremental?: Incremental | undefined): Promise { + validate(resources: Resource[], options: ValidateOptions): Promise { if (!this._currentValidator) { return Promise.resolve([]); } - return this._currentValidator?.validate(resources, incremental); + return this._currentValidator?.validate(resources, options); } clear(): Promise { diff --git a/packages/validation/src/validators/custom/simpleValidator.ts b/packages/validation/src/validators/custom/simpleValidator.ts index 750f2cafd..7e637268a 100644 --- a/packages/validation/src/validators/custom/simpleValidator.ts +++ b/packages/validation/src/validators/custom/simpleValidator.ts @@ -4,7 +4,7 @@ import {Document, isNode, Node, ParsedNode} from 'yaml'; import {AbstractPlugin} from '../../common/AbstractPlugin.js'; import {ResourceParser} from '../../common/resourceParser.js'; import {ValidationResult, RuleMetadata} from '../../common/sarif.js'; -import {Incremental, PluginMetadata, Resource} from '../../common/types.js'; +import {PluginMetadata, Resource, ValidateOptions} from '../../common/types.js'; import {createLocations} from '../../utils/createLocations.js'; import {isDefined} from '../../utils/isDefined.js'; import {PluginInit, ReportArgs, Resource as PlainResource, RuleInit} from './config.js'; @@ -33,15 +33,15 @@ export class SimpleCustomValidator extends AbstractPlugin { this._settings = rawSettings; } - async doValidate(resources: Resource[], incremental?: Incremental): Promise { + async doValidate(resources: Resource[], options: ValidateOptions): Promise { const results: ValidationResult[] = []; const resourceMap = keyBy(resources, r => r.id); const clonedResources: PlainResourceWithId[] = resources.map(r => JSON.parse(JSON.stringify({...r.content, _id: r.id})) ); - const dirtyResources = incremental - ? clonedResources.filter(r => incremental.resourceIds.includes(r._id)) + const dirtyResources = options.incremental + ? clonedResources.filter(r => options.incremental?.resourceIds.includes(r._id)) : clonedResources; for (const rule of this.rules) { @@ -68,7 +68,7 @@ export class SimpleCustomValidator extends AbstractPlugin { }, report: (res, args) => { const resource = resourceMap[(res as PlainResourceWithId)._id]; - const result = this.adaptToValidationResult(rule, resource, args); + const result = this.adaptToValidationResult(rule, resource, args, options); if (!result) return; results.push(result); }, @@ -104,7 +104,8 @@ export class SimpleCustomValidator extends AbstractPlugin { private adaptToValidationResult( rule: RuleMetadata, resource: Resource, - args: ReportArgs + args: ReportArgs, + options: ValidateOptions ): ValidationResult | undefined { const {parsedDoc} = this._parser.parse(resource); diff --git a/packages/validation/src/validators/kubernetes-schema/validator.ts b/packages/validation/src/validators/kubernetes-schema/validator.ts index f27681dda..6634d2c87 100644 --- a/packages/validation/src/validators/kubernetes-schema/validator.ts +++ b/packages/validation/src/validators/kubernetes-schema/validator.ts @@ -3,7 +3,7 @@ import {z} from 'zod'; import {AbstractPlugin} from '../../common/AbstractPlugin.js'; import {ResourceParser} from '../../common/resourceParser.js'; import {ValidationResult} from '../../common/sarif.js'; -import {CustomSchema, Incremental, Resource} from '../../common/types.js'; +import {CustomSchema, Incremental, Resource, ValidateOptions} from '../../common/types.js'; import {createLocations} from '../../utils/createLocations.js'; import {isDefined} from '../../utils/isDefined.js'; import {KNOWN_RESOURCE_KINDS} from '../../utils/knownResourceKinds.js'; @@ -70,7 +70,7 @@ export class KubernetesSchemaValidator extends AbstractPlugin { KNOWN_RESOURCE_KINDS.forEach(kind => this.getResourceValidator(kind)); } - async doValidate(resources: Resource[], incremental?: Incremental): Promise { + async doValidate(resources: Resource[], {incremental}: ValidateOptions): Promise { const results: ValidationResult[] = []; const dirtyResources = incremental ? resources.filter(r => incremental.resourceIds.includes(r.id)) : resources; @@ -83,7 +83,12 @@ export class KubernetesSchemaValidator extends AbstractPlugin { const deprecationError = validate(resource, this._settings.schemaVersion); if (deprecationError) { const ruleId = deprecationError.type === 'removal' ? 'K8S003' : 'K8S002'; - const asValidationError = this.adaptToValidationResult(resource, [deprecationError.path], ruleId, deprecationError.message); + const asValidationError = this.adaptToValidationResult( + resource, + [deprecationError.path], + ruleId, + deprecationError.message + ); isDefined(asValidationError) && results.push(asValidationError); } @@ -91,9 +96,9 @@ export class KubernetesSchemaValidator extends AbstractPlugin { const hasApiVersion = resource.apiVersion && resource.apiVersion.length > 0; if (resourceErrors === undefined || !hasApiVersion) { const errorKey = resource.apiVersion !== undefined ? 'apiVersion' : 'kind'; - const errorText = hasApiVersion ? - `Invalid or unsupported "apiVersion" value for "${resource.kind}".` : - `Missing "apiVersion" field for "${resource.kind}".`; + const errorText = hasApiVersion + ? `Invalid or unsupported "apiVersion" value for "${resource.kind}".` + : `Missing "apiVersion" field for "${resource.kind}".`; const validationResult = this.adaptToValidationResult(resource, [errorKey], 'K8S004', errorText); isDefined(validationResult) && results.push(validationResult); } @@ -129,12 +134,16 @@ export class KubernetesSchemaValidator extends AbstractPlugin { validate(resource.content); const errors = validate.errors ?? []; - const results = errors.map(err => this.adaptToValidationResult( - resource, - err.dataPath.substring(1).split('/'), - 'K8S001', - err.message ? `Value at ${err.dataPath} ${err.message}` : '' - )).filter(isDefined); + const results = errors + .map(err => + this.adaptToValidationResult( + resource, + err.dataPath.substring(1).split('/'), + 'K8S001', + err.message ? `Value at ${err.dataPath} ${err.message}` : '' + ) + ) + .filter(isDefined); return results; } @@ -149,7 +158,9 @@ export class KubernetesSchemaValidator extends AbstractPlugin { ): Promise { const apiVersion = (typeof resourceOrResourceKind === 'string' ? '' : resourceOrResourceKind.apiVersion) ?? ''; const kind = typeof resourceOrResourceKind === 'string' ? resourceOrResourceKind : resourceOrResourceKind.kind; - const key = matchResourceSchema(kind, apiVersion, this.definitions || []) ?? `${(resourceOrResourceKind as Resource).apiVersion}-${kind}`; + const key = + matchResourceSchema(kind, apiVersion, this.definitions || []) ?? + `${(resourceOrResourceKind as Resource).apiVersion}-${kind}`; const keyRef = `#/definitions/${key}`; try { diff --git a/packages/validation/src/validators/metadata/validator.ts b/packages/validation/src/validators/metadata/validator.ts index bf1f24d7b..92cb2de0e 100644 --- a/packages/validation/src/validators/metadata/validator.ts +++ b/packages/validation/src/validators/metadata/validator.ts @@ -3,13 +3,13 @@ import intersection from 'lodash/intersection.js'; import {AbstractPlugin} from '../../common/AbstractPlugin.js'; import {ResourceParser} from '../../common/resourceParser.js'; import {RuleConfigMetadataType, ValidationResult} from '../../common/sarif.js'; -import {Incremental, Resource} from '../../common/types.js'; +import {Resource, ValidateOptions} from '../../common/types.js'; import {METADATA_RULES} from './rules.js'; import {createLocations} from '../../utils/createLocations.js'; import {RuleMetadataWithConfig} from '../../types.js'; import {findJsonPointerNode} from '../../utils/findJsonPointerNode.js'; -import { RuleMap } from '../../config/parse.js'; -import { isDefined } from '../../utils/isDefined.js'; +import {RuleMap} from '../../config/parse.js'; +import {isDefined} from '../../utils/isDefined.js'; export class MetadataValidator extends AbstractPlugin { constructor(private resourceParser: ResourceParser) { @@ -33,44 +33,44 @@ export class MetadataValidator extends AbstractPlugin { Object.entries(rules).forEach(rule => { const ruleName = rule[0]; - if (!ruleName.startsWith('metadata/') || !ruleName.endsWith('-label') && !ruleName.endsWith('-annotation')) { + if (!ruleName.startsWith('metadata/') || (!ruleName.endsWith('-label') && !ruleName.endsWith('-annotation'))) { return; } const isLabelRule = this.isLabelRule(ruleName); const ruleTypeName = isLabelRule ? 'label' : 'annotation'; const ruleShortName = ruleName.replace('metadata/', ''); - const ruleNormalizedName = ruleShortName.replace('__', '/').replace((isLabelRule ? /-label$/ : /-annotation$/), ''); + const ruleNormalizedName = ruleShortName.replace('__', '/').replace(isLabelRule ? /-label$/ : /-annotation$/, ''); this._rules.push({ id: `MTD-${ruleShortName}`, name: ruleShortName, - shortDescription: { text: `The ${ruleNormalizedName} ${ruleTypeName} is missing.` }, + shortDescription: {text: `The ${ruleNormalizedName} ${ruleTypeName} is missing.`}, fullDescription: { - text: `The resource is violating the ${ruleShortName}. The resource may not be discoverable by tools that rely on these ${ruleTypeName}s.` + text: `The resource is violating the ${ruleShortName}. The resource may not be discoverable by tools that rely on these ${ruleTypeName}s.`, }, help: { - text: `Add required ${ruleTypeName}. You can hover the key for documentation.` + text: `Add required ${ruleTypeName}. You can hover the key for documentation.`, }, defaultConfiguration: { parameters: { name: ruleNormalizedName, dynamic: true, - } + }, }, properties: { configMetadata: { type: RuleConfigMetadataType.StringArray, - name: `Allowed ${ruleTypeName} values` + name: `Allowed ${ruleTypeName} values`, }, - } + }, }); }); this.setRules(this._rules); } - async doValidate(resources: Resource[], incremental?: Incremental): Promise { + async doValidate(resources: Resource[], {incremental}: ValidateOptions): Promise { const results: ValidationResult[] = []; const dirtyResources = incremental ? resources.filter(r => incremental.resourceIds.includes(r.id)) : resources; @@ -99,7 +99,7 @@ export class MetadataValidator extends AbstractPlugin { invalidKeys = this.validateCustomAnnotations(resource, rule); } else if (rule.name.endsWith('-label') || rule.name.endsWith('-annotation')) { invalidKeys = this.validateDynamicRule(resource, rule); - expectedValues = rule.configuration?.parameters?.configValue as string[] ?? []; + expectedValues = (rule.configuration?.parameters?.configValue as string[]) ?? []; } if (!invalidKeys.length) { @@ -109,13 +109,16 @@ export class MetadataValidator extends AbstractPlugin { const isLabelRule = this.isLabelRule(rule.name); const expectedValuesText = expectedValues.length ? `, expected values: ${expectedValues.join(', ')}` : ''; - return invalidKeys.map(key => this.adaptToValidationResult( - resource, - ['metadata', isLabelRule ? 'labels' : 'annotations'], - rule.id, - `Missing valid '${key}' ${isLabelRule ? 'label' : 'annotation'} in '${resource.kind}'${expectedValuesText}.` + return invalidKeys + .map(key => + this.adaptToValidationResult( + resource, + ['metadata', isLabelRule ? 'labels' : 'annotations'], + rule.id, + `Missing valid '${key}' ${isLabelRule ? 'label' : 'annotation'} in '${resource.kind}'${expectedValuesText}.` + ) ) - ).filter(isDefined) + .filter(isDefined); } private validateRecommendedLabels(resource: Resource, rule: RuleMetadataWithConfig) { @@ -147,15 +150,23 @@ export class MetadataValidator extends AbstractPlugin { ); } - private validateMap(actualMap: {[key: string]: any} = {}, expectedKeys: string[] = [], expectedValues: string[] = []): string[] { + private validateMap( + actualMap: {[key: string]: any} = {}, + expectedKeys: string[] = [], + expectedValues: string[] = [] + ): string[] { if (!expectedKeys.length) { return []; } const missingLabels = difference(expectedKeys, Object.keys(actualMap)); - const invalidLabels = expectedValues.length ? - Object.entries(actualMap).filter(([_, value]) => !expectedValues.includes(value)).map(([key, _]) => key): - Object.entries(actualMap).filter(([_, value]) => !value).map(([key, _]) => key); + const invalidLabels = expectedValues.length + ? Object.entries(actualMap) + .filter(([_, value]) => !expectedValues.includes(value)) + .map(([key, _]) => key) + : Object.entries(actualMap) + .filter(([_, value]) => !value) + .map(([key, _]) => key); const missingInvalidLabels = intersection(expectedKeys, invalidLabels); return [...missingLabels, ...missingInvalidLabels].sort((a, b) => a.localeCompare(b)); diff --git a/packages/validation/src/validators/open-policy-agent/validator.ts b/packages/validation/src/validators/open-policy-agent/validator.ts index bfb004da3..cb379d3e0 100644 --- a/packages/validation/src/validators/open-policy-agent/validator.ts +++ b/packages/validation/src/validators/open-policy-agent/validator.ts @@ -8,7 +8,7 @@ import {z} from 'zod'; import {AbstractPlugin} from '../../common/AbstractPlugin.js'; import {ResourceParser} from '../../common/resourceParser.js'; import {Region, ValidationResult, RuleMetadata} from '../../common/sarif.js'; -import {Incremental, Resource, YamlPath} from '../../common/types.js'; +import {Resource, ValidateOptions, YamlPath} from '../../common/types.js'; import {createLocations} from '../../utils/createLocations.js'; import {isDefined} from '../../utils/isDefined.js'; import {OPEN_POLICY_AGENT_RULES} from './rules.js'; @@ -55,7 +55,7 @@ export class OpenPolicyAgentValidator extends AbstractPlugin { this.validator = await loadPolicy(wasm); } - async doValidate(resources: Resource[], incremental?: Incremental): Promise { + async doValidate(resources: Resource[], {incremental}: ValidateOptions): Promise { const results: ValidationResult[] = []; const dirtyResources = incremental ? resources.filter(r => incremental.resourceIds.includes(r.id)) : resources; diff --git a/packages/validation/src/validators/yaml-syntax/validator.ts b/packages/validation/src/validators/yaml-syntax/validator.ts index 174e48153..ec188c2a6 100644 --- a/packages/validation/src/validators/yaml-syntax/validator.ts +++ b/packages/validation/src/validators/yaml-syntax/validator.ts @@ -2,7 +2,7 @@ import {YAMLError} from 'yaml'; import {AbstractPlugin} from '../../common/AbstractPlugin.js'; import {ResourceParser} from '../../common/resourceParser.js'; import {ValidationResult} from '../../common/sarif.js'; -import {Incremental, Resource} from '../../common/types.js'; +import {Resource, ValidateOptions} from '../../common/types.js'; import {createLocations} from '../../utils/createLocations.js'; import {isDefined} from '../../utils/isDefined.js'; import {YAML_RULES, YAML_RULE_MAP} from './rules.js'; @@ -22,7 +22,7 @@ export class YamlValidator extends AbstractPlugin { ); } - async doValidate(resources: Resource[], incremental?: Incremental): Promise { + async doValidate(resources: Resource[], {incremental}: ValidateOptions): Promise { const results: ValidationResult[] = []; const dirtyResources = incremental ? resources.filter(r => incremental.resourceIds.includes(r.id)) : resources; @@ -55,13 +55,15 @@ export class YamlValidator extends AbstractPlugin { const textWithoutLocation = err.message.split(' at line').at(0) ?? err.message; - return this.isRuleEnabled(ruleId) - ? this.createValidationResult(ruleId, { - message: { - text: textWithoutLocation, - }, - locations, - }) - : undefined; + if (!this.isRuleEnabled(ruleId)) { + return undefined; + } + + return this.createValidationResult(ruleId, { + message: { + text: textWithoutLocation, + }, + locations, + }); } }