# Compliance reference

> Frameworks, the agreed comparison table, your own requirements, the 22 controls, finding families, finding to incident, risk decisions, attestations, the scanner pull, scores and audit.

_Reference · Updated 2026-10-28 · Describes release 0.1.164_

Exact facts about the compliance pillar. The procedures are in [Turn on compliance](/help/setup-compliance.md) and [Security posture](/help/security-posture.md); who may approve a risk decision or release a compliance fix is owned by [Approval policy and change tiers](/help/approval-policy.md#risk-and-compliance).

## Frameworks and requirements {#frameworks}

Two frameworks ship in full, as paraphrased requirement rows (never the standard's own text): 154 rows — 61 SOC 2 Trust Services Criteria and 93 ISO/IEC 27001:2022 Annex A controls. A requirement with no automated check mapped onto it is shown as a manual-attestation row, never a silent gap and never a fake pass.

| Framework key | Name | Grouping | In scope by default |
|---|---|---|---|
| `soc2` | SOC 2 (Trust Services Criteria) | Categories `security` (Common Criteria), `availability`, `confidentiality`, `processing_integrity`, `privacy` | `security`, `availability`, `confidentiality` — privacy and processing integrity stay visible in the catalogue but out of the score |
| `iso27001` | ISO/IEC 27001:2022 | Themes `organizational` (A.5), `people` (A.6), `physical` (A.7), `technological` (A.8) | All |

Scope lives in `tenant.settings['compliance']`:

| Key | Meaning |
|---|---|
| `enabled` | `true` or `false`, read only while the platform offers SOC 2 / ISO 27001 at all: they are phase 2, and the platform switch `AIOPS_COMPLIANCE_FRAMEWORKS` is off by default, which reads as `false` in every workspace — existing ones included — whatever `enabled` holds, hides **Compliance frameworks (phase 2)** in **Guardrails** and refuses `compliance_enabled: true` with `409`. On the upgrade that turned it off, the control plane's first scheduler tick resolves the open `control:<id>` finding of every platform check, with the scope-change note below. Your platform administrator turns it on once the frameworks are agreed with you. Once it is on: a new workspace starts with `false and is switched on at onboarding, once the customer has accepted the comparison table ([Agreed comparison table](#agreed-table)); a workspace created before this setting existed has no value, which reads as `true`. With `false` the workspace scores no framework: no control runs on a scan or after a captured change — EST-01 to EST-06 and the platform's own alarms (LOG-01, CRY-01, ACC-04, MON-01) included — no control finding opens, the posture lists no requirement and **Audit & coverage** has no **Frameworks** tab. Security Hub and Defender findings keep arriving. Set it with **Score this workspace against SOC 2 and ISO 27001** under **Compliance frameworks (phase 2)** in **Guardrails**, or `compliance_enabled` on `PATCH …/guardrails` (`members.manage`, `null` = no value, read as on). Any change that takes a check out of the scan scope — this switch, a framework dropped from `frameworks`, a control disabled — resolves that check's open `control:<id>` finding with the note *This check no longer runs in this workspace* (audit `incident.resolved`, `by: scope-change`), except one awaiting approval or with a fix in flight; the response carries `closed_control_incidents`. |
| `platform_controls` | `false` (default) or `true`. The 13 `deterministic` checks of the platform's own settings (ACC-02 to ACC-04, LOG-01, DAT-01, DAT-02, CRY-01, CHG-01 to CHG-03, SEG-01, MON-01, MON-02) read nothing in your estate, so they stay out of the score unless this is `true`; their requirements fall back to attestation. They still run on every scan either way: a failure opens its control finding and a later pass closes it, so the platform's own alarms (an audit-trail break in LOG-01, plaintext secrets in CRY-01) keep working. ACC-01 reads your connector credentials and stays in. Set it with **Count the checks of the platform's own settings** or `compliance_platform_controls` on `PATCH …/guardrails` |
| `frameworks` | `["soc2", "iso27001"]` — a control is active when any of its mapped frameworks is in scope |
| `soc2_categories` | The SOC 2 categories the posture scores |
| `custom_requirements` | The workspace's own requirement rows (at most 100), written only through `PUT …/compliance/custom-requirements/{ref}` — see [Your own requirements](#custom-requirements) |
| `overrides` | `{control_id: {"disabled": true | "not_applicable": true, "reason": "…"}}` — `disabled` drops the control from scans; `not_applicable` keeps it and records the reason as its evidence (`PATCH …/compliance/controls/{id}`, `pillars.manage`, audit `compliance.control_override`) |

The mapping from requirement to check is this platform's interpretation; the interpretation sheet (`POST …/compliance/interpretation-export`, audit `compliance.interpretation_exported`) renders every control's stated basis for the customer's security or legal team to approve. Passing scans do not by themselves constitute SOC 2 or ISO 27001 compliance; every unmapped requirement needs human attestation and auditor-accepted evidence.

## Agreed comparison table {#agreed-table}

The comparison table is the version of this mapping the customer accepts before onboarding: when the posture says compliant, it means compliant against that accepted version. It lists every requirement row, in scope or not, then the workspace's own rows. Under **Agreed comparison table** on the Frameworks page: **Download table (CSV)**, **Download table (Markdown)** and **Record acceptance**.

| Column | Meaning |
|---|---|
| Framework, Ref, Requirement | The catalogue row (our paraphrase) or one of your own |
| In scope | `yes` / `no` under the workspace's `frameworks` and `soc2_categories` scope; the table is the same whether scoring is switched on or not |
| Judged by | What the posture scores the row with: `platform rules`, `scanner controls`, both, or `attestation` |
| Platform rules | The catalogue checks in scope that score the row (the platform's own checks only with `platform_controls`) |
| Scanner controls | The Security Hub controls those checks mirror, each with the AWS Config rule it runs (from the Security Hub CSPM controls reference); for your own row, the controls you named |
| AWS Config rules | The AWS Config managed rules (source identifiers) AWS Audit Manager's published data-source mapping puts behind the row: the SSAE-18 SOC 2 framework for SOC 2, the ISO/IEC 27001:2013 Annex A framework for ISO 27001, each 2013 control renumbered to its 2022 control by the ISO/IEC 27002:2022 Annex B correspondence (`AWS Config basis` names the 2013 controls) |
| Azure Policy | The group of the built-in Regulatory Compliance initiative: `SOC 2 Type 2` (`SOC_2_<ref>`, every SOC 2 row) or `ISO/IEC 27002 2022` (`ISO_IEC_27002_2022_<n>`, 23 ISO rows) |
| Note | What the platform reads for the row, and why the rest is evidenced by a person |

A cloud column is blank where no public mapping names a rule. AWS publishes no sample conformance pack for ISO 27001 or SOC 2 (a customer may author its own), and Security Hub has no ISO 27001 or SOC 2 standard; Audit Manager is closed to new customers, so its mapping is a reference. The scanner, AWS Config and Azure Policy columns of a catalogue row are not scored by the platform: they name the rules that evidence the same requirement in your cloud.

| Fact | Value |
|---|---|
| Export | `GET …/compliance/agreed-table` (`report.view`), `fmt` = `json` (rows, sources, `sha256`, the agreement), `csv` or `md`; the file name and the `X-Table-SHA256` header carry the version |
| Version | `sha256` over every column of every row and the scope: a scope switch, a platform rule override, your own row or a product upgrade that changes a row changes it |
| Record the acceptance | `POST …/compliance/agreed-table/accept` (`pillars.manage`) with `sha256` (the version the customer saw), `accepted_by` (who accepted: name, role, organisation), `accepted_on` (default today, never in the future) and an optional `note`; 409 when the table changed since that version |
| What is kept | The table is frozen as a `report` artifact (`source_id` `agreed-table`, meta `version`, `table_sha256`, `accepted_by`, `accepted_on`) and an attestation under `compliance:agreed-table` cites it, for 1,095 days |
| Supersede, never edit | A new acceptance (`v<n+1>`) expires the previous one; the trail is append-only |
| Audit | `compliance.table_agreed` with `version`, `table_sha256`, `accepted_by`, `accepted_on` |
| On the posture | `GET …/compliance/posture` → `agreement` (`version`, `accepted_by`, `accepted_on`, `current`, `lapsed`, `artifact_id`); the page reads *Scored against agreed table v2 (accepted by … on …)*, *No agreed table yet* without one, says the table has changed since when `current` is false, and that it lapsed after 1,095 days |
| On the Scores tab | `GET …/compliance/reports` → `agreed` (a current, unlapsed acceptance); without one, each SOC 2 / ISO 27001 row on **Score per standard** carries a *not agreed* badge: its score follows this platform's mapping, not a table the customer accepted. Turning **Compliance frameworks (phase 2)** on (once the platform offers it) does not wait for an agreement — the badge stays until one is recorded. |

## Your own requirements {#custom-requirements}

A workspace may add requirements of its own, such as "highly available: every applicable resource runs in at least two Availability Zones". Each row is judged by platform rules and/or scanner controls, or by an attestation when it names neither; it joins the comparison table (and changes its version) and the posture as the `custom` framework, shown as **Workspace requirements**. Add or remove them under **Your own requirements** in the **Agreed comparison table** card (**Save requirement**, **Remove**).

| Fact | Value |
|---|---|
| Endpoint | `PUT …/compliance/custom-requirements/{ref}` adds or replaces one, `DELETE` removes it (`pillars.manage`; audit `compliance.custom_requirement_saved` / `compliance.custom_requirement_removed`) |
| `ref` | Your own id: letters first, then letters or digits, parts joined by `-` or `.` (`HA-01`, `POL.7`); never a SOC 2 or ISO 27001 ref |
| `title`, `description` | What it asks (up to 200 characters) and how it is read (up to 1,000) |
| `checks` | Catalogue control ids (`EST-01`); `DRIFT-01`, an AI judgment, is refused. A check counts only while it is in scope |
| `scanner_controls` | Control ids a scanner reports: Security Hub (`ELB.13`, `RDS.5`) or a Defender assessment |
| Scanner control status | `fail` = an open finding no approved suppression covers; suppressed (`accepted`) = every open one is covered, never a pass; `pass` = the scanner reported it and cleared it; `unknown` = the scanner never reported it. The pull reads failing findings, so a control that never failed reads unknown |
| Row status | As a catalogue row: any fail fails it, all pass passes it, else unknown, else suppressed; a row with neither checks nor scanner controls takes an attestation (`POST …/compliance/requirements/{ref}/attest`) |
| Scope | Scored only while `enabled` is on; listed in the comparison table either way |

## Automated controls {#controls}

22 controls, each mapped many-to-many onto requirement refs. Check kinds in cost order — the order a scan evaluates them: `deterministic` (platform rows only, keyless), `script` (a direct read-only probe through the workspace's own connectors), `lake` (SQL over collected telemetry; no control uses it today), `agent` (one bounded run, the model residue — at most 5 per scan). Severity sets the compliance incident's severity on a fail: `high` → `error`, `medium` → `warning`, `low` → `info`.

| Id | Title | Kind | Severity | What the check does | SOC 2 | ISO 27001 | Mirrors |
|---|---|---|---|---|---|---|---|
| ACC-01 | Least-privilege connector credentials | script | high | Runs the permission audit over every enabled read-role connection; fails when any credential reports writes it could perform | CC6.1, CC6.3 | A.5.15, A.8.2 | — |
| ACC-02 | Platform role separation | deterministic | medium | Fails when more than one member exists and all hold the owner role | CC6.1, CC6.3 | A.5.15, A.5.18 | — |
| ACC-03 | Centralised identity (SSO) | deterministic | medium | Passes only when an enabled workspace IdP exists | CC6.1 | A.5.16, A.5.17 | — |
| ACC-04 | Support access is time-bound | deterministic | high | Fails when any active support grant has no expiry | CC6.1 | A.5.18, A.8.2 | — |
| LOG-01 | Tamper-evident audit trail | deterministic | medium | Newest audit event age; replays the last 500 rows of the sha256 audit chain; fails on a broken link or more than 5,000 unsealed rows; unknown with no event yet | CC7.2 | A.8.15 | CloudTrail.4 / CIS 3.2 (applied to the platform trail) |
| DAT-01 | Sensitive-data masking at capture | deterministic | medium | Evidences the always-on PII scrub floor plus the workspace's own patterns; always passes | CC6.7 | A.8.11 | — |
| DAT-02 | No unresolved sensitive-data exposure | deterministic | high | Fails while any `pii:*` compliance incident is active | CC6.7, CC7.4 | A.5.34, A.8.11 | — |
| CRY-01 | Stored secrets encrypted at rest | deterministic | high | Passes only when the deployment sets the secret-store key (Fernet); fails on the plaintext fallback | CC6.1 | A.8.24 | — |
| CHG-01 | Changes require documented approval | deterministic | high | Evidences the intrinsic write gate and samples the 20 most recent approval decisions; always passes | CC8.1 | A.8.32 | — |
| CHG-02 | Executed changes recorded with outcome | deterministic | medium | Over the executed platform changes of the last 30 days (newest 500): counts those with a real before-state capture; `method: none` counts as missing; evidence carries `{changes, with_prestate, pct}` and the uncovered CHG ids | CC8.1 | A.8.32 | — |
| CHG-03 | High-risk changes are window-gated | deterministic | low | Fails when an enabled live-infrastructure route classifies at L2/L3 without `params.change_window` | CC8.1 | A.8.32 | — |
| SEG-01 | Read/write credential segregation | deterministic | high | Fails when an enabled live-infrastructure route's connection is not `role: write` | CC6.1, CC8.1 | A.5.15, A.8.2 | — |
| MON-01 | Alert pipeline is live | deterministic | medium | Fails when no webhook token is set, no alert ever arrived, or the newest alert is older than 7 days | CC7.2 | A.8.16 | — |
| MON-02 | Monitoring coverage for enabled pillars | deterministic | low | Fails when an enabled pillar has a data category with no connected source | CC7.2 | A.8.16 | — |
| EST-01 | Backups enabled with retention | script | high | Managed databases have automated backups with retention > 0; every non-terminated VM is in a backup plan (AWS Backup / Recovery Services) | A1.2 | A.8.13 | RDS.11 |
| EST-02 | No publicly exposed storage | script | high | No bucket or container is world-readable or world-listable | CC6.6 | A.5.23, A.8.20 | S3.1, S3.8 / CIS 2.1.4 |
| EST-03 | No world-open security groups | script | high | No security-group / NSG rule opens a sensitive port (remote admin, database, cache, search, HDFS, Docker daemon, or all ports) to `/0`; a world-open 443 is out of scope | CC6.6 | A.8.20, A.8.22 | EC2.13, EC2.14, EC2.19 / CIS 5.2 |
| EST-04 | Encryption at rest on data services | script | medium | At-rest encryption on managed databases and block volumes | CC6.1, CC6.7 | A.8.24 | RDS.3, EC2.3, EC2.7 / CIS 2.2.1 |
| EST-05 | Cloud audit logging enabled | script | medium | The account records control-plane activity (CloudTrail / activity log diagnostic setting) | CC7.2 | A.8.15 | CloudTrail.1 |
| EST-06 | Cloud IAM hygiene | script | high | AWS: IAM users with a console password and no MFA device, and active access keys older than 90 days — the first 50 IAM users only (more are noted and withhold a pass); Azure: more than 3 Owner assignments made directly at subscription scope. Root-account use, `*:*` policies and unused credentials are not read | CC6.1, CC6.2, CC6.3 | A.5.16, A.5.18, A.8.2 | IAM.5, IAM.3 / CIS 1.10, 1.14 |
| GIT-01 | Protected default branches | script | medium | The connected repositories' default branches require reviews | CC8.1 | A.8.4, A.8.32 | — |
| DRIFT-01 | GitOps state matches the cluster | agent | medium | Out-of-sync or drifted applications, with the agent's interpretation. A model's verdict, not a deterministic rule, so it scores no requirement; it still runs while compliance is on | — | — | — |

EST-01 to EST-06 read AWS and Azure through the workspace's own connectors (GCP: probe pending — the control reports `unknown` with a connect hint). A control whose data category is not connected reports `unknown`, never a fail. The "Mirrors" column names the AWS Foundational Security Best Practices / CIS AWS Foundations control the basis prose cites; it is also the join between a scanner finding's control (`S3.1` fails) and the platform control it evidences (EST-02).

## How a scan runs {#scan-engine}

| Fact | Value |
|---|---|
| Triggers | `manual` (`POST …/compliance/scan`, `incidents.act`, 202; `409` while no check is in scope — SOC 2 / ISO 27001 off — with *No compliance check runs in this workspace*), `schedule` (the compliance cron), `change` (a captured change re-ran one control's probe — see [Change-triggered checks](#change-triggered)) |
| Order | Controls in scope, cheapest kind first; an `agent` check runs only while the scan's agent budget (5) holds, unless an operator requested that control explicitly |
| Result statuses | `pass`, `fail`, `unknown` (not evaluable: nothing connected, a withheld read), `not_applicable` (operator override), `accepted` (every offender covered by a live exception) |
| Evidence | One artifact per control (the evidence spec in the catalogue) plus one scan report artifact; the scan's `totals` and `cost_usd` (agent residue only) |
| A fail | Opens or updates the `control:<id>` compliance incident with the control's severity; a later pass resolves it; an exception accepted after the scan shows on the posture immediately (`effective_status`) and moves the incident at the next probe |
| Concurrency | One running scan per workspace; a scan that stops heart-beating is swept as failed after 6 hours |
| Audit | `compliance.scan_started`, `compliance.scan_finished` (`totals`, `cost_usd`) |

A `control:<id>` incident (the platform's own probe) and a `service=control:<id>` incident (a scanner finding) are analysed on the **incident** pillar when it is enabled — only that pillar carries the wrap-up contract, the planning block and the write capabilities; the compliance pillar's own run ends as a generic wrap-up with no plan.

## Schedule {#schedule}

| Fact | Value |
|---|---|
| Where | The compliance pillar's `schedule_cron` on the Agents page (5-field cron, UTC) |
| What fires | A `ComplianceScan` with `trigger: schedule` — the same scan as the Scan now button |
| Marker | One audit row `pillar.scheduled` per fire, target = the pillar agent; the next due time is computed from the last marker (or the row's update time), so a tick that was missed is not caught up in a burst |
| Skipped when | A scan is still running, or the previous scheduled run is still active. With no check in scope (SOC 2 / ISO 27001 off) the fire files no scan and no empty report: its marker carries `skipped: no check in scope` |
| Pulled findings | A separate cadence: `aiops_scanner_sync_interval_s` (see [Pulling findings](#scanner-pull)) |

## Finding families {#finding-families}

A scanner finding is classified offline and deterministically — the model is never asked what a control means, so the same finding always gets the same stance, which is what makes "may this auto-remediate?" auditable. Match order: an exact control id (or id prefix) a family lists; then an id contained by a listed sibling (a guessed family never hands out the `auto` stance); then the product token; then title / description patterns; then the scanner product; else `other`.

| Family | Stance | Tier | Method | Resource types | Listed controls (examples) | What escalates it |
|---|---|---|---|---|---|---|
| `logging-disabled` | auto | L1 | config-change | logging, s3, rds, container, network | CloudTrail.1/2/4/5, Config.1, CloudWatch.1, S3.9, RDS.9, EKS.8, ECS.12, ELB.5, APIGateway.1, WAFv2.1 | internet-facing or sensitive data → L2 |
| `missing-tags` | auto | L1 | config-change | ec2, s3, rds, container, other | Config.2, EKS.6/7, GuardDuty.2/3/4 | → L2 |
| `encryption-at-rest` | human | L2 | config-change | s3, ebs, rds, container, other | S3.4/5, EC2.7, RDS.3/4, EFS.1, DynamoDB.3, SQS.1, SNS.1, EKS.1 | → L3 |
| `public-exposure` | human | L3 | block | s3, network, rds, lambda, container, ebs, other | S3.1/2/3/8, EC2.2/13/14/15/18/19, RDS.2, Elasticsearch.1, Redshift.1, EMR.1, Lambda.1, EKS.2, ECS.2 | already L3 |
| `iam-hygiene` | human | L2 | policy | iam, container | IAM.1–9, IAM.21/22, ECS.8 | → L3 |
| `instance-metadata` | human | L2 | config-change | ec2 | EC2.8 | → L3 |
| `detective-service-disabled` | human | L2 | config-change | other | GuardDuty.1, GuardDuty.5–13, Inspector.1–4, Macie.1 | → L3 |
| `vulnerable-package` | human | L2 | patch | ec2, container, lambda | Inspector findings, ECR.1 | → L3; CVSS ≥ 9 on an internet-facing resource → severity `critical` |
| `unsupported-version` | human | L3 | patch | rds, container, lambda, other | RDS.13, EKS.3, ElastiCache.2, Lambda.2, ECS.10 | already L3 |
| `backup-missing` | auto | L2 | config-change | rds, ebs, other | RDS.11, DynamoDB.2, ElastiCache.1, Redshift.3, Backup.1, EC2.28; not capacity scaling (DynamoDB.1), deletion protection (Cognito.6, RDS.8) nor copying tags to backups (FSx.1/2) | → L3, and the stance becomes `human` |
| `threat-detected` | ticket | L3 | block | ec2, iam, container, other | GuardDuty | already L3; routed to the SecOps pillar |
| `certificate-hygiene` | human | L2 | policy | network | ACM.1/2 | → L3 |
| `policy-rule` | human | L2 | policy | other, container | ECR.2/3; any native AWS Config rule no other family recognises | → L3 |
| `other` (default) | human | L2 | accept-only | other | — | → L3 |

A `vulnerable-package` finding that lists its packages reaches the analysis with each package's installed and fixed version (the `vulnerable_packages` annotation, refreshed by every re-send). The analysis only drafts the fix: its steps are carried into the change request, where the remediation is reviewed, approved and released — nothing in the run changes the estate. The scanner clearing the finding is the plan's verification, never an extra step, and a package with no fixed version gets a compensating control instead of an upgrade.

**What the analysis is told, per resource type** — one section per type the findings name, each once, in this order:

| Resource | Steps | Rollback |
|---|---|---|
| Container image (ECR, any registry) | With a Git repository connection holding the Dockerfile or manifest: a repository edit that upgrades each package (its pull request is the change). Without one: upgrade in the image build, then rebuild and push under a **new** tag — never overwrite a tag or digest. Consumers (ECS, Kubernetes, Lambda) are listed for information only; no step redeploys or restarts them. The old digest is retired once nothing runs it, and its finding clears then. | The previous tag and digest stay in the registry. |
| EC2 instance (AWS) | AMI first; check what depends on each package; isolate with a quarantine security group that keeps only outbound 443 (and 80 where the package mirrors are plain HTTP); `AWS-RunPatchBaseline` with `Operation=Install` through Systems Manager (an operator command for now); put the security groups back; validate the package versions and the service endpoint. No hotfix, no blue/green. | Restore the root volume from the AMI, then the security groups. |
| Lambda function (AWS) | Publish a version of the code in use; upgrade the package and publish the new version; validate a test invocation, `Errors` and `Throttles`; point the alias at the new version. | Point the alias back — this covers only callers that use the alias. Systems Manager automation for Lambda is not assumed (to confirm). |

Any other resource type upgrades each package through its own package manager or build. When the image's repository, owner or consumers are not evidenced, the plan names the read permission or connection that would supply them instead of guessing.

Every family carries a `prevention` hint (the guardrail that keeps it closed) and an `action` sentence. `method` is the kind of fix that closes the family: `patch`, `config-change`, `policy`, `block`, or `accept-only` where the platform holds no fix path at all. Resource types are the coarse buckets coverage is reported on: `ec2`, `container`, `lambda`, `s3`, `ebs`, `rds`, `network`, `iam`, `logging`, `other`.

Typed fixes: a few exact controls map to one compiled posture operation — EC2.2/13/14/18/19 → `aws.security_group.revoke_ingress`; S3.1 → `aws.s3control.put_public_access_block`; IAM.7 and IAM.10–17 → `aws.iam.update_account_password_policy`; EC2.8 → `aws.ec2.require_imdsv2`; the Defender assessment "Storage account public access should be disallowed" → `azure.storage_account.disallow_blob_public_access`. A finding with a typed fix carries the `remediation_operation` label; the fix is still human-released (stance and tier stay the family's).

## Remediation stance and escalation {#stance}

| `remediation_stance` | Meaning |
|---|---|
| `auto` | Eligible for a pre-approved fix a person releases — low blast radius, reversible, no data path touched. Not an unattended fix: a finding is fixed only after a person approves it, never by a route or a certified step runbook on its own (`route.match_skipped`, reason `finding_lane`) |
| `human` | Propose a fix; a person releases it (the default) |
| `ticket` | No safe automated fix — raise it and let an owner plan |

Escalation is monotonic (rules only raise, never lower; a marker records that they ran once):

| Signal | Source | Effect |
|---|---|---|
| `exposure: internet` | Resource details and tags (public-access keys), or `settings['risk_context'].internet_patterns` (ARN globs) | Tier up one step (L1→L2→L3) |
| `data_sensitivity: sensitive` | `settings['risk_context'].sensitive_patterns` | Tier up one step |
| CVSS ≥ 9 and internet-facing | ASFF `Vulnerabilities[]` | Severity raised to `critical` |
| Tier lands L3 with stance `auto` | — | Stance demoted to `human` (autonomy never widens on escalation) |
| `env` | Resource tags, else `settings['risk_context'].env_map` | Carried on the finding; a production label raises the change tier at release |

`settings['risk_context']` also has `internal_patterns` (fills `exposure: internal` only when nothing derived internet). A declaration can fill an unknown or raise exposure to internet; it can never talk a derived internet-facing resource back down. `settings['workspace_scope'].tags` (up to 10 tags, case-insensitive, all-of) limits a workspace to its own tag slice of a shared account; a scoped workspace treats an untagged resource as out of scope.

The deterministic risk block a person reads before deciding (`risk`): `factors[]` (internet-facing / internal-only, sensitive data, env, CVSS with fix availability, CVE count, the escalations), a one-line `summary`, `exposure`, `data_sensitivity`, `env`, `cvss`, `epss`, `fix_available` (`YES` / `NO` / `PARTIAL`), `prevention`, `method`, `resource_types`.

## From a scanner finding to an incident {#finding-to-incident}

Security Hub (ASFF) and Defender for Cloud findings arrive pushed (`/webhook/securityhub/<slug>/<token>` and `/webhook/defender/<slug>/<token>`, usually behind SNS or an action group) or pulled by the scanner sync. Both go through the same normaliser and land as alerts:

| Alert label | Value |
|---|---|
| `alertname` | The control id (else the title) |
| `service` | `control:<id>` — the failure domain is the control, so twelve buckets failing S3.9 are one piece of work; `cloud-posture` when the finding names no control |
| `severity` | ASFF `CRITICAL` → `critical` (P1); `HIGH` → `error` (P2); `MEDIUM` / `LOW` → `warning`; `INFORMATIONAL` → `info`; Defender's `properties.metadata.severity` goes through the same map. In 0.1.143 and earlier `HIGH` was `critical`, so HIGH and CRITICAL were both P1; a register row takes the new severity the next time the scanner sends the finding; an incident already open keeps its priority (severity only ever rises on an open incident). A CVSS 9 or higher finding on an internet-facing resource is still raised to `critical` |
| `source` | `securityhub` or `defender` |
| `finding_family`, `remediation_stance`, `remediation_tier`, `rule_kind` (`detective`) | From the classification above |
| `account`, `region`, `resource`, `resource_type`, `product`, `fix_available`, `remediation_operation` | When known |

Pillar: `threat-detected` → SecOps; every other family → Compliance (an alert whose severity is literally `security` also goes to SecOps). Correlation key `service=control:<id>`, so a new resource failing the same control joins the open incident; the scanner's own `RESOLVED` / `PASSED` state is the clear. The `CloudFinding` register row (`source`, `finding_key`, `control`, `family`, `stance`, `tier`, CVE columns, context, `risk`, `status` `open` / `cleared`, `seen_count`, `suppressed_count`, `incident_id`, the raw ASFF kept scrubbed) is upserted on the same ingest.

Priority is derived from severity, never by a model: `critical` → P1, `error` → P2, `warning` → P3, `info` → P4; one level up (floor P1) when the blast radius reaches two services, one more for a `mission-critical` resource. Severity is the maximum of the correlated alerts and upgrades on join.

Pushed versus pulled:

| Arrival | What runs |
|---|---|
| Pushed (webhook), compliance pillar | The priority rule of pulled: P1/P2 is analysed automatically, P3/P4 waits for a person — also when a prepared fix exists. Nothing else of the pulled gate applies: `aiops_compliance_auto_triage` does not hold it, with the incident pillar disabled it runs on the compliance pillar, and a run the kill switch or budget refuses fails and escalates. No count folds findings together any more (0.1.147): a held finding's hold stays on its own alert and incident, and a group made only of held findings, split from a storm incident an earlier release left open, opens held |
| Pushed (webhook), SecOps (`threat-detected`) | The normal analysis, under the usual budget and pillar rules |
| Pulled (scanner sync) | The row opens either way; the paid run is **held** at P3/P4, a prepared fix or not; at P1/P2 it runs when the finding carries a typed fix (`remediation_operation`) **or** automatic P1/P2 analysis is on — and in both cases only when `aiops_compliance_auto_triage` is on (platform default on), the kill switch and budgets allow a run, and the incident pillar is enabled |
| A platform check fails (`control:<id>`) | A check of your estate or connections (not `deterministic`) at P1/P2 is analysed under the same switches; at P3/P4 (a `medium` or `low` check) it waits for a person, with the reason "its priority is below P2 (automatic analysis covers P1/P2)" |

Hold reasons shown on the incident: "no prepared fix for this finding type, and its priority is below P2 (automatic analysis covers P1/P2)"; "… and automatic P1/P2 analysis is off for this workspace"; "a prepared fix is available, but its priority is below P2 (automatic analysis covers P1/P2)" — the incident then reads *A prepared fix is available — waiting for a person to start it*, and Analyse starts the analysis that drafts it (no paid run for a P3/P4 until a person asks, 22-Oct); "automatic analysis is off, paused or over budget". A held finding is analysed with the Analyse button. The switches:

| Switch | Default | Scope |
|---|---|---|
| `aiops_auto_investigate_p1p2` | on (platform default; a superadmin may change it, a workspace's own `true` / `false` wins) | A P1/P2 finding — pulled, pushed to the compliance pillar, or a failing platform check — with no prepared fix gets its run when it opens |
| `aiops_auto_investigate_catchup` | off | The scheduler sweeps held `service=control:<id>` P1/P2 incidents that never got a run: oldest first, 3 per workspace per tick, at least 60 s old, never a second run |
| `aiops_compliance_auto_triage` | on | Whether a compliance finding may start a run at all |

A re-send the scanner observed before the fix recorded on the row (`remediated_at`) is stale and does not reopen; for Defender only within 48 hours of the fix.

## Pulling findings from Security Hub and Defender {#scanner-pull}

Security Hub and Defender findings can also be pulled instead of pushed: a workspace owner sets how often under **Security posture → Posture → Scanner sync → Pull from scanners automatically** (every 15 minutes by default; off, or every 5 minutes up to daily — through the API, `scanner_sync_interval_s` in seconds, `300` to `86400` or `0` = off, on `PATCH /api/aiops/{tenant_id}/guardrails`, stored as the workspace setting `aiops_scanner_sync_interval_s`, `null` = back to the platform default), the platform then reads them on that schedule through your read AWS / Azure connection, and **Sync now** on the same card pulls the last 24 hours on demand. A pull only reads the scanners: a finding that is already open stays on its incident, so re-reading it opens nothing and costs no model time, and a Security Hub or Defender re-send observed before a recorded fix is skipped (for Defender, an assessment whose verdict last changed before the fix, and only within 48 hours of the fix). Security Hub must be enabled in each region on the connection — "Security Hub is not enabled on <connection>" means it is not, not that the credential is wrong. On Azure the subscription needs Defender for Cloud, that is the `Microsoft.Security` resource provider registered: without it Azure lists no assessments at all, and the sync reads "Defender for Cloud is not enabled on <connection>" rather than 0 findings. Pull and webhook share the same deduplication, so using both is safe. With several AWS accounts, each read connection asks Security Hub for its own account's clears; a connection into the workspace's only AWS account — for example the Security Hub administrator account — asks for every account's. Only connections that run **Security findings sync** count: several of them into one account count once, and an access-key connection counts as an account of its own until a passing Test confirms which account it is in. A pull asks about up to 500 open findings at a time; with more open, each pull asks the next 500 and the line says so, for example *500 of 1240 open findings re-checked with Security Hub, the rest at the next syncs* — or *press Sync now again for the rest* while the scheduled pull is off, since nothing else asks. Defender returns every assessment in one listing, so all its open findings are checked on each pull. A pull paces its reads to at most about three a second (Security Hub's limit for reading findings) and asks a throttled read again after 1, 2 and 4 seconds; the connection's line then says *throttled, retried*. A Security Hub region still throttled after that reads `partial (rate limited)` while the other regions land; when every region, or the Defender list, stays throttled, the line reads *rate limited, retried at the next sync*, or *rate limited — press Sync now again in a minute* while the scheduled pull is off. **Sync now** needs `pillars.manage`, so for anyone without it those two lines say *a workspace owner can press Sync now again* instead. A **Sync now** across many accounts can outlast the request: the card then says *Still running on the server* and shows the report when the sync finishes.

| Fact | Value |
|---|---|
| Connections | Every enabled read-role `aws` (Security Hub) and `azure` (Defender for Cloud) connection; per-connection opt-out `sync_findings: false` |
| Interval | `aiops_scanner_sync_interval_s`: platform default 900 (every 15 minutes); `settings['aiops_scanner_sync_interval_s']` changes it per workspace (`0` = off): minimum 300 s (5 minutes) through **Scanner sync** or `PATCH /guardrails`, and a smaller value written straight into the setting still runs at most every 60 s; the manual sync ignores it. The schedule does not follow the workspace's SOC 2 / ISO 27001 switch: the cloud's own scanners are pulled either way |
| Window | Since the connection's last clean tick minus a 20-minute overlap (the register dedupes by finding id), or one interval plus the overlap back |
| Cap | 500 failing findings per connection per sync; beyond it the report says `truncated` and the cursor does not move |
| Regions (AWS) | The connection's `scope.regions`, else its `region`, else `us-east-1`; a region where the hub is off is reported `unavailable` and skipped; a denied or throttled region keeps the cursor |
| Security Hub read 1 | `GetFindings` with `RecordState = ACTIVE`, `ComplianceStatus ∈ {FAILED, WARNING}`, `WorkflowStatus ∈ {NEW, NOTIFIED}`, `UpdatedAt` in the window |
| Security Hub read 2 | The register's open rows re-read by `Id` (20 per request); the ones the scanner now calls cleared land as clears, however old |
| `pull_vulnerabilities` (on unless `false`) | Adds a read between them: `ProductName = Inspector` under the same window with no compliance filter (an Inspector CVE has no Compliance block, so read 1 never sees it); shares the cap, deduped by id. On by default; `false`, `0`, `no` or `off` on the connection stops it — every HIGH/CRITICAL CVE lands P1/P2 and may start a paid run, so set `daily_cost_limit_usd` before a large account's first pull |
| `finding_tag_scope: key=value` (AWS) | Read 1 adds Security Hub's `ResourceTags` filter (`key` EQUALS `value`): only findings on resources carrying that tag land — for an account shared with workloads the workspace must not analyse. The Inspector read is filtered after it is read, because an ECR image or Lambda finding carries no resource tags: a finding with tags of its own lands when they carry `key=value`; one without lands when the harvested inventory holds its resource (an image's repository, a Lambda version's function) with that tag; anything else is left out and counted as `vulnerabilities_out_of_scope` in the region's coverage entry (at most 5,000 Inspector findings are read per region and pull, then the region reads as partial). Read 2 stays unfiltered, so a finding already in the register still gets its clear. Two register clean-ups ride every pull, each through the same clear as the scanner's own, so their incidents resolve: an open finding read 2 asked in its own region that Security Hub answers in no region (a wiped account, a finding Security Hub deleted) clears as no longer reported by the scanner (not fixed) once it was last seen over 48 hours ago — only when every region was read in full, and never for a finding from a region the connection does not read; for the connection into the workspace's only AWS account, which asks about every account's findings, a finding of another account clears this way only when the same pull returned a finding of that account, otherwise only once it was last seen over 90 days ago (how long Security Hub keeps a finding), since a webhook-fed account the connection cannot read answers nothing either — with audit `compliance.finding_gone`; and while every AWS connection that pulls has a tag scope, an open finding whose resource the harvested inventory holds without any of their tags, and that no pull has re-sent for 48 hours, clears as out of scope (not fixed), with audit `compliance.scanner_out_of_scope`. A finding on a resource the inventory does not know stays open; a tag none of the harvested resources carries clears nothing. One `key=value` pair (key up to 128, value up to 256 characters); anything else fails the connection before any read with `invalid_tag_scope`, never a wider pull. Each region's coverage entry shows `tag_scope`, and the **Scanner sync** card's line for the region reads *only resources tagged key=value*. The findings the tag leaves out never reach the platform, so no count of them exists: the card says so, and `scoped_out` (in the response and the `ingest.batch` audit row) counts only the workspace tag scope — the audit row carries it only when that scope ran. Whether Security Hub carries the resource tags a finding needs depends on the source: if a scoped pull lands no finding you expect, check the finding's `Resources[].Tags` in Security Hub. The webhook (`/webhook/securityhub/<slug>`) is not filtered: scope it in the EventBridge rule that forwards the findings. The same tag (exact value) also scopes the platform's backup read — its findings and the **Backup** coverage figure ([Score per standard, patching and backup](#scores)) — the synthetic checks suggested from the connection's harvest and the resources **Built-in detection** watches |
| Defender | The assessments list per subscription (`$expand=metadata`): `Unhealthy` rows whose status changed in the window or that the register has never seen, plus `Healthy` / `NotApplicable` rows the register holds open (the clears); ids lower-cased; failures and clears each capped at 500; a subscription without the `Microsoft.Security` provider is reported as not enabled |
| Pace | 0.35 s between reads, 3 retries with back-off on throttling |
| Manual sync | `POST …/findings/sync` (`pillars.manage`), default the last 1,440 minutes; its result lands under its own marker |
| Markers | `compliance.scanner_synced` (the tick — drives the cursor), `compliance.scanner_synced_manual` (never moves it); `GET …/findings/sync` shows the reports |

## Risk decisions {#risk-decisions}

The agent prepares (finding + deterministic risk impact); a human decides; the register records it. A decision is a **request** first.

| Field | Values |
|---|---|
| `outcome` | `accept` — **Suppress** in the portal (register against the person, suppress re-sends until expiry, resolve the finding's alerts), `avoid` — **Fix** in the portal (the risk is not taken: a change request is opened and the change agent drafts the fix), `mitigate` — no longer offered in the portal; the API still takes it and older decisions keep it (the finding stands, a compensating control offsets it: a change request framed "do NOT fix the finding itself", suppression with expiry) |
| `scope` | `finding` or `control` (covers every resource failing that control) |
| `status` | `requested` → `approved` or `rejected` |
| `risk_statement` | Mandatory — the register records why, not just who |
| `expires_at` | Mandatory for `accept` and `mitigate`: default `aiops_risk_accept_days` (90) / `aiops_risk_mitigate_days` (180); must be in the future |
| `decided_by`, `decided_at` | The requester |
| `approved_by`, `approved_at` | The approver (also stamped on a rejection) |
| `revoked_at` | Set when a newer decision for the same scope supersedes it, or on an early revoke |
| `change_id`, `mitigation_landed_at` | The change request an avoid / mitigate opened and when its fix landed (`risk.mitigation_landed`) |
| `expired_at` | Stamped by the expiry sweep |
| `meta.source_receipt`, `meta.source_revoke_receipt` | The scanner-side suppression receipt |

Rules:

| Rule | Detail |
|---|---|
| Who requests | `incidents.act` or `approvals.decide` — `POST …/findings/{id}/decision` with `{outcome, risk_statement, scope, expires_at, suppress_at_source}` (201, Needs you `risk_request`) |
| Who approves, rejects, revokes | `approvals.decide` — `POST …/risk-decisions/{id}/approve`, `…/reject` (with a note), `…/revoke` |
| Self-approval | Refused while another member holds `approvals.decide`; a sole approver may, audited `self_approved`; `aiops_risk_no_self_approval: true` refuses it always |
| An expired request | Cannot be approved — send a new one |
| Supersede | A new approved decision for the same scope stamps `revoked_at` on the prior row (append-only, like the terms register) |
| Effect of `accept` | The finding's open alerts resolve (also an escalated or awaiting-approval incident — the acceptance is the human's decision); re-sends are swallowed and counted on `suppressed_count`, never silently; `accept` wins a tie with `mitigate` |
| Effect of `avoid` / `mitigate` | `change.open_change(source: compliance, origin_type: finding)` and the plan runner drafts the plan after commit; no suppression for avoid |
| Readers | See approved rows only |
| Expiry | By default no cron: an expired acceptance stops suppressing and the scanner's next re-send reopens the incident — that is the re-review. With `aiops_risk_expiry_sweep: true` the scheduler acts at expiry: stamps `expired_at`, reopens the finding's incident for a person, re-fires the quieted alerts, lifts a source-side suppression, records the posture drop (100 decisions per workspace per tick; audit `risk.expired`, actor `system:risk-expiry`) |
| Suppress at source | Opt-in `settings['aiops_risk_suppress_at_source']` and `suppress_at_source: true` on an accept: after approval the platform writes Security Hub workflow `SUPPRESSED` (the decider's email in `Note.UpdatedBy`) or an Azure Policy exemption through the **write-role** connection of the same account; best effort, receipt in `meta.source_receipt`; revoke, supersede and expiry lift it; audit `risk.suppressed_at_source` / `risk.unsuppressed_at_source` |
| Reports | `GET …/risk-register` and `POST …/risk-register/export` (`report.view`; audit `risk.register_exported`) |

Control exceptions: for the estate controls EST-01 to EST-06 a person accepts the risk on some of a control's offenders (`POST …/compliance/controls/{id}/exceptions`, same requester rule): one anchor `CloudFinding` per (control, resource) with `source: compliance`, then an ordinary accept request — supersede, default expiry, audit and the register come with it. Only resources the platform showed in the latest evidence can be accepted; a control with no per-resource evidence is marked not applicable instead. An exception decided after the scan shows on the posture at once; the control's incident moves at the next probe.

## Attestations and overrides {#attestations}

| Fact | Value |
|---|---|
| Endpoint | `POST …/compliance/requirements/{ref}/attest` (`pillars.manage`) |
| `status` | `attested` or `not_applicable` |
| `note` | Mandatory — say what evidences this (stored up to 2,000 characters) |
| `evidence_artifact_id` | Optional; must be an artifact of this workspace |
| `expires_in_days` | 1–1,095; the attestation lapses after it |
| Supersede, never edit | A new attestation expires the previous live one for the same ref; the trail is append-only |
| Audit | `compliance.attested` with `status`, the note's first 200 characters, the artifact, `expires_in_days` |
| Control overrides | `PATCH …/compliance/controls/{id}` with `disabled` or `not_applicable` and a reason (`pillars.manage`; audit `compliance.control_override`) |
| Request a fix | `POST …/compliance/controls/{id}/request-change` (`incidents.act`): a change request through the change hub (audit `compliance.change_requested`); compliance holds no write path |

## Mute list (legacy) {#mute-list}

`settings['aiops_compliance_muted']` — `[{control_id, resource, note, by, at}]`, at most 200 entries — mutes a (control, resource) pair for change-triggered checks: a live match is skipped before the probe (no probe, no result, no incident) but never silently — one `compliance.rule_muted_hit` audit row per pair per tick. An entry stops muting `aiops_risk_accept_days` after its `at` (the acceptance clock); only `POST …/incidents/muted-rules/unmute` (`approvals.decide`) removes one (audit `compliance.rule_unmuted`). Nothing writes the list any more: an accepted exception replaced "Suppress this finding".

## Change-triggered checks {#change-triggered}

Every harvested cloud change is judged, deterministic first:

| Step | What happens |
|---|---|
| 1. Rules | Pure predicates over the recorded event (provider, operation, request parameters or property diff) name a control — an opened security-group or NSG rule → EST-03, a public bucket or anonymous blob access → EST-02, audit trail or diagnostic setting removed → EST-05, encryption off → EST-04, an IAM / owner change → EST-06, a backup deleted → EST-01. A rule may be liberal: a false positive costs one keyless probe, never an incident |
| 2. Probe | The match is stamped on the change (`plan.compliance`, audit `change.compliance_flagged`); the control's own probe re-runs once and a fail lands exactly like a scan: `ComplianceScan(trigger: change)` + a `change-triggered` control result + the `control:<id>` incident with the triggering change ids on `change_refs`. The incident says what changed: the rule's sentence and one line of the request's key values (`plan.compliance.values`, already scrubbed), shown under **Changes that triggered this check** and on the change. Change scans never become "the latest scan" for the posture page |
| 3. Agent rung (off) | `aiops_change_compliance_agent: true` — one bounded turn per tick nominates violations among risky-looking events no rule matched (at most 20); a nomination walks the same probe-then-incident path and never opens an incident by itself |
| Re-break link | A captured write that puts back what a platform fix cleared within 14 days is linked both ways (`change.reverts`, `change.undone_by_external`; `reverts` / `undone_by` on the rows) |
| Mapped recheck | `aiops_change_recheck_mapped: true` — a change a rule mapped to a control also re-checks that control's open incident |
| Scanner link | Scope-free, no switch: the cloud's scanner is the re-check. An unmanaged captured write on a scanner control finding's resource (or, Azure, a child of it) in the 24 h before the finding started failing (`CloudFinding.failing_since`: when the scanner saw the failure — Defender's status change, Security Hub's observation, or for an account's first pull its first-observed time) and after the scanner last passed it lands on the finding incident's `change_refs` and on the change as `plan.recheck.results.<control>` with `by` (the scanner), audited `incident.change_linked` / `change.finding_linked` — from whichever side arrives second: the finding at ingest, or the write at the next harvest (`findings_linked` in the `change.evaluated` report). Webhook-reported, refused and platform writes never link, nor do vulnerability or threat findings, findings on a whole subscription, resource group or account, or a tag-only write outside a tagging control |
| Marker | `change.evaluated` once per workspace per harvest tick |

## Posture scores {#posture-scores}

Two different numbers, two different questions.

| Score | Endpoint | Covers | How |
|---|---|---|---|
| Compliance posture | `GET …/compliance/posture` (`report.view`) | The **full requirement catalogue** in scope, mapped from the latest completed scan (not a change scan), merged with live attestations and overrides | Per requirement, from its mapped controls' live statuses: `fail` if any fails; `pass` only if all pass; else `unknown`; else `accepted_exception` (never a pass); else `not_applicable`. A requirement with no scanned control takes its attestation (`attested` / `not_applicable`) or is `manual_pending`. The totals and the coverage statement ("Automated scans cover only the machine-checkable subset …") |
| Findings score | `GET …/findings/score` (`incidents.view`), the posture page's trend and cards | **Scanner findings** (Security Hub, Defender) — optionally one `account` or `source`, optionally the platform checks per account | `score` = cleared **or** accepted weight over total weight (100 when there are no findings); `cleared_score` = scanner clearance only (null without findings); weight = severity × fix tier: `critical` 8, `high` and `error` (a scanner HIGH) 5, `medium` 2, `low` / `info` / `warning` 1, times L1 ×1, L2 ×2, L3 ×3; counts `total`, `open`, `cleared`, `accepted`, `actively_suppressed`, `awaiting_scanner`, `new`, `resolved` |

The findings trend: the last 30 days by default (`days`, or an inclusive `start_date` / `end_date` range up to 366 days, or 12 ISO weeks / calendar months with `interval`). Each day comes from its saved daily snapshot when one exists (`snapshot`), else it is `reconstructed` from the current records (`live` for a day that has not closed); the first snapshot run reconstructs up to 366 days back. Neither score is a remediation rate: scan completeness is unknown, days without recorded findings do not prove a clean scan, a suppression is not remediation, and scanner clearance does not establish who performed a fix. `POST …/findings/posture-export` (`report.view`, audit `risk.posture_exported`) renders it.

`GET …/findings/coverage` (`report.view`) lists the observed findings by family, stance and resource type, with which families have a typed fix — the Coverage tab.

## Score per standard, patching and backup {#scores}

**Security posture → Audit & coverage → Scores** shows three reports read from your AWS accounts through the read connections the platform checks use (a connection with `platform_checks: false` is left out). The scheduler reads them once a day per workspace (`AIOPS_COMPLIANCE_REPORT_INTERVAL_S`, 86400; `0` = only the button); **Refresh reports** reads now (`incidents.act`). One row is saved per workspace per UTC day — a second read the same day replaces it — and the trend draws one point per saved day. A denied or failed AWS call is listed under the card; what was read still counts.

| Report | Read from | Number |
|---|---|---|
| Score per standard | Security Hub `GetEnabledStandards` and its control findings (`GetFindings`, product Security Hub, active records, at most 20,000 per region); each AWS Config conformance pack (`DescribeConformancePacks`, `DescribeConformancePackCompliance`); SOC 2 / ISO 27001 as this platform maps them (the posture score above — absent while **Compliance frameworks (phase 2)** is off) | Per standard: passed ÷ (passed + failed + unknown) checks. A Security Hub control is **failed** when one of its findings failed, **unknown** on `WARNING` / `NOT_AVAILABLE`, **passed** when every finding passed; archived and suppressed findings are ignored and a control with no finding (no data) is left out — Security Hub's own rule, so its standards match the console. A conformance-pack rule is passed when `COMPLIANT`, failed when `NON_COMPLIANT`, left out on `INSUFFICIENT_DATA` (the Config console's pack score counts rule-resource pairs, so it can differ). A SOC 2 / ISO 27001 requirement is passed when it passes or is attested, failed on a fail or an accepted exception, unknown when unknown or waiting for an attestation |
| Total | All of the above | The same formula over every check counted **once**: a Security Hub control enabled in several standards (CIS and AWS Foundational Security Best Practices share controls through consolidated control findings) counts once, failed anywhere = failed; a Config rule deployed in several packs (the name without its `-conformance-pack-…` suffix) counts once; each SOC 2 / ISO 27001 requirement once. A Security Hub control and a conformance-pack rule built on the same managed rule still count as two checks. The card shows the plain **mean of the standards** beside it (every standard weighs the same, whatever its number of checks): the two differ when the standards differ in size — three standards at 60%, 90% and 80% average 76.7%, while the pooled total leans toward the standard with the most checks. |
| Patch compliance | Systems Manager `DescribeInstanceInformation`, `DescribeInstancePatchStates`; EC2 `DescribeInstances` | Compliant nodes ÷ nodes with patch data. A node is non-compliant when Patch Manager reports a non-compliant patch (missing, failed, pending reboot or rejected, as its baseline decides). With `aiops_patch_within_days` set, a node whose last patch operation (scan or install) ended more than that many days ago is non-compliant too. Coverage = EC2 instances that report patch data ÷ all EC2 instances; the instances Systems Manager does not manage are listed apart. Target 100% |
| Backup | AWS Backup `ListBackupJobs` (created in the last 7 days); the EST-01 read (RDS backup retention, EC2 in a backup plan through `ListProtectedResources`) | Jobs succeeded = `COMPLETED` ÷ finished (`COMPLETED`, `FAILED`, `EXPIRED`, `ABORTED`, `PARTIAL`), for 24 hours and 7 days; jobs still running are not counted. Coverage = resources EST-01 found protected ÷ resources it checked — only resources carrying the connection's `finding_tag_scope` tag and the workspace scope tags are checked, and the figure names those tags when every connection kept to the same ones (*3 of 3 resources tagged aiops-managed=true*). A scope none of the resources read carries is reported as a note and clears no backup finding. Target 100% |

Read permissions: the read role created by the guided AWS setup carries `config:DescribeConformancePacks`, `config:DescribeConformancePackCompliance`, `ssm:DescribeInstanceInformation`, `ssm:DescribeInstancePatchStates` and `backup:ListBackupJobs` since 0.1.144 (re-run the setup to add them); a role built on `ReadOnlyAccess` already has them. No call writes anything. Azure (Defender regulatory compliance, Update Manager, Azure Backup) is not read yet.

## Audit actions {#audit-actions}

| Action | Emitted when |
|---|---|
| `compliance.scan_started`, `compliance.scan_finished` | A scan starts / completes (`totals`, `cost_usd`) |
| `pillar.scheduled` | The compliance cron fired (target = the pillar agent) |
| `compliance.scanner_synced`, `compliance.scanner_synced_manual` | A scheduled / manual findings pull (per-connection report in the payload) |
| `compliance.attested` | A requirement attested or marked not applicable |
| `compliance.control_override` | A control disabled or marked not applicable |
| `compliance.change_requested` | A fix requested for a control (or a discovery finding) |
| `compliance.interpretation_exported` | The interpretation sheet exported |
| `compliance.table_agreed` | The customer's acceptance of a version of the comparison table recorded |
| `compliance.custom_requirement_saved`, `compliance.custom_requirement_removed` | One of the workspace's own requirements added, replaced or removed |
| `compliance.rule_muted_hit`, `compliance.rule_unmuted` | A legacy mute skipped a probe / an entry was removed |
| `risk.requested`, `risk.decided`, `risk.rejected`, `risk.revoked`, `risk.expired` | The risk decision lifecycle (`risk.decided` carries `requested_by`, `self_approved` when applicable) |
| `risk.mitigation_landed` | The mitigate change's fix landed |
| `risk.suppressed_at_source`, `risk.unsuppressed_at_source` | The scanner-side suppression written / lifted (receipt in the payload) |
| `risk.register_exported`, `risk.posture_exported` | Exports |
| `change.compliance_flagged`, `change.evaluated` | Change-triggered checks |
| `incident.auto_remediate_blocked`, `route.match_skipped` (reason `finding_lane`) | A route would have run on a finding and did not |

## Settings {#settings}

| Setting | Default | Effect |
|---|---|---|
| `compliance.enabled`, `compliance.platform_controls` | off everywhere while `AIOPS_COMPLIANCE_FRAMEWORKS` is off (the default); once it is on, off for a new workspace (on when absent); off | Score SOC 2 / ISO 27001 at all; count the checks of the platform's own settings |
| `compliance.frameworks`, `compliance.soc2_categories`, `compliance.overrides` | both frameworks; security, availability, confidentiality; `{}` | Scope and overrides |
| `compliance.custom_requirements` | `[]` | The workspace's own requirement rows |
| `aiops_compliance_auto_triage` | on (platform) | A compliance finding may start a run |
| `aiops_auto_investigate_p1p2` | on (platform; superadmin-adjustable) | P1/P2 compliance findings with no prepared fix are analysed when they open |
| `aiops_auto_investigate_catchup` | off | The held P1/P2 backlog is swept, 3 per tick |
| `aiops_compliance_report_interval_s` | `86400` (platform `AIOPS_COMPLIANCE_REPORT_INTERVAL_S`; `0` = only **Refresh reports**) | How often the Scores tab's reports (score per standard, patch compliance, backup) are read again for a workspace with an AWS read connection or framework scoring on |
| `aiops_patch_within_days` | `0` (no rule; platform `AIOPS_PATCH_WITHIN_DAYS`) | Patch report: a node whose last patch operation is older than this many days counts as non-compliant. Unset, the report shows 40 days as an example (the nodes it would flag), never applied: the rule is optional and has no default |
| `aiops_patch_findings` | on (platform `AIOPS_PATCH_FINDINGS`) | Each non-compliant managed node of the daily Patch Manager read opens a **Patching** finding (control `PATCH.COMPLIANCE`), unless Security Hub's SSM.2 already holds it open; off clears every open one |
| `aiops_scanner_sync_interval_s` | 900 (platform) | Scheduled Security Hub / Defender pull; ≥ 60 s; 0 = off |
| `sync_findings: false`, `pull_vulnerabilities: false`, `finding_tag_scope` (per connection) | — | Opt a connection out of the pull / out of the Inspector CVEs / pull only findings on resources with one tag |
| `risk_context` | `{}` | `internet_patterns`, `internal_patterns`, `sensitive_patterns`, `env_map` (ARN globs) |
| `workspace_scope.tags` | `{}` | Tag slice of a shared account (up to 10 tags) |
| `aiops_risk_accept_days`, `aiops_risk_mitigate_days` | 90, 180 | Default expiry of an accept / mitigate |
| `aiops_risk_no_self_approval` | off | The requester may never approve their own request |
| `aiops_risk_expiry_sweep` | off | The scheduler acts at expiry instead of waiting for a re-send |
| `aiops_risk_suppress_at_source` | off | Accepted findings are suppressed at the scanner |
| `aiops_compliance_muted` | `[]` | Legacy mute list (read only) |
| `aiops_change_compliance_agent` | off (platform) | The agent rung of change-triggered checks |
| `aiops_change_recheck_mapped` | on | A mapped change re-checks the control's open incident |

## API summary {#api}

| Method and path | Permission | Purpose |
|---|---|---|
| `GET …/compliance/posture`, `GET …/compliance/catalog`, `GET …/compliance/scans`, `GET …/compliance/scans/{id}`, `GET …/compliance/controls/{id}` | `report.view` | Posture, catalogue, scans, one control's evidence |
| `POST …/compliance/scan` | `incidents.act` | Scan now (202; `409` while no check is in scope) |
| `GET …/compliance/reports` (`days`, default 30) | `report.view` | The score per standard and total, patching and backup, with the trend |
| `POST …/compliance/reports/refresh` | `incidents.act` | Read the reports from AWS now (inline up to 55 s, then `refreshing: true`) |
| `PATCH …/compliance/controls/{id}` | `pillars.manage` | Override a control |
| `POST …/compliance/requirements/{ref}/attest` | `pillars.manage` | Attest a requirement |
| `POST …/compliance/controls/{id}/request-change` | `incidents.act` | Open a change request for a control |
| `POST …/compliance/controls/{id}/exceptions` | `incidents.act` or `approvals.decide` | Request exceptions on a control's offenders |
| `GET …/compliance/discovery-findings`, `POST …/compliance/discovery-findings/{index}/request-change` | `report.view` + `datasources.view` / `incidents.act` + `datasources.view` | Compliance notes an estate x-ray recorded, with the scan's related draft routes and knowledge — API and MCP only; the portal leaves them to the x-ray report, since no control or framework maps them. `datasources.view` is the read permission of the x-ray scan itself |
| `POST …/compliance/interpretation-export` | `report.view` | The interpretation sheet |
| `GET …/compliance/agreed-table` | `report.view` | The comparison table, as json, csv or md |
| `POST …/compliance/agreed-table/accept` | `pillars.manage` | Record the customer's acceptance of a version |
| `PUT …/compliance/custom-requirements/{ref}`, `DELETE …/compliance/custom-requirements/{ref}` | `pillars.manage` | Add, replace or remove one of your own requirements |
| `GET …/findings`, `GET …/findings/accounts`, `GET …/findings/score`, `GET …/findings/{id}` | `incidents.view` | The register, accounts, the score, one finding |
| `GET …/findings/coverage`, `GET …/findings/sync`, `POST …/findings/posture-export` | `report.view` | Coverage, pull reports, export |
| `POST …/findings/sync` | `pillars.manage` | Pull findings now |
| `POST …/findings/{id}/decision` | `incidents.act` or `approvals.decide` | Request a risk decision |
| `POST …/risk-decisions/{id}/approve`, `…/reject`, `…/revoke` | `approvals.decide` | Decide / withdraw |
| `GET …/risk-register`, `POST …/risk-register/export` | `report.view` | The register |
| `POST …/incidents/muted-rules/unmute` | `approvals.decide` | Remove a legacy mute entry |


## Next steps

- Next: [Permissions](/help/permissions.md)
- [All Reference pages](/help/reference/llms.txt)
- [Documentation home](/help/)
