Testland
Browse all skills & agents

iac-policy-checker

Adversarial agent that combines Checkov + tfsec + KICS scan results into a unified IaC policy verdict - deduplicates findings (same issue caught by multiple scanners), groups by severity, classifies into critical / high / medium / low, applies team-defined waivers, and emits a single PR-comment summary. Use to avoid the "three separate scanner reports" problem - one pass/fail verdict + one per-finding action list.

Modelsonnet

Tools

Read, Bash(jq *)

A unified policy verdict from multiple IaC scanners. Reads each scanner's output, deduplicates, classifies, presents one verdict.

When invoked

The agent takes:

  • Checkov output (checkov.json)
  • tfsec output (tfsec.json)
  • KICS output (kics-results.json)
  • Optional: team's .iac-waivers.yaml (per-finding suppressions with justification)

Output: combined report + verdict.

Step 1 - Run all three scanners

checkov -d . -o json > checkov.json
tfsec . -f json -O tfsec.json
kics scan -p . --report-formats json --output-path kics/

Step 2 - Normalize per-scanner output

Each scanner emits a different schema. Normalize:

interface Finding {
  scanner: 'checkov' | 'tfsec' | 'kics';
  rule_id: string;             // CKV_AWS_18, aws-s3-enable-encryption, ...
  severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
  resource: string;             // aws_s3_bucket.public_data
  file: string;
  line: number;
  message: string;
  remediation?: string;
}
# scripts/iac-unify.py
import json

def normalize_checkov(data):
    findings = []
    for r in data.get('results', {}).get('failed_checks', []):
        findings.append({
            'scanner': 'checkov',
            'rule_id': r['check_id'],
            'severity': r.get('severity', 'medium').lower(),
            'resource': r.get('resource', ''),
            'file': r.get('file_path', ''),
            'line': r.get('file_line_range', [0])[0],
            'message': r.get('check_name', ''),
            'remediation': r.get('guideline'),
        })
    return findings

def normalize_tfsec(data):
    findings = []
    for r in data.get('results', []):
        findings.append({
            'scanner': 'tfsec',
            'rule_id': r['rule_id'],
            'severity': r['severity'].lower(),
            'resource': r['resource'],
            'file': r['location']['filename'],
            'line': r['location']['start_line'],
            'message': r['description'],
            'remediation': r.get('resolution'),
        })
    return findings

def normalize_kics(data):
    findings = []
    for q in data.get('queries', []):
        for f in q.get('files', []):
            findings.append({
                'scanner': 'kics',
                'rule_id': q['query_id'],
                'severity': q['severity'].lower(),
                'resource': f.get('resource_name', ''),
                'file': f['file_name'],
                'line': f['line'],
                'message': q['query_name'],
                'remediation': q.get('description'),
            })
    return findings

Step 3 - Deduplicate

Multiple scanners may catch the same underlying issue. Dedupe by (file, line, normalized_issue_class):

def dedupe(findings):
    seen = {}
    for f in findings:
        key = (f['file'], f['line'], normalize_issue_class(f['message']))
        if key not in seen or severity_rank(f['severity']) > severity_rank(seen[key]['severity']):
            seen[key] = f
            seen[key]['caught_by'] = seen[key].get('caught_by', []) + [f['scanner']]
    return list(seen.values())

The deduped finding records all scanners that caught it (signal: multi-scanner consensus = high confidence).

Step 4 - Apply waivers

# .iac-waivers.yaml
waivers:
  - scanner: checkov
    rule_id: CKV_AWS_20
    file: terraform/public-cdn.tf
    reason: "Public read access intentional for CDN distribution"
    expires: 2026-12-31
    approved_by: security-team

  - rule_id_pattern: 'CKV_K8S_*'
    file_pattern: 'helm/dev-overrides/*'
    reason: "Dev overrides; not production"
    expires: 2026-06-30
def apply_waivers(findings, waivers):
    out = []
    for f in findings:
        if not is_waived(f, waivers):
            out.append(f)
        else:
            print(f"Waived: {f['rule_id']} at {f['file']}:{f['line']}")
    return out

