Module 5: Scanning Iac And Dependencies

6. Reading a finding: fix, suppress, or accept the risk

Description

Twenty-six combined findings — eleven from Trivy, fifteen from Checkov, already mapped in the previous lesson — don't all get resolved the same way, nor should they. This lesson teaches the three legitimate responses to a real finding: fix the HCL when the fix is cheap and correct; suppress, with a written and verifiable reason, when the finding is real but out of scope for this specific project; and, in cases this lesson names but doesn't execute here, accept the risk by documenting it without hiding it. You're going to do the first two for real, on real findings from lessons 4 and 5, with both tools re-run to confirm each change.

Connection to the module

Lessons 4 and 5 produced a long list of pending work. This lesson is where that list stops being an inventory and turns into decisions — the same kind of judgment a real security team applies every day: not every finding deserves the same treatment, and the worst possible response is silently ignoring one, with no record that someone saw it and decided something about it.


Analogy: the signed exception, not the disabled alarm

Going back to lesson 1's inspector analogy: if the inspector flags that the basement door has no panic bar, you have three honest responses. You can install it — the direct fix. You can decide that specific door opens onto an interior courtyard with no street access, so a panic bar doesn't add real safety there, and write that justification into the inspection report, signed, so the next inspector doesn't flag it again as a surprise. Or you can acknowledge it really is needed, but you have no budget this quarter, and note it as an accepted risk, with a review date. What's never a legitimate response is disconnecting the alarm that detects the open door so the inspector stops mentioning it — that doesn't resolve the risk, it only hides the evidence that it exists.

Suppressing a finding with #checkov:skip= or .trivyignore, when done with a written reason alongside it, is the inspector's signed exception — never the disabled alarm.


Part 1 — Fix: AWS-0024 / CKV_AWS_28, point-in-time recovery for Shipments

This is the obvious candidate for a direct fix: cheap, no side effects, and on asset number one from THREAT-MODEL.md. Recall lesson 4's finding:

AWS-0024 (MEDIUM): Point-in-time recovery is not enabled.

And its Checkov equivalent (lesson 5):

Check: CKV_AWS_28: "Ensure DynamoDB point in time recovery (backup) is enabled"
	FAILED for resource: aws_dynamodb_table.shipments

The fix, in dynamodb.tf

resource "aws_dynamodb_table" "shipments" {
  name         = var.table_name
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "shipmentId"

  attribute {
    name = "shipmentId"
    type = "S"
  }

  point_in_time_recovery {
    enabled = true
  }

  tags = local.common_tags
}

One new block, four lines, without touching any existing attribute. terraform validate confirms the change is syntactically correct before rescanning:

terraform fmt -recursive
terraform validate

What to expect (literal):

Success! The configuration is valid.

Rescanning: confirming the fix worked

trivy config dynamodb.tf

What to expect (literal — run to write this lesson, after the fix):

dynamodb.tf (terraform)
=======================
Tests: 1 (SUCCESSES: 0, FAILURES: 1)
Failures: 1 (UNKNOWN: 0, LOW: 1, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

AWS-0025 (LOW): Table encryption does not use a customer-managed KMS key.

AWS-0024 no longer appears. Only AWS-0025 remains (customer-managed key encryption), a different finding, unrelated to point-in-time recovery. Tests: 1 instead of Tests: 2 — numeric confirmation that the fix resolved exactly the finding it set out to resolve, nothing more, nothing less.

checkov -d . --compact --quiet 2>&1 | grep "CKV_AWS_28\|Passed checks"

What to expect (literal):

Passed checks: 114, Failed checks: 14, Skipped checks: 0

CKV_AWS_28 dropped off the failed list — 113 passed went up to 114, 15 failed went down to 14 —, confirming the same fix from the second scanner's angle, with its own engine, completely independently.


Part 2 — Suppress with Checkov: CKV_AWS_144, cross-region replication

This is the right candidate to suppress, not fix: "Ensure that S3 bucket has cross-region replication enabled." Andes Cargo operates in a single region (us-east-1, fixed since aws-core-services-guide) — replicating the manifest bucket to a second region would add a resource, a recurring cost, and new operational surface, without resolving any real security risk for a system that, by design, doesn't yet need full-region failure tolerance.

The suppression comment — and the detail only discovered by running the command for real

Checkov's documented syntax is a #checkov:skip=<ID>:<reason> comment. What isn't always clear without testing it is exactly where that comment has to live. The first time this lesson was written, the comment was put above the resource line:

#checkov:skip=CKV_AWS_144:Single-region deployment by design (us-east-1 only)
resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
  tags   = var.tags
}

