Skip to content

fix(controls): compensating controls were impossible to create and inert - #404

Merged
0xmanhnv merged 1 commit into
developfrom
fix/compensating-controls-usable
Aug 3, 2026
Merged

fix(controls): compensating controls were impossible to create and inert#404
0xmanhnv merged 1 commit into
developfrom
fix/compensating-controls-usable

Conversation

@0xmanhnv

@0xmanhnv 0xmanhnv commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

The defect

compensating_controls has 0 rows on every tenant, including tenants where the module is released and enabled. The feature was unreachable and, had it been reachable, inert. Four independent faults, each sufficient on its own.

1. Create could never succeed — and failed dishonestly

Create put control_type, status and reduction_factor straight into the INSERT with no validation beyond a non-empty name. The live CHECK constraints allow segmentation | identity | runtime | detection | other; the create form offered preventive | detective | corrective | compensatingzero overlap. A constraint violation then fell into the generic error arm and became 500 {"message":"internal error"}, so a plain client input mistake looked like a server fault.

Validation now mirrors the CHECKs and returns 400 with the allowed values. The allowlist is sourced from pkg/domain/compensatingcontrol, which already carried exactly the right vocabulary as typed constants and had zero importers — the handler is raw SQL and bypassed it entirely.

2. Even a valid create returned 500

Found while writing the integration test. Create/Update/RecordTest scanned their own RETURNING clause into plain string fields, but description, test_result, test_evidence and created_by are all NULL on a freshly inserted row:

sql: Scan error on column index 7: converting NULL to string is unsupported

The error is raised after the INSERT has already committed — the caller got a 500 while the control really had been created. List/Get were already null-safe; the three write paths were not. All five read sites now go through one scanControl.

3. Controls could not affect priority on the path control changes actually drive

This is why the feature would still have been a note even once creatable.

Linking an asset to a control publishes a reclassify sweep, and that sweep runs:

LinkAssets → ControlChangePublisher → Reclassifier.reclassifyAsset → ClassifyFinding

ClassifyFinding never consulted the control lookup. Only EnrichAndClassifyBatch and the read-only ExplainFinding did. So the B2 fan-out — machinery built specifically so control changes reflect in priority — fed the one classifier that could not see controls. ExplainFinding would report "Compensating controls present (reduction: 30%)" on a finding that ClassifyFinding had just written as P1; the explain endpoint and the live classifier disagreed. The inline copy in priority_explanation.go even carried the comment "same as the live classify path", which was false.

All three paths now go through one shared helper, so a fourth path cannot silently diverge.

4. A failed test left the control reporting itself as active

RecordTest with test_result='fail' left status='active'. Scoring survived only because the effective-control SQL separately excludes test_result='fail', but the API response was misleading. It now deactivates, matching the domain rule in compensatingcontrol.RecordTest. Deactivation is deliberately one-way — a later passing test does not silently re-activate a control an operator disabled for other reasons. An empty or unknown test_result (which violated the test_result CHECK and 500'd) is now a 400.

The reduction_factor unit

Kept as a 0–1 fraction on the wire, because that is what the column stores: DECIMAL(3,2) CHECK (>= 0 AND <= 1). The UI change (openctemio/ui#TBD) keeps a percent-labelled field and divides by 100 at the API boundary — integer percents map exactly onto the storable set at scale 2, so the conversion is lossless and the wire contract is unchanged.

A 0 factor is now rejected at create. The column defaults to 0.0 and the classifier only marks an asset protected when the factor is > 0, so a control saved without one was accepted and then did nothing.

Scope note — this does change classification, narrowly

Item 3 changes classification outcomes, but only for assets an operator has deliberately linked to an effective control, and there are currently zero such controls anywhere. It applies the existing binary IsProtected rule on a path that was missing it.

It does not make reduction_factor band the outcome. The factor is still presentational — a 0.05 control and a 0.95 control both produce P2, and the value only reaches a reason string. Making it band the result would change risk scoring for every tenant and deserves its own change and its own argument.

Verification

Check Result
go build ./... exit 0
go vet ./... exit 0
go test ./... exit 0 — 93 packages ok, 0 failures
make lint-ci (golangci-lint v1.64.8 + vet + staticcheck) clean

Both new tests were proven red against the unfixed code:

  • removing the applyControlProtection call from ClassifyFindingClassifyFinding must consult the compensating control lookup
  • reverting Create to the name-only check → the legacy payload reaches db.QueryRowContext instead of being rejected

The integration tests run against the real schema and assert the CHECK constraints directly, so the Go allowlist cannot drift from the database without a test failing.

Compensating controls had 0 rows on every tenant because the feature was
broken end to end, in four independent places.

1. Create could never succeed. control_type/status/reduction_factor went
   straight into the INSERT unvalidated, so a value outside the CHECK
   constraints surfaced as a 500 with "internal error" instead of a 400
   saying what was wrong. Validation now mirrors the CHECKs, sourced from
   the pkg/domain/compensatingcontrol constants (which already carried the
   correct vocabulary and had zero importers).

2. Even a VALID create returned 500. Create/Update/RecordTest scanned their
   own RETURNING clause into plain strings, but description, test_result,
   test_evidence and created_by are NULL on a fresh row — "converting NULL
   to string is unsupported", raised AFTER the INSERT committed. The caller
   saw a failure while the control really had been created. All five read
   sites now share one null-safe scanControl.

3. Compensating controls could not affect priority on the path that control
   changes actually drive. Linking an asset publishes a reclassify sweep
   (LinkAssets -> Reclassifier.reclassifyAsset -> ClassifyFinding), but
   ClassifyFinding never consulted the control lookup — only the batch and
   explain paths did. The fan-out built to make control changes move
   priority fed a classifier that could not see controls. The lookup is now
   applied through one shared helper used by all three paths.

4. RecordTest with test_result='fail' left status='active', so the API
   reported a failed control as active (scoring was saved only by the
   effective-control SQL separately excluding failures). It now deactivates,
   matching the domain rule. An empty/unknown test_result is a 400, not a
   500.

reduction_factor stays a 0-1 fraction on the wire — it is what the column
(DECIMAL(3,2), CHECK 0..1) stores. A 0 factor is now rejected at create:
the classifier only treats an asset as protected when the factor is > 0, so
a control saved with the column default would have been a silent no-op.

Note on scope: item 3 changes classification outcomes, but only for assets
an operator has deliberately linked to an effective control. It applies the
existing binary IsProtected rule on a path that was missing it; it does not
make reduction_factor band the outcome. The factor remains presentational
(a 0.05 and a 0.95 control both yield P2) — banding would change scoring for
every tenant and needs its own change and its own argument.
@0xmanhnv
0xmanhnv merged commit 5d9423a into develop Aug 3, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant