Skip to content

Commit 659e5b0

Browse files
author
Marco Franceschi
committed
feat: Refactored RulesEngine to allow composite rules
1 parent 6ff9f9f commit 659e5b0

File tree

9 files changed

+254
-172
lines changed

9 files changed

+254
-172
lines changed

src/plugins/policyPack/index.ts

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,55 @@ export default class PolicyPackPlugin extends Plugin {
130130
}
131131
}
132132

133+
private async executeRule({
134+
rules,
135+
policyPack,
136+
storageEngine,
137+
}: {
138+
rules: Rule[]
139+
policyPack: string
140+
storageEngine: StorageEngine
141+
}): Promise<RuleFinding[]> {
142+
const findings: RuleFinding[] = []
143+
144+
// Run rules:
145+
for (const rule of rules) {
146+
try {
147+
if (rule.queries?.length > 0) {
148+
const { queries, ...ruleMetadata } = rule
149+
const subRules = queries.map(q => ({
150+
...q,
151+
...ruleMetadata,
152+
}))
153+
154+
findings.push(
155+
...(await this.executeRule({
156+
rules: subRules,
157+
policyPack,
158+
storageEngine,
159+
}))
160+
)
161+
} else {
162+
const { data } = rule.gql
163+
? await storageEngine.query(rule.gql)
164+
: { data: undefined }
165+
const results = (await this.policyPacksPlugins[
166+
policyPack
167+
]?.engine?.processRule(rule, data)) as RuleFinding[]
168+
169+
findings.push(...results)
170+
}
171+
} catch (error) {
172+
this.logger.error(
173+
`Error processing rule ${rule.id} for ${policyPack} policy pack`
174+
)
175+
this.logger.debug(error)
176+
}
177+
}
178+
179+
return findings
180+
}
181+
133182
async configure(
134183
pluginManager: PluginManager,
135184
plugins: ConfiguredPlugin[]
@@ -239,26 +288,11 @@ export default class PolicyPackPlugin extends Plugin {
239288
mergeSchemas(currentSchema, findingsSchema),
240289
])
241290

242-
const findings: RuleFinding[] = []
243-
244-
// Run rules:
245-
for (const rule of rules) {
246-
try {
247-
const { data } = rule.gql
248-
? await storageEngine.query(rule.gql)
249-
: { data: undefined }
250-
const results = (await this.policyPacksPlugins[
251-
policyPack
252-
]?.engine?.processRule(rule, data)) as RuleFinding[]
253-
254-
findings.push(...results)
255-
} catch (error) {
256-
this.logger.error(
257-
`Error processing rule ${rule.id} for ${policyPack} policy pack`
258-
)
259-
this.logger.debug(error)
260-
}
261-
}
291+
const findings = await this.executeRule({
292+
rules,
293+
policyPack,
294+
storageEngine,
295+
})
262296

263297
// Prepare mutations
264298
const mutations = engine?.prepareMutations(findings)
@@ -279,7 +313,6 @@ export default class PolicyPackPlugin extends Plugin {
279313
)
280314
this.logger.successSpinner('success')
281315

282-
// TODO: Use table to display results
283316
const results = findings.filter(
284317
finding => finding.result === Result.FAIL
285318
)

src/rules-engine/evaluators/composite-evaluator.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