This did not suppress the check. A rescan showed CKV_AWS_144 still appearing as FAILED, Skipped checks: 0. The correct location, confirmed by running the command — not assumed from the documentation —, is inside the block, as the first line of the resource's body:

resource "aws_s3_bucket" "this" {
  # checkov:skip=CKV_AWS_144:Single-region deployment by design (us-east-1 only); cross-region replication is out of scope for this $0 lab
  bucket = var.bucket_name
  tags   = var.tags
}

Confirming the suppression

checkov -d . --compact --quiet 2>&1 | grep "Passed checks\|CKV_AWS_144"

What to expect (literal — run to write this lesson, with the comment in the correct location):

Passed checks: 114, Failed checks: 13, Skipped checks: 1

CKV_AWS_144 no longer appears in the FAILED list — but it doesn't count as Passed either. The Skipped checks count went from 0 to 1, a third category, distinct from pass or fail: the check was evaluated, a documented suppression was found, and it was excluded from the final result without pretending the bucket actually has cross-region replication. It's the exact difference between the signed exception and the disabled alarm from this lesson's analogy — the record that someone saw the finding and decided something, instead of the finding simply disappearing.

Note on the JSON report: if you generate the report with checkov -d . -o json, you'll find the aggregate count under summary.skipped (confirmed: 1 in this run), but the results.skipped_checks array can come back empty for a check evaluated inside a local module, depending on the exact Checkov version. If you need a suppression's exact reason, in writing and independent of how well each tool version exports it, the reliable source is always the comment inside the HCL file itself — never rely solely on the generated report to reconstruct why a decision was made.


Part 3 — Suppress with Trivy: AWS-0089, bucket access logging

Trivy doesn't read #checkov:skip= comments — it uses its own mechanism, a .trivyignore file at the project root, with one check ID per line. The candidate: AWS-0089, access logging disabled on andes-cargo-shipment-docs. Configuring S3 access logging requires declaring a second destination bucket, with its own retention policy and cost — a complete operational piece, out of scope for this $0 lab, and partially covered by another control already named in this guide: CloudTrail (Module 7), which logs API calls against this bucket, though not the object-level detail an S3 access log would offer.

cat > .trivyignore <<'EOF'
# AWS-0089: S3 bucket logging disabled on andes-cargo-shipment-docs.
# Access logging targets a second bucket + its own retention/cost policy, out of scope
# for this $0 LocalStack lab. Detection of unauthorized access already covered by
# CloudTrail (Module 7 of this guide). Revisit if this project ever targets real AWS.
AWS-0089
EOF

Every line that doesn't start with # is a check ID to ignore — the format is deliberately simple, a flat list, with no nested syntax.

trivy config .