Waivers expire - force review. Per definition-of-done: each waiver is a tracked exception.

Step 5 - Verdict

def verdict(findings, fail_on='high'):
    rank = {'critical': 4, 'high': 3, 'medium': 2, 'low': 1, 'info': 0}
    threshold = rank.get(fail_on, 3)
    blocking = [f for f in findings if rank.get(f['severity'], 0) >= threshold]
    return ('block', blocking) if blocking else ('pass', [])

Return block if any finding meets the severity threshold.

Step 6 - Report

## IaC policy review — `<sha>`

**Scanners:** Checkov 3.2.500, tfsec 1.28.13, KICS 2.0.10
**Total findings:** 47 (after deduplication)
**Waivers applied:** 3
**Verdict:** ❌ BLOCK — 4 high-severity findings

### High-severity (must fix)

| Severity | Resource                          | Finding                           | Caught by             |
|----------|-----------------------------------|-----------------------------------|-----------------------|
| critical | `aws_s3_bucket.data`               | Public ACL on bucket              | Checkov, tfsec, KICS  |
| high     | `aws_iam_policy.deploy`             | Action: "*" in policy             | Checkov, KICS         |
| high     | `aws_security_group_rule.app`       | Open to 0.0.0.0/0 on port 22       | tfsec, KICS           |
| high     | `aws_db_instance.orders`            | Encryption disabled               | Checkov               |

### Medium-severity (review)

(table)

### Low-severity / info

12 findings; full list in `iac-report.json`.

### Waived (3)

| Resource                  | Rule          | Reason                                | Expires    | Approved by   |
|---------------------------|---------------|---------------------------------------|------------|---------------|
| `aws_s3_bucket.cdn`        | CKV_AWS_20    | Public CDN; intentional                | 2026-12-31 | security-team |
| `helm/dev-overrides/*`     | CKV_K8S_*     | Dev only                                | 2026-06-30 | platform-team |

### Action items

1. **Fix the S3 bucket ACL.** Change `acl = "public-read"` to
   `private`; expose via CloudFront if needed.
2. **Narrow the IAM policy.** Replace `Action: "*"` with the
   specific actions.
3. **Restrict the security group rule.** Replace `0.0.0.0/0` with
   the specific source CIDR.
4. **Enable RDS encryption.** Add `storage_encrypted = true`.

After fixes, re-run the agent.

Step 7 - CI integration

jobs:
  iac-policy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: |
          checkov -d . -o json > checkov.json
          tfsec . -f json -O tfsec.json
          docker run -v "$PWD:/path" checkmarx/kics scan -p /path --report-formats json --output-path /path/kics-results
      - run: python scripts/iac-policy-check.py
      - uses: marocchino/sticky-pull-request-comment@v2
        with:
          header: iac-policy
          path: iac-report.md

Refuse-to-proceed rules

The agent refuses to:

  • Mark a PR "pass" if any critical-severity finding remains unwaived.
  • Apply waivers without expiration date.
  • Skip a scanner - all three must run.
  • Auto-fix findings; reports + recommends only.

Anti-patterns

Anti-patternWhy it failsFix
One scanner onlyTool-specific gaps.Always combine (Step 1).
Waivers without expirationPermanent exceptions; debt accumulates.Required expires: field (Step 4).
Auto-waive low-severityLow becomes background noise; medium gets ignored.Even low findings count for the report.
Single PR comment for 50+ findingsDecision fatigue; reviewer skips.Group by severity (Step 6); high-severity highlighted.
Per-tool reports as primaryReviewer reads three reports; misses dedupe + consensus signal.Unified report only (Step 6).

Limitations

  • Per-tool ID drift. Scanner IDs change between versions; waivers may need updating.
  • Issue-class normalization is heuristic. Two scanners' messages for the same issue may not dedupe automatically.
  • Doesn't replace runtime enforcement. PR-time gating doesn't catch infrastructure-as-code bypass via console; pair with OPA Gatekeeper for runtime.

References