How to Implement FedRAMP 20x KSI Checks (Checks as Objects)
Model each FedRAMP 20x KSI check as a first-class object with a stable identity, declared inputs, a validation method, a structured machine-readable result, a cadence, an owner, and a failure path. Each check reads current state from an authoritative source and asserts one condition; results roll up deterministically into a KSI assertion. This makes evidence regenerable on demand, runs on the 7-day (Low) / 3-day (Moderate) cadence, and treats any failed or broken validation as a vulnerability.
In this article
Main question
How do you implement FedRAMP 20x KSI checks as first-class objects?
How to Implement FedRAMP 20x KSI Checks (Checks as First-Class Objects)
To implement FedRAMP 20x Key Security Indicator (KSI) checks well, model each check as a first-class object with a stable identity, declared inputs, a validation method, a structured result, a cadence, an owner, and a defined failure path — instead of burying logic in scripts that emit screenshots. Every check fetches current state from an authoritative source, asserts a single condition against it, and returns a machine-readable result that rolls up into a KSI assertion. This is what lets a 20x evidence pipeline regenerate proof on demand, run on FedRAMP's validation cadence (at least every 7 days at Low, every 3 days at Moderate for machine-based resources), and treat any broken validation as a vulnerability.
Key takeaways
- A KSI check is one assertion: smallest unit you would enable, disable, schedule, or attribute to an owner. Stable identity is the keystone — everything else (results, findings, cadence, audit trail) keys off it.
- The check object has seven fields: identity, inputs, validation method, result, cadence, owner, failure path. Define these as data, keep the predicate as a small pure function.
- There are six recurring check types: cloud configuration, log/status, state/inventory, serverless (Lambda) custom logic, Terraform/IaC static analysis, and application source-code analysis. Each emits the same result shape.
- Results roll up deterministically: per-resource pass/fail → per-assertion verdict → KSI assertion → pass/fail summary. Keep the math explicit and reproducible.
- A failed or broken validation is a vulnerability under FedRAMP 20x and must route to Vulnerability Detection and Response (VDR), not a silent retry.
- Screenshots are not an acceptable system of record. The check's output must be machine- and human-readable and regenerable on demand.
This post is the implementation companion to our guide on how to automate KSI evidence collection. That post covers the pipeline; this one covers the object at the center of it.
What does "checks as first-class objects" mean?
It means a check is a real, addressable thing in your system — a record with an identity and configuration — not an anonymous block of code that happens to run during a scan. The contrast is concrete. In the naive design, "is CloudTrail logging enabled" lives inside a 400-line scan script, has no name you can reference, runs for every customer whether or not it applies, and produces a finding you can only suppress one resource at a time. In the first-class design, that same logic is one row in a check catalog with the identifier aws_cloudtrail_logs_enabled, a known input signal, a one-line predicate, a severity, an owner, and a cadence. You can list it, enable or disable it per environment with a recorded reason, point a finding back to it, and reproduce its result on demand.
The single design decision that unlocks all of this is granularity: one check per assertion. The smallest unit a team would reasonably want to toggle, schedule, or attribute separately is one assertion ("AWS Config recorder is recording"), not a whole bundle ("the entire Change Management evaluation"). If your unit of identity is too coarse, "we don't run Azure" also disables your AWS and GCP predicates, and a failing finding can't point at the specific thing that broke. Pin a stable assertion_id to every predicate first; the rest of the architecture hangs off it.
| Without first-class checks | With first-class checks |
|---|---|
| Logic hidden in scan scripts | Each check is a named, addressable object |
| Every customer runs every check | Per-environment enable/disable with a reason |
| Findings can't reference their source | Finding carries the producing assertion_id |
| No audit trail for scoping decisions | Who disabled what, when, and why is recorded |
| Hard to add custom checks | New check = new catalog entry, same contract |
What fields does a KSI check object have?
A KSI check object carries everything needed to run it, schedule it, interpret its output, and act on a failure. We model it as seven fields. Identity and the validation method are immutable parts of the check definition; cadence and owner are policy you attach; the result is what the check emits each run.
| Field | What it is | Example |
|---|---|---|
| Identity | Stable, unique assertion_id; the KSI it serves; provider; severity | aws_cloudtrail_logs_enabled · KSI-MLA-LOG · aws · high |
| Inputs | The signal/resource type the check reads, from an authoritative source | aws:cloudtrail:trails |
| Validation method | The predicate: a pure function (resource) -> bool plus pass/fail messages | "trail status IsLogging == true" |
| Result | Structured pass/fail per resource, plus human-readable reason and snapshot | {status, resource_id, reason, snapshot, observed_at} |
| Cadence | How often it must run, derived from impact level and machine vs. non-machine | every 3 days (Moderate, machine-based) |
| Owner | Who remediates a failure and where the work item lands | platform-security team · Jira project |
| Failure path | What happens on fail or on a broken run: open a VDR-tracked vulnerability | route to VDR with assertion id + resource |
A clean way to declare the identity-and-inputs-and-method triple is a small dataclass that binds a signal to a predicate. The predicate stays pure — it takes one resource dict and returns a boolean — so it is trivially testable and free of I/O:
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class CheckDef:
assertion_id: str # stable identity, e.g. "aws_cloudtrail_logs_enabled"
ksi_id: str # the KSI it serves, e.g. "KSI-MLA-LOG"
provider: str # "aws" | "azure" | "gcp" | "scm" | "code" | ...
signal: str # authoritative input, e.g. "aws:cloudtrail:trails"
severity: str # "low" | "moderate" | "high" | "critical"
condition: Callable[[dict], bool] # the predicate — pure, no I/O
passing_msg: Callable[[dict], str]
failing_msg: Callable[[dict], str]
snapshot_fields: list[str] # keys to capture into evidence
The catalog of CheckDef rows is the registry. Cadence, owner, and per-environment enable/disable are configuration layered on top — they should not live inside the predicate. Keeping the validation logic separate from registration is what lets you disable a check for one customer (with a recorded reason that feeds your audit trail) without touching code, and lets a finding cascade-dismiss when its check is turned off. Anything else — custom customer-defined checks, bulk "disable all Azure compute" actions, agent-suggested scoping — is a natural extension once the object exists.
How do check types map to KSI themes?
FedRAMP 20x organizes security outcomes into 11 KSI themes, and most validations reduce to one of six check types. The type tells you where the input comes from and how the predicate runs; the result shape is identical across all six. (For why a KSI is a different artifact than a control narrative, see KSIs vs the SSP.)
| Check type | Reads from | Primary KSI themes |
|---|---|---|
| Cloud configuration | Cloud config API | Service Configuration, Cloud Native Architecture |
| Log / status | Monitoring / logging source | Monitoring, Logging & Auditing |
| State / inventory | Running system or tracked record | Policy & Inventory, Change Management |
| Serverless (Lambda) custom logic | A function that returns structured pass/fail | Any theme needing custom logic |
| Terraform / IaC static analysis | Infrastructure-as-code in your repos | Change Management, Cloud Native Architecture |
| Application source-code analysis | App source/config in your repos | Service Configuration, IAM |
Each subsection below shows the input, the validation logic, and the structured result — the three things every first-class check must define.
1. Cloud configuration check
A configuration check fetches current cloud config and asserts a single property: encryption is on, logging is enabled, a public endpoint is closed. The pattern is fetch then assert. Collection (the cloud API call) happens upstream and lands a normalized resource record; the check is a pure predicate over that record.
Input — a normalized resource dict per S3 bucket:
{ "resource_id": "prod-evidence-store", "encryption_type": "aws:kms",
"kms_key_arn": "arn:aws:kms:...:key/abc", "observed_at": "2026-06-04T11:02:09Z" }
Validation logic — encryption at rest must use a KMS key:
def s3_encrypted_with_kms(r: dict) -> bool:
return r.get("encryption_type") == "aws:kms" and bool(r.get("kms_key_arn"))
check = CheckDef(
assertion_id="aws_s3_encryption_at_rest",
ksi_id="KSI-SVC-ENC", provider="aws",
signal="aws:s3:buckets", severity="high",
condition=s3_encrypted_with_kms,
passing_msg=lambda r: f"Bucket {r['resource_id']} encrypted with KMS",
failing_msg=lambda r: f"Bucket {r['resource_id']} not KMS-encrypted",
snapshot_fields=["encryption_type", "kms_key_arn"],
)
Structured result:
{ "assertion_id": "aws_s3_encryption_at_rest", "status": "FAIL",
"resource_id": "prod-evidence-store",
"reason": "Bucket prod-evidence-store not KMS-encrypted",
"snapshot": { "encryption_type": "AES256", "kms_key_arn": null },
"observed_at": "2026-06-04T11:02:09Z", "source": "aws:s3:buckets" }
2. Log / status check
A log/status check pulls a status or recent log signal from a monitoring source and evaluates it — the core of the Monitoring, Logging & Auditing theme. The predicate is the same shape; the input is a status record rather than a static config. A common pitfall is asserting "a trail exists" instead of "a trail is actively logging." Existence is not evidence; current status is.
Input — a CloudTrail trail status record:
{ "resource_id": "org-trail", "is_logging": true,
"delivers_to_cloudwatch": true, "latest_delivery_error": null,
"observed_at": "2026-06-04T11:02:11Z" }
Validation logic — the trail must be logging and delivering to CloudWatch for real-time monitoring:
def cloudtrail_actively_logging(r: dict) -> bool:
return (r.get("is_logging") is True
and r.get("delivers_to_cloudwatch") is True
and not r.get("latest_delivery_error"))
The result is identical in shape to the configuration check (status, resource_id, reason, snapshot, observed_at, source). That uniformity is the point: a downstream aggregator never needs to know which type produced a result.
3. State / inventory check
A state/inventory check verifies something about the running system or a tracked record — does the AWS Config recorder actually record, is every production resource in the inventory, is the change log current. This is the "check something in an app or data store" pattern, and it maps to Policy & Inventory and Change Management. The input is a system record; the predicate asserts the desired state.
Input and logic — the configuration recorder must be recording:
def config_recorder_active(r: dict) -> bool:
return r.get("recording") is True
check = CheckDef(
assertion_id="aws_config_recorder_recording",
ksi_id="KSI-CMT-BAS", provider="aws",
signal="aws:config:recorders", severity="high",
condition=config_recorder_active,
passing_msg=lambda r: f"Config recorder {r['resource_id']} is recording",
failing_msg=lambda r: f"Config recorder {r['resource_id']} stopped — drift untracked",
snapshot_fields=["recording", "name"],
)
For an inventory completeness check, the predicate runs over the join of "resources the connector sees" and "resources the inventory claims," and fails any resource present in one set but not the other. The result still carries a resource_id and a reason, so a missing resource becomes a first-class finding rather than a silent gap — which matters because assessors verify that every information resource you list is actually being validated.
4. Complex check via a serverless function (AWS Lambda)
When a check needs custom logic that does not reduce to a one-line predicate over a single record — correlating across services, walking an IAM policy graph, computing a derived condition — run it as a serverless function and have it return the same structured result. The function is just a different execution host for the validation method; the contract does not change.
The Lambda receives the inputs it needs and returns a list of per-resource results:
def handler(event, _context):
resources = event["resources"] # pre-fetched authoritative state
results = []
for r in resources:
# custom logic: e.g., no IAM role grants standing admin AND wildcard resource
violates = grants_admin(r) and has_wildcard_resource(r)
results.append({
"assertion_id": "aws_iam_no_standing_admin_wildcard",
"status": "FAIL" if violates else "PASS",
"resource_id": r["role_name"],
"reason": ("Role grants admin on '*'"
if violates else "Role scoped within least privilege"),
"snapshot": {"policies": r.get("attached_policies", [])},
"observed_at": event["observed_at"],
"source": "aws:iam:roles",
})
return {"assertion_id": "aws_iam_no_standing_admin_wildcard", "results": results}
The orchestrator invokes the function on cadence, captures the returned JSON as evidence, and feeds it into the same roll-up as every other check. Treat the function's own failure to execute (timeout, error) as a broken validation — a vulnerability, covered below — not a passing result. A check that did not run is never a pass.
5. Terraform / IaC static analysis check
An IaC check analyzes infrastructure-as-code to validate a control before deploy — catching a misconfiguration in the pull request instead of in production. The input is parsed Terraform (or CloudFormation, Bicep, etc.); the predicate asserts a required property on a resource block. This serves Change Management ("baseline defined as code") and Cloud Native Architecture.
Input — a parsed Terraform resource:
{ "type": "aws_s3_bucket", "name": "evidence",
"block": { "server_side_encryption_configuration": { "rule": {
"apply_server_side_encryption_by_default": { "sse_algorithm": "aws:kms" } } } },
"file": "infra/storage.tf", "line": 14 }
Validation logic — every S3 bucket resource declares KMS encryption:
def tf_bucket_requires_kms(block: dict) -> bool:
rule = (block.get("server_side_encryption_configuration", {}).get("rule", {}))
default = rule.get("apply_server_side_encryption_by_default", {})
return default.get("sse_algorithm") == "aws:kms"
The structured result points at the file and line, so a failed IaC check links straight to a code fix:
{ "assertion_id": "tf_s3_encryption_required", "status": "FAIL",
"resource_id": "aws_s3_bucket.evidence", "reason": "Bucket lacks KMS encryption block",
"snapshot": { "file": "infra/storage.tf", "line": 14 },
"observed_at": "2026-06-04T11:03:00Z", "source": "scm:terraform" }
The same machine-readable result that fails the check can also drive a remediation proposal: the file path and line are enough to open a fix branch. An agent or pipeline reads the failing result, generates the missing encryption block, and opens a pull request scoped to that repo — turning a finding into a reviewable change.
6. Application source-code analysis check
A source-code check scans application code or config for a required property — a security header set, TLS enforced, a debug flag off, a secret not hard-coded. The input is the parsed file or a structured match; the predicate asserts presence or absence. This commonly supports Service Configuration and IAM (for example, verifying the app enforces authentication on a route).
Input and logic — a web config must enforce HSTS:
def enforces_hsts(parsed_config: dict) -> bool:
headers = parsed_config.get("security_headers", {})
hsts = headers.get("Strict-Transport-Security", "")
return "max-age=" in hsts and int(_max_age(hsts)) >= 31536000 # >= 1 year
Result — a PASS/FAIL per file, with the offending file in the snapshot:
{ "assertion_id": "code_enforces_hsts", "status": "PASS",
"resource_id": "app/middleware/security.py", "reason": "HSTS set, max-age >= 1y",
"snapshot": { "header": "max-age=31536000; includeSubDomains" },
"observed_at": "2026-06-04T11:03:30Z", "source": "code:security_headers" }
Source-code checks are the most likely to need exclusions (test fixtures, vendored code). Handle that with the check object's per-environment enable/disable and a resource_filter that excludes non-applicable files before evaluation — never by weakening the predicate, which would mask real failures everywhere.
What is the output contract for a KSI check?
Every check, regardless of type, emits the same result object so that one aggregator can roll them all up. The contract has a machine-readable core and a human-readable layer, and it carries the metadata 20x requires: a source and a timestamp, so the assertion is reproducible and attributable.
{
"assertion_id": "aws_cloudtrail_logs_enabled", // stable check identity
"ksi_id": "KSI-MLA-LOG", // the KSI it serves
"status": "PASS", // PASS | FAIL | ERROR | N/A
"resource_id": "org-trail", // what was checked
"reason": "Trail org-trail is logging to CloudWatch", // human-readable
"snapshot": { "is_logging": true }, // evidence captured
"severity": "high",
"source": "aws:cloudtrail:trails", // where it came from
"observed_at": "2026-06-04T11:02:11Z" // when (reproducibility)
}
status is the machine layer; reason is the human layer; source + observed_at make it machine-readable OSCAL-friendly and regenerable. Note what is not here: no image, no PDF, no screenshot. A screenshot cannot be diffed, queried, or re-verified, so it cannot be your system of record under 20x.
How do results roll up to a KSI assertion?
Roll-up is deterministic and happens in two stages. First, the per-resource results for one assertion collapse into a verdict — passing count over total. Second, the assertion verdicts for a KSI aggregate into the KSI's pass/fail status. Keep the arithmetic explicit so an assessor can reproduce it.
def roll_up_assertion(results: list[dict]) -> dict:
scored = [r for r in results if r["status"] in ("PASS", "FAIL")]
total = len(scored)
passing = sum(1 for r in scored if r["status"] == "PASS")
score = (passing / total) if total else None
if score is None: status = "no_evidence"
elif score >= 0.75: status = "passing"
elif score > 0.0: status = "partial"
else: status = "failing"
return {"assertion_id": results[0]["assertion_id"],
"status": status, "score": score,
"passing": passing, "total": total,
"failing_resources": [r for r in scored if r["status"] == "FAIL"]}
Two aggregation modes cover most KSIs. Per-resource sums passing/total across every resource — right for "all production buckets encrypted." By-provider marks a provider covered if any of its resources passes, then scores covered-providers over connected-providers — right for "at least one cloud enforces this control." A KSI assertion is then passing only when its underlying verdicts clear the threshold, and the program-level summary is the weighted roll-up across KSI families. Disabled checks contribute nothing to the denominator — they produce no result, so they neither pass nor fail; the scoping decision lives in the audit trail instead. Weighting automated families more heavily than document-only families is a reasonable refinement, but the floor is this transparent passing/total math.
How do cadence and failure-as-vulnerability work?
Cadence is policy attached to the check, derived from impact level and whether the resource is machine- or non-machine-based. For machine-based resources, FedRAMP 20x requires validation at least every 7 days at Low and every 3 days at Moderate; non-machine validations must run at least once every 3 months. These are floors — a scheduler that beats them (many teams validate machine-based checks continuously) is compliant; one that drifts past them is not. We go deeper on this in our note on KSI validation cadence.
The failure path is the part teams most often get wrong. Under FedRAMP 20x, both a failed validation and a broken validation are vulnerabilities. A check that returns FAIL opens a tracked finding routed to its owner. A check that fails to run — the Lambda timed out, the API credential expired, the collector crashed — is equally a vulnerability, because a pipeline that silently stops collecting is an open, undetected gap. Both must flow into Vulnerability Detection and Response with the assertion_id, the affected resource, and an owner — never a silent retry that hides the gap.
def to_vulnerability(result: dict, owner: str) -> dict:
assert result["status"] in ("FAIL", "ERROR") # FAIL = condition unmet; ERROR = broken run
return {"source": "ksi_validation",
"assertion_id": result["assertion_id"], "ksi_id": result["ksi_id"],
"resource_id": result.get("resource_id"),
"severity": result.get("severity", "moderate"),
"owner": owner, "detected_at": result.get("observed_at"),
"kind": "validation_failure" if result["status"] == "FAIL" else "broken_validation"}
Because every result carries a stable assertion_id, the vulnerability links back to exactly the check that produced it, and re-enabling a previously disabled check cleanly reopens any findings that were cascade-dismissed. Stable identity, again, is what makes the whole loop auditable.
Frequently asked questions
What is a FedRAMP 20x KSI check?
A KSI check is a single validation that asserts one condition about your cloud service offering and emits a structured pass/fail result tied to a Key Security Indicator. Implemented as a first-class object, it has a stable identity, declared inputs, a validation method, a result, a cadence, an owner, and a failure path. Each check maps to one assertion so it can be enabled, disabled, scheduled, and attributed independently.
Why model KSI checks as first-class objects instead of scripts?
Because 20x evidence must be regenerable on demand, attributable, and auditable, and anonymous script logic cannot deliver that. A first-class check is addressable: you can list it, reference it from a finding, disable it per environment with a recorded reason, and reproduce its result with a source and timestamp. It also makes custom and agent-suggested checks a natural extension rather than a rewrite.
What fields should a KSI check object have?
Seven: identity (a stable assertion_id, the KSI it serves, provider, severity), inputs (the signal it reads), validation method (the predicate plus pass/fail messages), result (the structured per-resource output), cadence (how often it runs), owner (who remediates), and failure path (route to VDR). Identity and method belong to the check definition; cadence and owner are configuration layered on top.
How often must KSI checks run?
For machine-based information resources, at least every 7 days at Low and every 3 days at Moderate; the High cadence is expected to be more frequent but is not yet finalized. Non-machine-based validations must run at least once every 3 months. These are minimum frequencies — validating machine-based checks continuously is common and compliant.
What happens when a KSI check fails or can't run?
Both are vulnerabilities under FedRAMP 20x. A FAIL result opens a finding routed to the check's owner through Vulnerability Detection and Response. A check that errors or stops running is also a vulnerability, because a pipeline that silently stops collecting is an undetected gap. Carry the assertion_id, affected resource, and owner into the VDR record rather than retrying silently.
Can a KSI check output a screenshot as evidence?
No — not as the system of record. FedRAMP 20x evidence must be machine- and human-readable and regenerable on demand, and screenshots cannot be diffed, queried, or re-verified. A check should emit a structured result with a machine-readable status, a human-readable reason, an evidence snapshot, a source, and a timestamp. Screenshots can illustrate context for a human but cannot be the evidence.
How do individual check results become a KSI pass/fail?
In two deterministic stages. Per-resource results collapse into an assertion verdict (passing over total); assertion verdicts aggregate into the KSI's status, either per-resource (sum across resources) or by-provider (covered providers over connected providers). The KSI is passing only when its verdicts clear the threshold, and the program score is the weighted roll-up across KSI families. Disabled checks contribute nothing to the denominator.
How do I add a custom KSI check?
Add a new entry to your check catalog with a new assertion_id, the KSI it serves, its input signal, and a predicate — the same contract as every built-in check. For logic that does not reduce to a one-line predicate, host it in a serverless function that returns the standard result shape. Because the object model is uniform, the new check flows through the same roll-up, cadence, and failure handling without engine changes.
Sources
- FedRAMP 20x Key Security Indicators — fedramp.gov
- FedRAMP 20x Persistent Validation and Assessment — fedramp.gov
- FedRAMP 20x Phase One Pilot Participation Example — fedramp.gov
- FedRAMP RFC-0006: 20x Phase One Key Security Indicators — fedramp.gov
- NIST SP 800-53 Rev 5 (control catalog) — csrc.nist.gov
Last updated: June 2026. Written by the Boundera team.
Next step
If you want to turn this guidance into an execution plan, the product side handles control mapping, SSP drafting, and evidence collection.
Related articles
Run FedRAMP 20x KSI Checks in CI: The Boundera GitHub Action
An open-source GitHub Action that evaluates your Terraform against FedRAMP 20x KSIs on every commit - no vendor server, evidence stays in your runner.
FedRAMP 20x KSI Validation: How Often and in What Format
FedRAMP 20x KSI validation cadence and format: machine-based every 7 days (Low) / 3 days (Moderate), non-machine every 3 months, evidence machine- and human-readable.
FedRAMP Compliance Tools in 2026: What to Look For
How to evaluate FedRAMP compliance tools in 2026 by capability - control mapping, SSP generation, continuous evidence, KSI automation, OSCAL, and ConMon.