What to expect (literal — run to write this lesson, with Part 1's fix and both suppressions applied):

Report Summary

┌───────────────────────────┬───────────┬───────────────────┐
│          Target           │   Type    │ Misconfigurations │
├───────────────────────────┼───────────┼───────────────────┤
│ .                         │ terraform │         0         │
├───────────────────────────┼───────────┼───────────────────┤
│ dynamodb.tf               │ terraform │         1         │
├───────────────────────────┼───────────┼───────────────────┤
│ lambda.tf                 │ terraform │         1         │
├───────────────────────────┼───────────┼───────────────────┤
│ modules/s3-bucket/main.tf │ terraform │         6         │
├───────────────────────────┼───────────┼───────────────────┤
│ secrets.tf                │ terraform │         1         │
└───────────────────────────┴───────────┴───────────────────┘

From eleven findings to nine. dynamodb.tf dropped from two to one (Part 1's fix). modules/s3-bucket/main.tf dropped from seven to six (AWS-0089, ignored via .trivyignore; AWS-0086/0087/0091/0093/0094 and AWS-0132 are still present — this lesson's .trivyignore only targets AWS-0089, not the others). lambda.tf and secrets.tf didn't change, because this lesson didn't touch any findings from those two files.


Updating RISK-MAP.md: two documented decisions, none hidden

If you're keeping RISK-MAP.md (Module 1, lesson 8) up to date, this is exactly the kind of update that belongs after this lesson — not just "fixed," but how each case was decided:

+ | - | AWS-0024/CKV_AWS_28 | (new, M5) | Point-in-time recovery on Shipments | Add `point_in_time_recovery` block | M5.6 | Resolved: `dynamodb.tf` updated, confirmed via `trivy config` and `checkov -d` |
+ | - | CKV_AWS_144 | (new, M5) | S3 cross-region replication | Documented suppression (single-region by design) | M5.6 | Suppressed via `#checkov:skip=CKV_AWS_144`, reason in HCL comment |
+ | - | AWS-0089 | (new, M5) | S3 access logging disabled | Documented suppression (out of $0 scope, partial CloudTrail coverage) | M5.6 | Suppressed via `.trivyignore`, reason documented in file |

Three rows, three different decisions, each with its verification mechanism cited — the same evidence discipline THREAT-MODEL.md demanded since Module 1.


About "accepting the risk": the third path, named

This lesson executed two of the three legitimate responses. The third — accepting the risk without fixing it or suppressing it from the tool's report — is the right call when a finding is real, can't be resolved right now (due to cost, an external dependency, a pending business decision), but also shouldn't be hidden from the scan report: you want it to keep showing up as FAILED every time someone runs the scanner, as an active reminder, with the justification living in a separate document (a ticket, a risk register, RISK-MAP.md itself) instead of in a suppression comment. AWS-0132/CKV_AWS_145 (bucket encryption with a customer-managed key) is exactly that case, for now: named, justified, not yet fixed, visible on every run without blocking anything. It's a state that can stay that way indefinitely, or turn into a documented suppression the day an automated gate demands a binary pass/fail answer — you'll see exactly that second situation in lesson 7, when this same finding runs into a pipeline that doesn't allow middle ground.


Common mistakes

Putting the #checkov:skip= comment above the resource instead of inside it (the real mistake from this lesson, verified by running the command). What happens: someone copies an example from a tutorial or the documentation without checking it against their own Checkov version, and the comment ends up above the resource line. How to spot it: if your Skipped checks stays at 0 after adding the comment, and the ID you tried to suppress still shows up in the FAILED list. How to fix it: move the comment inside the resource block, as the first line of its body — exactly the pattern verified in Part 2 of this lesson.

Suppressing a finding without writing any reason, just the ID (silent-shortcut mistake). What happens: someone, in a hurry, writes #checkov:skip=CKV_AWS_144 with nothing after the colon, or without the colon at all. How to spot it: if your suppression comment has no readable text after the ID. How to fix it: the reason isn't optional in this lesson's spirit, even though Checkov's technical syntax accepts it empty — without a written reason, a reviewer six months later has no way of knowing whether the suppression is still valid or whether someone put it there to "pass" a pipeline without thinking about it. Every suppression in this lesson carries its reason on the same line.

Confusing a suppressed finding with a resolved one (report-reading mistake). What happens: someone sees CKV_AWS_144 no longer appears in FAILED and assumes the bucket now has cross-region replication. How to spot it: if your understanding of the real infrastructure state comes only from the Passed/Failed count, without checking Skipped separately. How to fix it: Skipped checks: 1 is a category distinct from Passed — the bucket still doesn't have cross-region replication; what changed is that the team documented, in writing, why that's an acceptable decision for this project, not that the risk stopped existing.


Exercises

Exercise 1 — Reproduce the comment-placement mistake, on purpose. In a test .tf file, write a #checkov:skip= above the resource (the wrong location) and run Checkov. Confirm the check still fails, then fix the location.

See solution
# test main.tf
#checkov:skip=CKV_AWS_18:test
resource "aws_s3_bucket" "foo" {
  bucket = "foo-bucket"
}
checkov -d . --compact --quiet 2>&1 | grep "Skipped checks\|CKV_AWS_18"

You should see Skipped checks: 0 and CKV_AWS_18 still in the FAILED list. Now move the comment inside the block:

resource "aws_s3_bucket" "foo" {
  # checkov:skip=CKV_AWS_18:test
  bucket = "foo-bucket"
}

Run the same command again — Skipped checks: 1, and CKV_AWS_18 disappears from FAILED. You confirmed with your own hands the same behavior this lesson documented.

Exercise 2 — Decide, without running any command, whether AWS-0025/CKV_AWS_119 (DynamoDB without a KMS CMK) should be fixed, suppressed, or accepted. Using this lesson's criteria — cost of the fix, business relevance, availability in the $0 lab —, argue your choice.

See solution

Accept, named, not suppressed — the same treatment AWS-0132 gets in lesson 8's project. Fixing it would require creating and managing a customer-managed KMS key, with a real recurring cost on a genuine AWS account and no confirmed equivalent in LocalStack Hobby — it's not a one-line fix like Part 1's in this lesson. Suppressing it outright would hide a real finding without a scope justification as clear as AWS-0089's (missing destination bucket) or CKV_AWS_144's (single-region architecture by design). The right answer is to leave it visible on every run, documented as a pending cost/benefit decision, exactly the third path this lesson names.

Exercise 3 — Write the complete .trivyignore entry for a hypothetical AWS-0999 finding about a logs bucket, with a two-line reason. Without running any command, write the complete block, following this lesson's Part 3 format exactly.

See solution
# AWS-0999: logs bucket without versioning.
# Logs are write-only and rotated every 30 days via lifecycle policy;
# versioning objects that are never overwritten adds no real additional protection.
AWS-0999

The pattern is the same one this lesson already used for AWS-0089: # comments explaining the reason, as many lines as needed, followed by the check's exact ID on its own line, with no additional prefix.


Summary and next step

In this lesson you applied the three legitimate responses to a scanner finding: you fixed AWS-0024/CKV_AWS_28 with a four-line block in dynamodb.tf, confirmed with both tools re-run; you suppressed CKV_AWS_144 with a documented comment — discovering, by actually doing it, that the comment's correct location is inside the resource block, not above it — and AWS-0089 with a documented .trivyignore; and you named the third path, accept without suppressing, reserved for real findings with no cheap fix and no scope justification as clear as the others. Trivy's count dropped from eleven to nine findings; Checkov's, from fifteen failed to thirteen failed plus one skipped with a reason.

Before moving on you should be able to: decide, for any new finding, which of the three responses applies; write a #checkov:skip= comment in the correct location without having to test it twice; and explain the difference between a suppressed finding and a resolved one without hesitating.

Lesson 7 takes this exact same scan — now with nine Trivy findings instead of eleven — into cicd-and-gitops-on-aws-guide's inherited pipeline, as a new ci.yml step run with act.

Resources

  1. www.checkov.io — Suppressing and Skipping Policies — official #checkov:skip= syntax, including the correct location inside the block.
  2. trivy.dev — Filtering — official .trivyignore documentation and its format.
  3. This course, Module 1, lesson 8 — RISK-MAP.md, the document this lesson updates with three new rows.
  4. This course, Module 7 (coming up) — CloudTrail, the detective control that partially complements this lesson's suppression of AWS-0089.