feat(P23): tagging standard + Wiz adapter + Kyverno adapter
Phase 23 (v1.7) — tagging standards and security adapters.
* schemas/tagging-standard.json (D-054): canonical required-tags schema
(acdl:owner, acdl:contract, acdl:environment, acdl:cost-center).
* adapters/terraform/policy/custom_rules/acdl_tagging.py: Checkov custom
rule (ACDL_TAG_NAMING) loaded via --external-checks-dir; closes D-043
(synthetic SKIPPED record replaced by real PASS/FAIL records).
* checkov_adapter.py: removed _emit_tag_naming_skipped(), added
ACDL_TAG_NAMING to RULE_MAP, updated docstring.
* scripts/run_platform.sh: both Checkov invocations pass
--external-checks-dir adapters/terraform/policy/custom_rules/.
* adapters/wiz/ (D-052): Wiz adapter translating issue records to
PolicyCheckResult (engine: "wiz"); graceful degradation emits
WIZ_NOT_CONFIGURED SKIPPED when unconfigured; is_configured() gate.
* adapters/kyverno/ (D-053): Kyverno adapter translating PolicyReport
results to PolicyCheckResult (engine: "kyverno"); ready but inactive
for Terraform-only stacks; 3 sample ClusterPolicies in policies/.
* schemas/policy_check_result.schema.json: engine enum += "wiz".
* tests: fixtures + test_wiz_adapter.py (8 tests) + test_kyverno_adapter.py
(13 tests); updated test_checkov_adapter.py to not expect the removed
synthetic ACDL_TAG_NAMING SKIPPED record.
* scripts/run_ci.sh: lint stage compiles the new adapter modules.
202 tests pass; CI pipeline OK (lint + test + check-only).
Deviations:
- Wiz adapt() had an AttributeError on bare-list top-level input
(data.get() on a list); fixed to dispatch on isinstance(data, list)
before calling .get(). No spec change — bare-list handling is implied
by the original docstring's "data if isinstance(data, list)" branch.
- Kyverno _to_pcr({}) defaults result to "skipped" (entry.get("result",
"skip") -> "skip"), not "error"; test expectation corrected. Added an
explicit unknown-result-string test to cover the "error" fallback.
---ci---
project: acdl
phase: 23
milestone: v1.7
status: execute
---/ci---
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# Kyverno Adapter
|
||||
|
||||
The Kyverno adapter translates Kyverno `PolicyReport` results to the
|
||||
normalized ACDL
|
||||
[`PolicyCheckResult`](../../schemas/policy_check_result.schema.json) schema
|
||||
(engine: `"kyverno"`), mirroring the Checkov/Wiz adapter pattern.
|
||||
|
||||
## What Kyverno is
|
||||
|
||||
[Kyverno](https://kyverno.io/) is a Kubernetes-native policy engine. It
|
||||
runs as an admission controller inside a cluster, validates / mutates /
|
||||
generates K8s resources against declarative `ClusterPolicy` rules, and
|
||||
publishes results to `PolicyReport` resources.
|
||||
|
||||
## When to use it
|
||||
|
||||
Kyverno is the right engine **when the platform emits Kubernetes
|
||||
manifests** (a K8s-native stack). The ACDL platform today emits Terraform
|
||||
only (D-053), so this adapter is **ready but inactive**: it ships now so
|
||||
the schema path, severity/result mapping and sample policies are in place
|
||||
ahead of the GitOps reconciler that will emit K8s manifests (roadmap).
|
||||
|
||||
## How the adapter translates PolicyReport results
|
||||
|
||||
`kyverno_adapter.py <policyreport.json> <contract-id>` reads a JSON file
|
||||
containing a Kyverno `PolicyReport` (or just its `.results[]` array) and
|
||||
emits a list of `PolicyCheckResult` dicts:
|
||||
|
||||
| Kyverno PolicyReport result field | PolicyCheckResult field |
|
||||
|-----------------------------------|-------------------------|
|
||||
| `policy` | `ruleId` (default `KYVERNO_UNKNOWN`) |
|
||||
| `severity` | `severity` (lower-cased, mapped) |
|
||||
| `result` | `result` (`pass`/`fail`/`error` as-is, `warn`/`skip`→`skipped`) |
|
||||
| `message` | `message` |
|
||||
| `resource` | `resourceRef` + `evidence.resource` |
|
||||
| `namespace`, `kind`, `name` | `evidence.*` |
|
||||
|
||||
The adapter is read-only against a local JSON fixture; the GitOps
|
||||
reconciler is responsible for fetching the live `PolicyReport` and writing
|
||||
the file. When there are zero results, the adapter returns an empty list
|
||||
(unlike Wiz it does not synthesize a SKIPPED record — Kyverno not running
|
||||
is a deployment state, not a configuration gap).
|
||||
|
||||
## Roadmap dependency
|
||||
|
||||
This adapter activates when the GitOps reconciler (roadmap) emits K8s
|
||||
manifests. Until then it is documentation-only; the pipeline does not
|
||||
invoke it. The `engine: "kyverno"` enum value is present in
|
||||
`schemas/policy_check_result.schema.json` so future records validate.
|
||||
|
||||
## Sample policies
|
||||
|
||||
The `policies/` directory holds three valid Kyverno `ClusterPolicy`
|
||||
manifests (documentation-only today — the platform does not run them):
|
||||
|
||||
- `disallow-privileged-containers.yaml` — fail pods with
|
||||
`securityContext.privileged: true`.
|
||||
- `require-resource-labels.yaml` — require `acdl:owner` and
|
||||
`acdl:environment` labels on all pods (mirrors the ACDL tagging standard
|
||||
in [`schemas/tagging-standard.json`](../../schemas/tagging-standard.json)).
|
||||
- `require-image-digests.yaml` — require container images to reference a
|
||||
digest (`image@sha256:...`), not a mutable tag.
|
||||
|
||||
## Schema path
|
||||
|
||||
The output records validate against
|
||||
[`schemas/policy_check_result.schema.json`](../../schemas/policy_check_result.schema.json)
|
||||
(`engine: "kyverno"` was already in the enum and is retained in Phase 23).
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Kyverno adapter — translate Kyverno PolicyReport results to ACDL PolicyCheckResult records.
|
||||
|
||||
Kyverno is a Kubernetes-native policy engine. It evaluates K8s manifests
|
||||
and produces PolicyReport resources. This adapter translates those results
|
||||
to the normalized PolicyCheckResult schema (engine: "kyverno").
|
||||
|
||||
D-053: the platform emits Terraform, not K8s manifests. This adapter is
|
||||
ready but inactive for Terraform-only stacks. It activates when the GitOps
|
||||
reconciler (roadmap) emits K8s manifests. Sample policies are included as
|
||||
documentation at adapters/kyverno/policies/.
|
||||
|
||||
CLI: kyverno_adapter.py <policyreport.json> <contract-id>
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
SEVERITY_MAP = {
|
||||
"critical": "critical",
|
||||
"high": "high",
|
||||
"medium": "medium",
|
||||
"low": "low",
|
||||
"info": "info",
|
||||
}
|
||||
|
||||
RESULT_MAP = {
|
||||
"pass": "pass",
|
||||
"fail": "fail",
|
||||
"warn": "skipped",
|
||||
"error": "error",
|
||||
"skip": "skipped",
|
||||
}
|
||||
|
||||
|
||||
def _iso8601_now():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _to_pcr(entry, contract_id):
|
||||
severity_raw = entry.get("severity", "info")
|
||||
severity = SEVERITY_MAP.get(str(severity_raw).lower(), "info")
|
||||
result_raw = entry.get("result", "skip")
|
||||
result = RESULT_MAP.get(str(result_raw).lower(), "error")
|
||||
return {
|
||||
"contractId": contract_id,
|
||||
"evaluatedAt": _iso8601_now(),
|
||||
"engine": "kyverno",
|
||||
"ruleId": entry.get("policy", "KYVERNO_UNKNOWN"),
|
||||
"severity": severity,
|
||||
"result": result,
|
||||
"message": entry.get("message", ""),
|
||||
"evidence": {
|
||||
"resource": entry.get("resource", ""),
|
||||
"namespace": entry.get("namespace", ""),
|
||||
"kind": entry.get("kind", ""),
|
||||
"name": entry.get("name", ""),
|
||||
},
|
||||
"resourceRef": entry.get("resource", ""),
|
||||
}
|
||||
|
||||
|
||||
def adapt(policyreport_json_path, contract_id):
|
||||
with open(policyreport_json_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
out = []
|
||||
# Kyverno PolicyReport has a .results[] array
|
||||
results = data.get("results", [])
|
||||
if not isinstance(results, list):
|
||||
results = []
|
||||
for entry in results:
|
||||
out.append(_to_pcr(entry, contract_id))
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: kyverno_adapter.py <policyreport.json> <contract-id>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(json.dumps(adapt(sys.argv[1], sys.argv[2]), indent=2))
|
||||
@@ -0,0 +1,27 @@
|
||||
apiVersion: kyverno.io/v1
|
||||
kind: ClusterPolicy
|
||||
metadata:
|
||||
name: disallow-privileged-containers
|
||||
annotations:
|
||||
policies.kyverno.io/title: Disallow Privileged Containers
|
||||
policies.kyverno.io/category: Security
|
||||
policies.kyverno.io/severity: high
|
||||
policies.kyverno.io/subject: Pod
|
||||
spec:
|
||||
validationFailureAction: audit
|
||||
background: true
|
||||
rules:
|
||||
- name: require-non-privileged
|
||||
match:
|
||||
any:
|
||||
- resources:
|
||||
kinds:
|
||||
- Pod
|
||||
validate:
|
||||
message: "Privileged containers are not allowed. Set securityContext.privileged to false."
|
||||
pattern:
|
||||
spec:
|
||||
containers:
|
||||
- name: "*"
|
||||
securityContext:
|
||||
privileged: "false"
|
||||
@@ -0,0 +1,26 @@
|
||||
apiVersion: kyverno.io/v1
|
||||
kind: ClusterPolicy
|
||||
metadata:
|
||||
name: require-image-digests
|
||||
annotations:
|
||||
policies.kyverno.io/title: Require Image Digests
|
||||
policies.kyverno.io/category: Supply Chain
|
||||
policies.kyverno.io/severity: high
|
||||
policies.kyverno.io/subject: Pod
|
||||
spec:
|
||||
validationFailureAction: audit
|
||||
background: true
|
||||
rules:
|
||||
- name: require-digest-reference
|
||||
match:
|
||||
any:
|
||||
- resources:
|
||||
kinds:
|
||||
- Pod
|
||||
validate:
|
||||
message: "Container images must reference a digest (e.g. image@sha256:...), not a mutable tag."
|
||||
pattern:
|
||||
spec:
|
||||
containers:
|
||||
- name: "*"
|
||||
image: "*@sha256:*"
|
||||
@@ -0,0 +1,37 @@
|
||||
apiVersion: kyverno.io/v1
|
||||
kind: ClusterPolicy
|
||||
metadata:
|
||||
name: require-resource-labels
|
||||
annotations:
|
||||
policies.kyverno.io/title: Require ACDL Resource Labels
|
||||
policies.kyverno.io/category: Governance
|
||||
policies.kyverno.io/severity: medium
|
||||
policies.kyverno.io/subject: Pod
|
||||
spec:
|
||||
validationFailureAction: audit
|
||||
background: true
|
||||
rules:
|
||||
- name: require-acdl-owner-label
|
||||
match:
|
||||
any:
|
||||
- resources:
|
||||
kinds:
|
||||
- Pod
|
||||
validate:
|
||||
message: "Pods must carry the acdl:owner label (ACDL tagging standard)."
|
||||
pattern:
|
||||
metadata:
|
||||
labels:
|
||||
acdl:owner: "?*"
|
||||
- name: require-acdl-environment-label
|
||||
match:
|
||||
any:
|
||||
- resources:
|
||||
kinds:
|
||||
- Pod
|
||||
validate:
|
||||
message: "Pods must carry the acdl:environment label (ACDL tagging standard)."
|
||||
pattern:
|
||||
metadata:
|
||||
labels:
|
||||
acdl:environment: "?*"
|
||||
@@ -6,8 +6,10 @@ schemas/policy_check_result.schema.json. Run Checkov with --soft-fail so
|
||||
Checkov never exits non-zero; the confidence signal decides the gate, not
|
||||
Checkov's exit code.
|
||||
|
||||
Spike scope (D-043): tag/naming is a single SKIPPED record. A custom
|
||||
Checkov YAML rule for tag presence lands in v1.2.
|
||||
The ACDL tagging standard (D-054, D-043 closure) is enforced by a custom
|
||||
Checkov rule at adapters/terraform/policy/custom_rules/acdl_tagging.py,
|
||||
loaded via --external-checks-dir. The adapter therefore maps
|
||||
ACDL_TAG_NAMING as a real rule (no synthetic SKIPPED record is emitted).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
@@ -27,6 +29,10 @@ RULE_MAP = {
|
||||
"CKV_AWS_40": ("iam-wildcard", "medium"),
|
||||
"CKV_AWS_7": ("kms-key-reference", "medium"),
|
||||
"CKV_AWS_33": ("kms-key-reference", "medium"),
|
||||
# D-054 / D-043 closure: ACDL_TAG_NAMING is now a real custom Checkov
|
||||
# rule (adapters/terraform/policy/custom_rules/acdl_tagging.py), loaded
|
||||
# via --external-checks-dir. No synthetic SKIPPED record is emitted.
|
||||
"ACDL_TAG_NAMING": ("tagging-standard", "medium"),
|
||||
}
|
||||
|
||||
_RESULT_MAP = {"PASSED": "pass", "FAILED": "fail", "SKIPPED": "skipped"}
|
||||
@@ -60,20 +66,6 @@ def _to_pcr(checkov_record, contract_id, result_str):
|
||||
}
|
||||
|
||||
|
||||
def _emit_tag_naming_skipped(contract_id):
|
||||
return {
|
||||
"contractId": contract_id,
|
||||
"evaluatedAt": _iso8601_now(),
|
||||
"engine": "checkov",
|
||||
"ruleId": "ACDL_TAG_NAMING",
|
||||
"severity": "info",
|
||||
"result": "skipped",
|
||||
"message": "tag/naming check deferred to v1.2 (D-043)",
|
||||
"evidence": {},
|
||||
"resourceRef": "",
|
||||
}
|
||||
|
||||
|
||||
def adapt(checkov_json_path, contract_id):
|
||||
with open(checkov_json_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
@@ -88,7 +80,6 @@ def adapt(checkov_json_path, contract_id):
|
||||
out.append(_to_pcr(rec, contract_id, "FAILED"))
|
||||
for rec in results.get("skipped_checks", []):
|
||||
out.append(_to_pcr(rec, contract_id, "SKIPPED"))
|
||||
out.append(_emit_tag_naming_skipped(contract_id))
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# ACDL Custom Checkov Rules
|
||||
|
||||
This directory holds ACDL-authored Checkov custom rules, written in the
|
||||
[Checkov Python custom-rule framework](https://www.checkov.io/4.Contributing/Custom%20Policies.html).
|
||||
|
||||
## Files
|
||||
|
||||
- `acdl_tagging.py` — `ACDL_TAG_NAMING` (D-054): ensures every taggable AWS
|
||||
resource carries the four required ACDL tags
|
||||
(`acdl:owner`, `acdl:contract`, `acdl:environment`, `acdl:cost-center`).
|
||||
This rule replaces the synthetic SKIPPED `ACDL_TAG_NAMING` record that the
|
||||
Checkov adapter previously emitted (D-043 closure). The canonical tag set
|
||||
is declared in [`schemas/tagging-standard.json`](../../../schemas/tagging-standard.json).
|
||||
|
||||
## How Checkov loads them
|
||||
|
||||
Checkov custom rules are discovered via the `--external-checks-dir` flag.
|
||||
`scripts/run_platform.sh` invokes Checkov with:
|
||||
|
||||
```
|
||||
checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail \
|
||||
--external-checks-dir adapters/terraform/policy/custom_rules/
|
||||
```
|
||||
|
||||
Checkov imports each `*.py` file in the directory and instantiates the
|
||||
module-level `check` object (see the `check = AcdlTaggingStandard()` line at
|
||||
the bottom of `acdl_tagging.py`).
|
||||
|
||||
## Severity / result mapping
|
||||
|
||||
The Checkov adapter (`adapters/terraform/policy/checkov_adapter.py`)
|
||||
maps `ACDL_TAG_NAMING` to `(tagging-standard, medium)` in `RULE_MAP`. The
|
||||
custom rule therefore produces real `PASS`/`FAIL` PolicyCheckResult records,
|
||||
feeding the confidence signal instead of the old SKIPPED placeholder.
|
||||
@@ -0,0 +1,55 @@
|
||||
"""ACDL tagging standard custom Checkov rule (D-054).
|
||||
|
||||
Checks that all taggable AWS resources have the required ACDL tags:
|
||||
acdl:owner, acdl:contract, acdl:environment, acdl:cost-center
|
||||
|
||||
Fails (severity medium) when any required tag is missing.
|
||||
Closes the D-043 deferral (the SKIPPED ACDL_TAG_NAMING placeholder
|
||||
becomes a real check).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
|
||||
from checkov.common.models.enums import CheckResult, CheckCategories
|
||||
from checkov.common.models.consts import graph_resource_name_utils
|
||||
|
||||
REQUIRED_TAGS = ("acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center")
|
||||
|
||||
# Resources that support tags (exclude resources that have no tags attribute)
|
||||
NON_TAGGABLE_TYPES = (
|
||||
"aws_cloudfront_origin_access_control",
|
||||
"aws_lambda_function_url",
|
||||
"aws_route_table_association",
|
||||
"aws_internet_gateway",
|
||||
)
|
||||
|
||||
class AcdlTaggingStandard(BaseResourceCheck):
|
||||
def __init__(self):
|
||||
name = "Ensure all taggable AWS resources have required ACDL tags"
|
||||
check_id = "ACDL_TAG_NAMING"
|
||||
supported_resources = ["*"] # all resources
|
||||
categories = [CheckCategories.GENERAL_SECURITY]
|
||||
super().__init__(name=name, check_id=check_id, categories=categories, supported_resources=supported_resources)
|
||||
|
||||
def scan_resource_conf(self, conf, entity_type):
|
||||
# Skip non-taggable resources
|
||||
if entity_type in NON_TAGGABLE_TYPES:
|
||||
return CheckResult.PASSED
|
||||
# Check for a tags block
|
||||
tags = conf.get("tags")
|
||||
if not tags:
|
||||
return CheckResult.FAILED
|
||||
tag_keys = set()
|
||||
if isinstance(tags, list) and tags:
|
||||
tag_block = tags[0]
|
||||
if isinstance(tag_block, dict):
|
||||
tag_keys = set(tag_block.keys())
|
||||
elif isinstance(tags, dict):
|
||||
tag_keys = set(tags.keys())
|
||||
missing = [t for t in REQUIRED_TAGS if t not in tag_keys]
|
||||
if missing:
|
||||
return CheckResult.FAILED
|
||||
return CheckResult.PASSED
|
||||
|
||||
check = AcdlTaggingStandard()
|
||||
@@ -0,0 +1,55 @@
|
||||
# Wiz Adapter
|
||||
|
||||
The Wiz adapter translates Wiz API issue records to the normalized ACDL
|
||||
[`PolicyCheckResult`](../../schemas/policy_check_result.schema.json) schema
|
||||
(engine: `"wiz"`), mirroring the Checkov adapter pattern.
|
||||
|
||||
## What Wiz is
|
||||
|
||||
[Wiz](https://www.wiz.io/) is a cloud security SaaS platform that
|
||||
continuously scans CSPM / CWPP / KSPM findings across AWS, Azure, GCP and
|
||||
Kubernetes. It exposes a GraphQL/REST API for fetching issue records.
|
||||
|
||||
## Adapter behaviour
|
||||
|
||||
`wiz_adapter.py <wiz_issues.json> <contract-id>` reads a JSON file of Wiz
|
||||
issue records (the shape returned by the Wiz `issues` GraphQL query /
|
||||
list endpoint) and emits a list of `PolicyCheckResult` dicts:
|
||||
|
||||
| Wiz field | PolicyCheckResult field |
|
||||
|------------------|------------------------------------------------------------|
|
||||
| `id` / `control.id` | `ruleId` |
|
||||
| `severity` | `severity` (mapped `CRITICAL/HIGH/MEDIUM/LOW/INFO`) |
|
||||
| `status` | `result` (`OPEN→fail`, `RESOLVED→pass`, `IN_PROGRESS/DISMISSED→skipped`) |
|
||||
| `title` / `control.name` | `message` |
|
||||
| `entity.id` | `resourceRef` + `evidence.resource` |
|
||||
| `entity.{name,cloudPlatform,subscriptionId}` | `evidence.*` |
|
||||
|
||||
The adapter is read-only against a local JSON fixture; the pipeline is
|
||||
responsible for fetching from Wiz (when configured) and writing the file.
|
||||
|
||||
## Offline / degraded behaviour (D-052)
|
||||
|
||||
When Wiz is not configured the pipeline passes an empty issues payload (or
|
||||
simply does not invoke the adapter). The adapter degrades gracefully:
|
||||
|
||||
- an empty `issues` list → the adapter emits a single `WIZ_NOT_CONFIGURED`
|
||||
`PolicyCheckResult` with `result: "skipped"` so the confidence policy
|
||||
input stays non-empty (and does not falsely inflate the score).
|
||||
|
||||
`is_configured()` returns `True` only when the `WIZ_API_TOKEN`
|
||||
environment variable is set; the pipeline uses it to decide whether to
|
||||
fetch and invoke the adapter at all.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Required | Purpose |
|
||||
|-----------------|----------|--------------------------------------------------|
|
||||
| `WIZ_API_TOKEN` | yes | Bearer token for the Wiz REST API. When unset, `is_configured()` returns `False`. |
|
||||
| `WIZ_ENDPOINT` | no | Wiz API endpoint (defaults to `https://api.wiz.io` when implemented). |
|
||||
|
||||
## Schema path
|
||||
|
||||
The output records validate against
|
||||
[`schemas/policy_check_result.schema.json`](../../schemas/policy_check_result.schema.json)
|
||||
(`engine: "wiz"` was added to the enum in Phase 23).
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Wiz adapter — translate Wiz API results to ACDL PolicyCheckResult records.
|
||||
|
||||
Wiz is a SaaS security platform with a REST API (issues, security graph
|
||||
queries). This adapter translates Wiz issue records to the normalized
|
||||
PolicyCheckResult schema (engine: "wiz"), matching the Checkov adapter
|
||||
pattern.
|
||||
|
||||
D-052: stub + schema path. The adapter degrades gracefully when Wiz is
|
||||
not configured — it emits a single SKIPPED record (WIZ_NOT_CONFIGURED)
|
||||
so the confidence policy input stays non-empty. The pipeline invokes it
|
||||
optionally when WIZ_API_TOKEN is set.
|
||||
|
||||
CLI: wiz_adapter.py <wiz_issues.json> <contract-id>
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
SEVERITY_MAP = {
|
||||
"CRITICAL": "critical",
|
||||
"HIGH": "high",
|
||||
"MEDIUM": "medium",
|
||||
"LOW": "low",
|
||||
"INFO": "info",
|
||||
}
|
||||
|
||||
RESULT_MAP = {
|
||||
"OPEN": "fail",
|
||||
"RESOLVED": "pass",
|
||||
"IN_PROGRESS": "skipped",
|
||||
"DISMISSED": "skipped",
|
||||
}
|
||||
|
||||
|
||||
def _iso8601_now():
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _to_pcr(wiz_issue, contract_id):
|
||||
severity_raw = wiz_issue.get("severity", "INFO")
|
||||
severity = SEVERITY_MAP.get(str(severity_raw).upper(), "info")
|
||||
status = wiz_issue.get("status", "OPEN")
|
||||
result = RESULT_MAP.get(str(status).upper(), "error")
|
||||
control = wiz_issue.get("control", {})
|
||||
return {
|
||||
"contractId": contract_id,
|
||||
"evaluatedAt": _iso8601_now(),
|
||||
"engine": "wiz",
|
||||
"ruleId": wiz_issue.get("id", control.get("id", "WIZ_UNKNOWN")),
|
||||
"severity": severity,
|
||||
"result": result,
|
||||
"message": wiz_issue.get("title", control.get("name", "")),
|
||||
"evidence": {
|
||||
"resource": wiz_issue.get("entity", {}).get("id"),
|
||||
"resource_name": wiz_issue.get("entity", {}).get("name"),
|
||||
"cloud_platform": wiz_issue.get("entity", {}).get("cloudPlatform"),
|
||||
"subscription_id": wiz_issue.get("entity", {}).get("subscriptionId"),
|
||||
},
|
||||
"resourceRef": wiz_issue.get("entity", {}).get("id", ""),
|
||||
}
|
||||
|
||||
|
||||
def _emit_not_configured(contract_id):
|
||||
return {
|
||||
"contractId": contract_id,
|
||||
"evaluatedAt": _iso8601_now(),
|
||||
"engine": "wiz",
|
||||
"ruleId": "WIZ_NOT_CONFIGURED",
|
||||
"severity": "info",
|
||||
"result": "skipped",
|
||||
"message": "Wiz adapter not configured (WIZ_API_TOKEN not set); degraded gracefully (D-052).",
|
||||
"evidence": {},
|
||||
"resourceRef": "",
|
||||
}
|
||||
|
||||
|
||||
def adapt(wiz_json_path, contract_id):
|
||||
with open(wiz_json_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
out = []
|
||||
# Accept either a bare list of issues or an object with an "issues" key.
|
||||
if isinstance(data, list):
|
||||
issues = data
|
||||
else:
|
||||
issues = data.get("issues", [])
|
||||
if not isinstance(issues, list):
|
||||
issues = []
|
||||
for issue in issues:
|
||||
out.append(_to_pcr(issue, contract_id))
|
||||
if not out:
|
||||
out.append(_emit_not_configured(contract_id))
|
||||
return out
|
||||
|
||||
|
||||
def is_configured():
|
||||
return bool(os.environ.get("WIZ_API_TOKEN"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: wiz_adapter.py <wiz_issues.json> <contract-id>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print(json.dumps(adapt(sys.argv[1], sys.argv[2]), indent=2))
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"engine": {
|
||||
"type": "string",
|
||||
"enum": ["checkov", "kyverno", "opa"],
|
||||
"enum": ["checkov", "kyverno", "opa", "wiz"],
|
||||
"description": "Policy engine that produced this result."
|
||||
},
|
||||
"ruleId": {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://acdl.dev/schemas/tagging-standard.json",
|
||||
"title": "ACDL Tagging Standard",
|
||||
"description": "Required tags for all taggable AWS resources created by the platform. Enforced by a Checkov custom YAML rule (adapters/terraform/policy/custom_rules/acdl_tagging.yaml). The checkov adapter maps ACDL_TAG_NAMING as a real rule (D-054, D-043 closure).",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"required_tags": {
|
||||
"type": "object",
|
||||
"description": "The set of tags that must be present on every taggable AWS resource.",
|
||||
"properties": {
|
||||
"acdl:owner": {
|
||||
"type": "string",
|
||||
"description": "The consumer repository name (e.g. 'consumer-repo'). Injected from the ABAC session."
|
||||
},
|
||||
"acdl:contract": {
|
||||
"type": "string",
|
||||
"description": "The contract ID (UUID)."
|
||||
},
|
||||
"acdl:environment": {
|
||||
"type": "string",
|
||||
"enum": ["dev", "qa", "prod", "dr"],
|
||||
"description": "The environment name."
|
||||
},
|
||||
"acdl:cost-center": {
|
||||
"type": "string",
|
||||
"description": "The cost center (consumer-provided or platform-default 'acdl-default')."
|
||||
}
|
||||
},
|
||||
"required": ["acdl:owner", "acdl:contract", "acdl:environment", "acdl:cost-center"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"default_values": {
|
||||
"type": "object",
|
||||
"description": "Default values used when the consumer does not supply the tag.",
|
||||
"properties": {
|
||||
"acdl:cost-center": {
|
||||
"type": "string",
|
||||
"default": "acdl-default"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["required_tags"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -47,6 +47,9 @@ python3 -m py_compile \
|
||||
core/contract_resolver.py \
|
||||
adapters/terraform/adapter.py \
|
||||
adapters/terraform/policy/checkov_adapter.py \
|
||||
adapters/terraform/policy/custom_rules/acdl_tagging.py \
|
||||
adapters/wiz/wiz_adapter.py \
|
||||
adapters/kyverno/kyverno_adapter.py \
|
||||
scripts/push_consumer_image.py \
|
||||
|| fail "lint: py_compile failed"
|
||||
echo "lint: OK"
|
||||
|
||||
@@ -195,9 +195,9 @@ fi
|
||||
echo ""
|
||||
echo "=== Step 5: run Checkov on terraform/spike/main.tf ==="
|
||||
if [ "$QUIET" = "0" ]; then
|
||||
checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail 2>&1 | tee "$WORK/checkov.json"
|
||||
checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ 2>&1 | tee "$WORK/checkov.json"
|
||||
else
|
||||
checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail > "$WORK/checkov.json" 2> "$WORK/checkov.err"
|
||||
checkov -f terraform/spike/main.tf --framework terraform -o json --soft-fail --external-checks-dir adapters/terraform/policy/custom_rules/ > "$WORK/checkov.json" 2> "$WORK/checkov.err"
|
||||
fi
|
||||
[ -s "$WORK/checkov.json" ] || fail "checkov produced no output"
|
||||
echo ""
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"apiVersion": "wgpolicyk8s.io/v1alpha1",
|
||||
"kind": "PolicyReport",
|
||||
"metadata": {
|
||||
"name": "acdl-policy-report",
|
||||
"namespace": "default"
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"policy": "disallow-privileged-containers",
|
||||
"severity": "high",
|
||||
"result": "pass",
|
||||
"message": "Pod spec is compliant (no privileged container).",
|
||||
"resource": "default/Pod/acdl-app",
|
||||
"namespace": "default",
|
||||
"kind": "Pod",
|
||||
"name": "acdl-app"
|
||||
},
|
||||
{
|
||||
"policy": "require-resource-labels",
|
||||
"severity": "medium",
|
||||
"result": "fail",
|
||||
"message": "Pod missing required label acdl:owner.",
|
||||
"resource": "default/Pod/acdl-bad-app",
|
||||
"namespace": "default",
|
||||
"kind": "Pod",
|
||||
"name": "acdl-bad-app"
|
||||
},
|
||||
{
|
||||
"policy": "require-image-digests",
|
||||
"severity": "high",
|
||||
"result": "warn",
|
||||
"message": "Container image uses a mutable tag; consider pinning to a digest.",
|
||||
"resource": "default/Pod/acdl-app",
|
||||
"namespace": "default",
|
||||
"kind": "Pod",
|
||||
"name": "acdl-app"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"issues": [
|
||||
{
|
||||
"id": "wiz-issue-001",
|
||||
"title": "Publicly exposed S3 bucket with sensitive data",
|
||||
"severity": "CRITICAL",
|
||||
"status": "OPEN",
|
||||
"control": {
|
||||
"id": "wiz-control-public-s3",
|
||||
"name": "Public S3 bucket exposure"
|
||||
},
|
||||
"entity": {
|
||||
"id": "arn:aws:s3:::acdl-leaked-bucket",
|
||||
"name": "acdl-leaked-bucket",
|
||||
"cloudPlatform": "AWS",
|
||||
"subscriptionId": "111111111111"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wiz-issue-002",
|
||||
"title": "IAM role with overly broad permissions",
|
||||
"severity": "HIGH",
|
||||
"status": "RESOLVED",
|
||||
"control": {
|
||||
"id": "wiz-control-iam-broad",
|
||||
"name": "Overly broad IAM role"
|
||||
},
|
||||
"entity": {
|
||||
"id": "arn:aws:iam::111111111111:role/acdl-broad-role",
|
||||
"name": "acdl-broad-role",
|
||||
"cloudPlatform": "AWS",
|
||||
"subscriptionId": "111111111111"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "wiz-issue-003",
|
||||
"title": "Security group allows 0.0.0.0/0 on port 22",
|
||||
"severity": "MEDIUM",
|
||||
"status": "IN_PROGRESS",
|
||||
"control": {
|
||||
"id": "wiz-control-ssh-open",
|
||||
"name": "SSH open to the world"
|
||||
},
|
||||
"entity": {
|
||||
"id": "arn:aws:ec2:us-east-1:111111111111:security-group/sg-abcdef",
|
||||
"name": "acdl-ssh-sg",
|
||||
"cloudPlatform": "AWS",
|
||||
"subscriptionId": "111111111111"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from adapters.terraform.policy.checkov_adapter import (
|
||||
RULE_MAP, _to_pcr, _emit_tag_naming_skipped, adapt,
|
||||
RULE_MAP, _to_pcr, adapt,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,12 +66,11 @@ class TestToPcr:
|
||||
jsonschema.validate(pcr, policy_check_result_schema)
|
||||
|
||||
|
||||
class TestTagNamingSkipped:
|
||||
def test_skipped_pcr(self):
|
||||
pcr = _emit_tag_naming_skipped("c-1")
|
||||
assert pcr["result"] == "skipped"
|
||||
assert pcr["ruleId"] == "ACDL_TAG_NAMING"
|
||||
assert pcr["severity"] == "info"
|
||||
class TestRuleMapTagging:
|
||||
def test_acdl_tag_naming_is_real_rule(self):
|
||||
# D-054 / D-043 closure: ACDL_TAG_NAMING is now a real custom Checkov
|
||||
# rule, not a synthetic SKIPPED record.
|
||||
assert RULE_MAP["ACDL_TAG_NAMING"] == ("tagging-standard", "medium")
|
||||
|
||||
|
||||
class TestAdapt:
|
||||
@@ -99,14 +98,16 @@ class TestAdapt:
|
||||
results = adapt(str(f), "c-1")
|
||||
assert isinstance(results, list)
|
||||
|
||||
def test_adapt_includes_tag_naming(self, tmp_path):
|
||||
def test_adapt_does_not_emit_synthetic_tag_naming(self, tmp_path):
|
||||
# D-043 closure: adapt() no longer appends a synthetic SKIPPED
|
||||
# ACDL_TAG_NAMING record. The custom Checkov rule (loaded via
|
||||
# --external-checks-dir) produces real PASS/FAIL records instead.
|
||||
data = self._sample_checkov_json()
|
||||
f = tmp_path / "checkov.json"
|
||||
f.write_text(json.dumps(data))
|
||||
results = adapt(str(f), "c-1")
|
||||
tag = [r for r in results if r["ruleId"] == "ACDL_TAG_NAMING"]
|
||||
assert len(tag) == 1
|
||||
assert tag[0]["result"] == "skipped"
|
||||
assert tag == [] # no synthetic record
|
||||
|
||||
def test_adapt_has_passed_and_failed(self, tmp_path):
|
||||
data = self._sample_checkov_json()
|
||||
@@ -123,4 +124,4 @@ class TestAdapt:
|
||||
f = tmp_path / "checkov.json"
|
||||
f.write_text(json.dumps(data))
|
||||
results = adapt(str(f), "c-1")
|
||||
assert len(results) == 1 # just the tag naming skipped
|
||||
assert results == [] # no synthetic tag-naming record anymore
|
||||
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from adapters.kyverno.kyverno_adapter import (
|
||||
SEVERITY_MAP, RESULT_MAP, _to_pcr, adapt,
|
||||
)
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent.parent / "tests" / "fixtures"
|
||||
|
||||
|
||||
class TestSeverityResultMaps:
|
||||
def test_severity_map(self):
|
||||
assert SEVERITY_MAP["critical"] == "critical"
|
||||
assert SEVERITY_MAP["high"] == "high"
|
||||
assert SEVERITY_MAP["medium"] == "medium"
|
||||
assert SEVERITY_MAP["low"] == "low"
|
||||
assert SEVERITY_MAP["info"] == "info"
|
||||
|
||||
def test_result_map(self):
|
||||
assert RESULT_MAP["pass"] == "pass"
|
||||
assert RESULT_MAP["fail"] == "fail"
|
||||
assert RESULT_MAP["warn"] == "skipped"
|
||||
assert RESULT_MAP["error"] == "error"
|
||||
assert RESULT_MAP["skip"] == "skipped"
|
||||
|
||||
|
||||
class TestToPcr:
|
||||
def test_translates_pass(self):
|
||||
entry = {"policy": "p1", "severity": "high", "result": "pass",
|
||||
"message": "ok", "resource": "ns/Pod/x"}
|
||||
pcr = _to_pcr(entry, "c-1")
|
||||
assert pcr["engine"] == "kyverno"
|
||||
assert pcr["ruleId"] == "p1"
|
||||
assert pcr["severity"] == "high"
|
||||
assert pcr["result"] == "pass"
|
||||
assert pcr["resourceRef"] == "ns/Pod/x"
|
||||
|
||||
def test_warn_maps_to_skipped(self):
|
||||
entry = {"policy": "p1", "severity": "medium", "result": "warn",
|
||||
"resource": "r"}
|
||||
pcr = _to_pcr(entry, "c-1")
|
||||
assert pcr["result"] == "skipped"
|
||||
|
||||
def test_unknown_severity_defaults_info(self):
|
||||
entry = {"policy": "p1", "severity": "BOGUS", "result": "fail",
|
||||
"resource": "r"}
|
||||
pcr = _to_pcr(entry, "c-1")
|
||||
assert pcr["severity"] == "info"
|
||||
|
||||
def test_unknown_result_defaults_error(self):
|
||||
entry = {"policy": "p1", "severity": "low", "result": "BOGUS",
|
||||
"resource": "r"}
|
||||
pcr = _to_pcr(entry, "c-1")
|
||||
assert pcr["result"] == "error"
|
||||
|
||||
def test_missing_policy_defaults_unknown(self):
|
||||
entry = {"severity": "low", "result": "pass", "resource": "r"}
|
||||
pcr = _to_pcr(entry, "c-1")
|
||||
assert pcr["ruleId"] == "KYVERNO_UNKNOWN"
|
||||
|
||||
def test_pcr_validates_against_schema(self, policy_check_result_schema):
|
||||
entry = {"policy": "p1", "severity": "high", "result": "fail",
|
||||
"message": "m", "resource": "ns/Pod/x", "namespace": "ns",
|
||||
"kind": "Pod", "name": "x"}
|
||||
pcr = _to_pcr(entry, "11111111-1111-1111-1111-111111111111")
|
||||
jsonschema.validate(pcr, policy_check_result_schema)
|
||||
|
||||
|
||||
class TestAdapt:
|
||||
def test_translates_fixture(self, tmp_path, policy_check_result_schema):
|
||||
src = FIXTURES / "kyverno_policyreport.json"
|
||||
f = tmp_path / "policyreport.json"
|
||||
f.write_text(src.read_text())
|
||||
results = adapt(str(f), "11111111-1111-1111-1111-111111111111")
|
||||
assert len(results) == 3
|
||||
|
||||
# result 1: pass/high
|
||||
assert results[0]["ruleId"] == "disallow-privileged-containers"
|
||||
assert results[0]["severity"] == "high"
|
||||
assert results[0]["result"] == "pass"
|
||||
|
||||
# result 2: fail/medium
|
||||
assert results[1]["ruleId"] == "require-resource-labels"
|
||||
assert results[1]["severity"] == "medium"
|
||||
assert results[1]["result"] == "fail"
|
||||
|
||||
# result 3: warn/high -> skipped/high
|
||||
assert results[2]["ruleId"] == "require-image-digests"
|
||||
assert results[2]["severity"] == "high"
|
||||
assert results[2]["result"] == "skipped"
|
||||
|
||||
for pcr in results:
|
||||
jsonschema.validate(pcr, policy_check_result_schema)
|
||||
|
||||
def test_empty_results(self, tmp_path):
|
||||
f = tmp_path / "empty.json"
|
||||
f.write_text(json.dumps({"results": []}))
|
||||
results = adapt(str(f), "11111111-1111-1111-1111-111111111111")
|
||||
assert results == []
|
||||
|
||||
def test_missing_results_key(self, tmp_path):
|
||||
f = tmp_path / "noresults.json"
|
||||
f.write_text(json.dumps({"apiVersion": "x", "kind": "PolicyReport"}))
|
||||
results = adapt(str(f), "11111111-1111-1111-1111-111111111111")
|
||||
assert results == []
|
||||
|
||||
def test_non_list_results_treated_as_empty(self, tmp_path):
|
||||
f = tmp_path / "bad.json"
|
||||
f.write_text(json.dumps({"results": "not-a-list"}))
|
||||
results = adapt(str(f), "11111111-1111-1111-1111-111111111111")
|
||||
assert results == []
|
||||
|
||||
def test_missing_fields_in_entry(self, tmp_path, policy_check_result_schema):
|
||||
f = tmp_path / "sparse.json"
|
||||
f.write_text(json.dumps({"results": [{}]}))
|
||||
results = adapt(str(f), "11111111-1111-1111-1111-111111111111")
|
||||
assert len(results) == 1
|
||||
pcr = results[0]
|
||||
assert pcr["ruleId"] == "KYVERNO_UNKNOWN"
|
||||
assert pcr["severity"] == "info"
|
||||
# entry.get("result", "skip") -> default "skip" -> "skipped"
|
||||
assert pcr["result"] == "skipped"
|
||||
jsonschema.validate(pcr, policy_check_result_schema)
|
||||
|
||||
def test_unknown_result_string_defaults_error(self, tmp_path, policy_check_result_schema):
|
||||
# An explicit but unmapped result string falls back to "error".
|
||||
f = tmp_path / "unknownresult.json"
|
||||
f.write_text(json.dumps({"results": [
|
||||
{"policy": "p1", "severity": "low", "result": "BOGUS",
|
||||
"resource": "r"},
|
||||
]}))
|
||||
results = adapt(str(f), "11111111-1111-1111-1111-111111111111")
|
||||
assert results[0]["result"] == "error"
|
||||
jsonschema.validate(results[0], policy_check_result_schema)
|
||||
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from adapters.wiz.wiz_adapter import (
|
||||
SEVERITY_MAP, RESULT_MAP, _to_pcr, _emit_not_configured, adapt, is_configured,
|
||||
)
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent.parent / "tests" / "fixtures"
|
||||
|
||||
|
||||
class TestSeverityResultMaps:
|
||||
def test_severity_map_critical(self):
|
||||
assert SEVERITY_MAP["CRITICAL"] == "critical"
|
||||
assert SEVERITY_MAP["HIGH"] == "high"
|
||||
assert SEVERITY_MAP["MEDIUM"] == "medium"
|
||||
assert SEVERITY_MAP["LOW"] == "low"
|
||||
assert SEVERITY_MAP["INFO"] == "info"
|
||||
|
||||
def test_result_map_open_is_fail(self):
|
||||
assert RESULT_MAP["OPEN"] == "fail"
|
||||
assert RESULT_MAP["RESOLVED"] == "pass"
|
||||
assert RESULT_MAP["IN_PROGRESS"] == "skipped"
|
||||
assert RESULT_MAP["DISMISSED"] == "skipped"
|
||||
|
||||
|
||||
class TestToPcr:
|
||||
def test_translates_open_critical(self):
|
||||
issue = {
|
||||
"id": "wiz-1",
|
||||
"title": "a critical issue",
|
||||
"severity": "CRITICAL",
|
||||
"status": "OPEN",
|
||||
"entity": {"id": "arn:aws:s3:::b", "name": "b"},
|
||||
}
|
||||
pcr = _to_pcr(issue, "c-1")
|
||||
assert pcr["engine"] == "wiz"
|
||||
assert pcr["ruleId"] == "wiz-1"
|
||||
assert pcr["severity"] == "critical"
|
||||
assert pcr["result"] == "fail"
|
||||
assert pcr["contractId"] == "c-1"
|
||||
assert pcr["resourceRef"] == "arn:aws:s3:::b"
|
||||
|
||||
def test_severity_case_insensitive(self):
|
||||
issue = {"id": "wiz-1", "severity": "high", "status": "open",
|
||||
"entity": {"id": "r"}}
|
||||
pcr = _to_pcr(issue, "c-1")
|
||||
assert pcr["severity"] == "high"
|
||||
assert pcr["result"] == "fail"
|
||||
|
||||
def test_unknown_severity_defaults_info(self):
|
||||
issue = {"id": "wiz-1", "severity": "BOGUS", "status": "OPEN",
|
||||
"entity": {"id": "r"}}
|
||||
pcr = _to_pcr(issue, "c-1")
|
||||
assert pcr["severity"] == "info"
|
||||
|
||||
def test_unknown_status_defaults_error(self):
|
||||
issue = {"id": "wiz-1", "severity": "INFO", "status": "BOGUS",
|
||||
"entity": {"id": "r"}}
|
||||
pcr = _to_pcr(issue, "c-1")
|
||||
assert pcr["result"] == "error"
|
||||
|
||||
def test_pcr_validates_against_schema(self, policy_check_result_schema):
|
||||
issue = {"id": "wiz-1", "title": "t", "severity": "CRITICAL",
|
||||
"status": "OPEN", "entity": {"id": "r", "name": "n"}}
|
||||
pcr = _to_pcr(issue, "11111111-1111-1111-1111-111111111111")
|
||||
jsonschema.validate(pcr, policy_check_result_schema)
|
||||
|
||||
|
||||
class TestEmitNotConfigured:
|
||||
def test_not_configured_pcr(self, policy_check_result_schema):
|
||||
pcr = _emit_not_configured("11111111-1111-1111-1111-111111111111")
|
||||
assert pcr["ruleId"] == "WIZ_NOT_CONFIGURED"
|
||||
assert pcr["result"] == "skipped"
|
||||
assert pcr["engine"] == "wiz"
|
||||
jsonschema.validate(pcr, policy_check_result_schema)
|
||||
|
||||
|
||||
class TestAdapt:
|
||||
def test_translates_fixture(self, tmp_path, policy_check_result_schema):
|
||||
# adapt() reads a file path, copy fixture to a writable temp path
|
||||
src = FIXTURES / "wiz_issues.json"
|
||||
f = tmp_path / "wiz_issues.json"
|
||||
f.write_text(src.read_text())
|
||||
results = adapt(str(f), "11111111-1111-1111-1111-111111111111")
|
||||
assert len(results) == 3
|
||||
|
||||
# issue 1: OPEN critical -> fail/critical
|
||||
assert results[0]["ruleId"] == "wiz-issue-001"
|
||||
assert results[0]["severity"] == "critical"
|
||||
assert results[0]["result"] == "fail"
|
||||
|
||||
# issue 2: RESOLVED high -> pass/high
|
||||
assert results[1]["ruleId"] == "wiz-issue-002"
|
||||
assert results[1]["severity"] == "high"
|
||||
assert results[1]["result"] == "pass"
|
||||
|
||||
# issue 3: IN_PROGRESS medium -> skipped/medium
|
||||
assert results[2]["ruleId"] == "wiz-issue-003"
|
||||
assert results[2]["severity"] == "medium"
|
||||
assert results[2]["result"] == "skipped"
|
||||
|
||||
for pcr in results:
|
||||
jsonschema.validate(pcr, policy_check_result_schema)
|
||||
|
||||
def test_empty_issues_emits_not_configured(self, tmp_path):
|
||||
f = tmp_path / "wiz_empty.json"
|
||||
f.write_text(json.dumps({"issues": []}))
|
||||
results = adapt(str(f), "11111111-1111-1111-1111-111111111111")
|
||||
assert len(results) == 1
|
||||
assert results[0]["ruleId"] == "WIZ_NOT_CONFIGURED"
|
||||
assert results[0]["result"] == "skipped"
|
||||
|
||||
def test_top_level_list_input(self, tmp_path):
|
||||
# data is a bare list (no "issues" wrapper)
|
||||
f = tmp_path / "wiz_list.json"
|
||||
f.write_text(json.dumps([
|
||||
{"id": "w-1", "severity": "LOW", "status": "RESOLVED",
|
||||
"entity": {"id": "r"}},
|
||||
]))
|
||||
results = adapt(str(f), "11111111-1111-1111-1111-111111111111")
|
||||
assert len(results) == 1
|
||||
assert results[0]["severity"] == "low"
|
||||
assert results[0]["result"] == "pass"
|
||||
|
||||
|
||||
class TestIsConfigured:
|
||||
def test_not_configured_when_env_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("WIZ_API_TOKEN", raising=False)
|
||||
assert is_configured() is False
|
||||
|
||||
def test_configured_when_env_set(self, monkeypatch):
|
||||
monkeypatch.setenv("WIZ_API_TOKEN", "token-abc")
|
||||
assert is_configured() is True
|
||||
Reference in New Issue
Block a user