What problem does this solve?
Two coupled problems, one fix:
- The scanner only persists failing findings.
scanner/engine.py::run_scan() collects a flat
findings list — there is no record of which rules ran clean, which errored, or which
resources were never evaluated.
- As a direct consequence,
api/models/finding.py::get_compliance_score() computes control
status as status = "FAIL" if rule_id in failed_rule_ids else "PASS" — any rule that errored,
whose resource was never observed, or that never ran is counted as PASS by default. This
inflates every reported compliance score.
Both block every downstream reasoning-layer feature (drift history, campaign detection,
attack-path confidence) and the eight already-open "enterprise rules" issues (#253, #255, #257,
#258, #259, #261, #262, plus closed #256/#260) that already assume a PASS/FAIL/UNKNOWN/
NOT_APPLICABLE contract exists, with no shared engine support for it.
PR #250 ("Azure Resource Graph inventory foundation") already lands Phase 1 of the underlying
evidence model — normalised, tenant-scoped resource snapshots with complete/partial/failed
collection state — but explicitly does not persist rule-outcome data. This issue is Phase 2: the
rule-outcome side, built on top of #250's snapshots.
Describe the solution
- New table (e.g.
rule_evaluations) keyed on (scan_id, rule_id, resource_id) with a status
enum: PASS / FAIL / ERROR / NOT_APPLICABLE, plus a free-text reason for ERROR/NOT_APPLICABLE.
ScanEngine.run_scan() writes an evaluation row for every resource a rule was applicable to,
not just the ones that failed.
- Update
get_compliance_score() to compute PASS only from persisted successful evaluations in
this table — remove the absence-implies-PASS default entirely.
- Document the contract in
docs/adding-a-rule.md so new rules (including the 8 open enterprise
issues) target it directly instead of inventing ad hoc indeterminate handling (see
scanner/rules/az_cmp_002.py's own LOW-severity indeterminate workaround for what happens
without a shared model).
Alternatives considered
Ship the schema and the compliance-score fix as two separate PRs — rejected; the fix has no
correct implementation without the schema existing first, and reviewing them apart just means
merging a known-broken intermediate state.
Additional context
Depends on: PR #250 (merge first). Blocks: issue 2 below, and all eight open enterprise-rules
issues. Exit test: a forced API error or unseen resource never appears as PASS.
Coordination
This issue is the shared evaluation-contract foundation for the open enterprise-rule packs. It
does not duplicate them — it provides the engine support (per-resource PASS / FAIL / ERROR /
NOT_APPLICABLE) they assume but do not themselves build. It explicitly blocks and should be
linked from: #253, #254, #255, #257, #258, #259, #261, #262. Enterprise-rule PRs should consume
this persisted evaluation contract instead of re-implementing ad hoc indeterminate handling.
What problem does this solve?
Two coupled problems, one fix:
scanner/engine.py::run_scan()collects a flatfindingslist — there is no record of which rules ran clean, which errored, or whichresources were never evaluated.
api/models/finding.py::get_compliance_score()computes controlstatus as
status = "FAIL" if rule_id in failed_rule_ids else "PASS"— any rule that errored,whose resource was never observed, or that never ran is counted as PASS by default. This
inflates every reported compliance score.
Both block every downstream reasoning-layer feature (drift history, campaign detection,
attack-path confidence) and the eight already-open "enterprise rules" issues (#253, #255, #257,
#258, #259, #261, #262, plus closed #256/#260) that already assume a PASS/FAIL/UNKNOWN/
NOT_APPLICABLE contract exists, with no shared engine support for it.
PR #250 ("Azure Resource Graph inventory foundation") already lands Phase 1 of the underlying
evidence model — normalised, tenant-scoped resource snapshots with complete/partial/failed
collection state — but explicitly does not persist rule-outcome data. This issue is Phase 2: the
rule-outcome side, built on top of #250's snapshots.
Describe the solution
rule_evaluations) keyed on (scan_id, rule_id, resource_id) with astatusenum: PASS / FAIL / ERROR / NOT_APPLICABLE, plus a free-text reason for ERROR/NOT_APPLICABLE.
ScanEngine.run_scan()writes an evaluation row for every resource a rule was applicable to,not just the ones that failed.
get_compliance_score()to compute PASS only from persisted successful evaluations inthis table — remove the absence-implies-PASS default entirely.
docs/adding-a-rule.mdso new rules (including the 8 open enterpriseissues) target it directly instead of inventing ad hoc indeterminate handling (see
scanner/rules/az_cmp_002.py's own LOW-severity indeterminate workaround for what happenswithout a shared model).
Alternatives considered
Ship the schema and the compliance-score fix as two separate PRs — rejected; the fix has no
correct implementation without the schema existing first, and reviewing them apart just means
merging a known-broken intermediate state.
Additional context
Depends on: PR #250 (merge first). Blocks: issue 2 below, and all eight open enterprise-rules
issues. Exit test: a forced API error or unseen resource never appears as PASS.
Coordination
This issue is the shared evaluation-contract foundation for the open enterprise-rule packs. It
does not duplicate them — it provides the engine support (per-resource PASS / FAIL / ERROR /
NOT_APPLICABLE) they assume but do not themselves build. It explicitly blocks and should be
linked from: #253, #254, #255, #257, #258, #259, #261, #262. Enterprise-rule PRs should consume
this persisted evaluation contract instead of re-implementing ad hoc indeterminate handling.