src/rules-engine/evaluators/js-evaluator.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import groupBy from 'lodash/groupBy'
2+
import isEmpty from 'lodash/isEmpty'
3+
import { Entity } from '../../types'
14
import {
25
JsRule,
36
ResourceData,
@@ -9,6 +12,17 @@ import {
912
import { RuleEvaluator } from './rule-evaluator'
1013

1114
export default class JsEvaluator implements RuleEvaluator<JsRule> {
15+
private readonly findings: RuleFinding[] = []
16+
17+
private readonly providerName
18+
19+
private readonly entityName
20+
21+
constructor(providerName: string, entityName: string) {
22+
this.entityName = entityName
23+
this.providerName = providerName
24+
}
25+
1226
canEvaluate(rule: Rule | JsRule): boolean {
1327
return 'check' in rule
1428
}
@@ -32,4 +46,53 @@ export default class JsEvaluator implements RuleEvaluator<JsRule> {
3246

3347
return finding
3448
}
49+
50+
// TODO: Share logic with the JSON evaluator
51+
prepareMutations(): Entity[] {
52+
const mutations = []
53+
54+
// Group Findings by schema type
55+
const findingsByType = groupBy(this.findings, 'typename')
56+
57+
for (const findingType in findingsByType) {
58+
if (!isEmpty(findingType)) {
59+
// Group Findings by resource
60+
const findingsByResource = groupBy(
61+
findingsByType[findingType],
62+
'resourceId'
63+
)
64+
65+
for (const resource in findingsByResource) {
66+
if (resource) {
67+
const data = (
68+
(findingsByResource[resource] as RuleFinding[]) || []
69+
).map(({ typename, ...properties }) => properties)
70+
71+
// Create dynamically update mutations by resource
72+
const updateMutation = {
73+
name: `${this.providerName}${this.entityName}Findings`,
74+
mutation: `mutation update${findingType}($input: Update${findingType}Input!) {
75+
update${findingType}(input: $input) {
76+
numUids
77+
}
78+
}
79+
`,
80+
data: {
81+
filter: {
82+
id: { eq: resource },
83+
},
84+
set: {
85+
[`${this.entityName}Findings`]: data,
86+
},
87+
},
88+
}
89+
90+
mutations.push(updateMutation)
91+
}
92+
}
93+
}
94+
}
95+
96+
return mutations
97+
}
3598
}

src/rules-engine/evaluators/json-evaluator.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import lodash from 'lodash'
1+
import lodash, { groupBy, isEmpty } from 'lodash'
22
import * as jqNode from 'node-jq'
33
import {
44
Condition,
@@ -13,8 +13,20 @@ import {
1313
import { RuleEvaluator } from './rule-evaluator'
1414
import AdditionalOperators from '../operators'
1515
import ComparisonOperators from '../operators/comparison'
16+
import { Entity } from '../../types'
1617

1718
export default class JsonEvaluator implements RuleEvaluator<JsonRule> {
19+
private readonly findings: RuleFinding[] = []
20+
21+
private readonly providerName
22+
23+
private readonly entityName
24+
25+
constructor(providerName: string, entityName: string) {
26+
this.entityName = entityName
27+
this.providerName = providerName
28+
}
29+
1830
canEvaluate(rule: JsonRule): boolean {
1931
return 'conditions' in rule
2032
}
@@ -35,6 +47,7 @@ export default class JsonEvaluator implements RuleEvaluator<JsonRule> {
3547
typename: data.resource?.__typename, // eslint-disable-line no-underscore-dangle
3648
rule: ruleMetadata,
3749
} as RuleFinding
50+
this.findings.push(finding)
3851

3952
return finding
4053
}
@@ -174,4 +187,52 @@ export default class JsonEvaluator implements RuleEvaluator<JsonRule> {
174187
return data
175188
}
176189
}
190+
191+
prepareMutations(): Entity[] {
192+
const mutations = []
193+
194+
// Group Findings by schema type
195+
const findingsByType = groupBy(this.findings, 'typename')
196+
197+
for (const findingType in findingsByType) {
198+
if (!isEmpty(findingType)) {
199+
// Group Findings by resource
200+
const findingsByResource = groupBy(
201+
findingsByType[findingType],
202+
'resourceId'
203+
)
204+
205+
for (const resource in findingsByResource) {
206+
if (resource) {
207+
const data = (
208+
(findingsByResource[resource] as RuleFinding[]) || []
209+
).map(({ typename, ...properties }) => properties)
210+
211+
// Create dynamically update mutations by resource
212+
const updateMutation = {
213+
name: `${this.providerName}${this.entityName}Findings`,
214+
mutation: `mutation update${findingType}($input: Update${findingType}Input!) {
215+
update${findingType}(input: $input) {
216+
numUids
217+
}
218+
}
219+
`,
220+
data: {
221+
filter: {
222+
id: { eq: resource },
223+
},
224+
set: {
225+
[`${this.entityName}Findings`]: data,
226+
},
227+
},
228+
}
229+
230+
mutations.push(updateMutation)
231+
}
232+
}
233+
}
234+
}
235+
236+
return mutations
237+
}
177238
}
Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,50 @@
1+
import { Entity } from '../../types'
12
import { JsonRule, Result, Rule, RuleFinding } from '../types'
23
import { RuleEvaluator } from './rule-evaluator'
34

45
export default class ManualEvaluator implements RuleEvaluator<JsonRule> {
6+
private readonly findings: RuleFinding[] = []
7+
8+
private readonly providerName
9+
10+
private readonly entityName
11+
12+
constructor(providerName: string, entityName: string) {
13+
this.entityName = entityName
14+
this.providerName = providerName
15+
}
16+
517
canEvaluate(rule: Rule): boolean {
6-
return (
7-
!('gql' in rule) &&
8-
!('conditions' in rule) &&
9-
!('resource' in rule) &&
10-
!('relatedRules' in rule)
11-
)
18+
return !('gql' in rule) && !('conditions' in rule) && !('resource' in rule)
1219
}
1320

1421
async evaluateSingleResource(rule: Rule): Promise<RuleFinding> {
15-
return {
22+
const finding = {
1623
id: `${rule.id}/manual`,
1724
result: Result.SKIPPED,
1825
typename: 'manual',
1926
rule,
2027
} as RuleFinding
28+
this.findings.push(finding)
29+
return finding
30+
}
31+
32+
prepareMutations(): Entity[] {
33+
const manualFindings = this.findings.map(({ typename, ...finding }) => ({
34+
...finding,
35+
}))
36+
return [
37+
{
38+
name: `${this.providerName}${this.entityName}Findings`,
39+
mutation: `
40+
mutation($input: [Add${this.providerName}${this.entityName}FindingsInput!]!) {
41+
add${this.providerName}${this.entityName}Findings(input: $input, upsert: true) {
42+
numUids
43+
}
44+
}
45+
`,
46+
data: manualFindings,
47+
},
48+
]
2149
}
2250
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { Entity } from '../../types'
12
import { ResourceData, Rule, RuleFinding } from '../types'
23

34
export interface RuleEvaluator<K extends Rule> {
45
canEvaluate: (rule: K) => boolean
56
evaluateSingleResource: (rule: K, data?: ResourceData) => Promise<RuleFinding>
6-
// @TODO complex rules can take a query and return an array of resourceId + Result
7+
prepareMutations: () => Entity[]
78
}

0 commit comments

Comments
 (0)