e74a8c2f5d
---ci--- project: acdl phase: 42 milestone: v1.9 status: execute ---/ci--- Phase 42 — stub-implementation (REQ-107..111, D-084): route_halt_artifact (REQ-107): - core/separation_of_duties.py: real SNS publish (ACDL_SOD_HALT_TOPIC_ARN) + outbox fallback (SEPARATION_OF_DUTIES_VIOLATION event via outbox_writer) + stderr emission. No silent print-only stub. - terraform/platform/main.tf: aws_sns_topic.acdl-sod-halt + output. HITL attestation gates (REQ-108): - core/hitl_gates.py: attest(contract_id, env, approver, evidence, outbox_client) records approver_qa/approver_prod/approver_dr to outbox, runs SoD check on prod, invokes attestation matrix, returns (ok, reason). Dev skips (autonomous). approver_from_env() reads GITHUB_ACTOR/GITEA_ACTOR. - scripts/run_platform.sh: Step 7b HITL gate before apply for qa/prod/dr. 8-concern attestation matrix (REQ-109, D-084): - core/attestation_matrix.py: check(env, evidence) runs the 8 concerns from hitl_matrix_design.md §10.4. Offline-testable (contract_nfrs, schema_validity, policy_pass) run for real. Operator-supplied accept signed artifacts validated for freshness (FRESHNESS_DAYS table) + schema. Signature skip when ACDL_ATTESTATION_SIGNING_KEY_ID unset (D-089). Fail loud if missing/expired for prod/dr. Wiz real client (REQ-110): - adapters/wiz/wiz_adapter.py: WizClient (GraphQL API, Bearer auth, pagination via pageInfo.hasNextPage + endCursor). fetch_and_adapt translates issues → PolicyCheckResult; graceful degrade when WIZ_API_TOKEN/WIZ_API_URL unset. Kyverno fleshed out (REQ-111): - adapters/kyverno/kyverno_adapter.py: full PolicyReport → PolicyCheckResult mapping (pass/fail/skip/warn + severity + skip-with- reason + resource ref construction from kind/name/namespace). adapt_inactive() emits KYVERNO_INACTIVE_TF_STACK guard. --kube-version stub parsed for future GitOps. Tests: +47 (test_route_halt_artifact.py, test_hitl_gates.py, test_attestation_matrix.py, test_wiz_adapter_real_client.py, expanded test_kyverno_adapter.py). Existing wiz_adapter tests updated for the real client's control.name ruleId. 493 passed; run_ci.sh green; run_platform.sh --check-only green.
193 lines
6.1 KiB
Python
193 lines
6.1 KiB
Python
"""Wiz adapter — translate Wiz API results to ACDL PolicyCheckResult records.
|
|
|
|
Wiz is a SaaS security platform with a GraphQL API. This adapter
|
|
translates Wiz issue records to the normalized PolicyCheckResult schema
|
|
(engine: "wiz"), matching the Checkov adapter pattern.
|
|
|
|
v1.9 (REQ-110): the adapter is a real API client. `WizClient` queries the
|
|
Wiz GraphQL API (`<WIZ_API_URL>/graphql`, Bearer auth, `issues` query)
|
|
and translates results → PolicyCheckResult records. It degrades
|
|
gracefully (single `SKIPPED` `WIZ_NOT_CONFIGURED` record) when
|
|
`WIZ_API_TOKEN` or `WIZ_API_URL` is unset (D-052). Pagination is handled
|
|
via `pageInfo.hasNextPage` + `endCursor`. Offline tests use a recorded
|
|
GraphQL fixture.
|
|
|
|
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",
|
|
"INFORMATIONAL": "info",
|
|
"INFO": "info",
|
|
}
|
|
|
|
RESULT_MAP = {
|
|
"OPEN": "fail",
|
|
"RESOLVED": "pass",
|
|
"IN_PROGRESS": "skipped",
|
|
"DISMISSED": "skipped",
|
|
}
|
|
|
|
|
|
_ISSUES_QUERY = """
|
|
query IssuesQuery($filterBy: IssueFilter, $after: String) {
|
|
issues(filterBy: $filterBy, after: $after) {
|
|
nodes {
|
|
id
|
|
severity
|
|
title
|
|
status
|
|
entity { id name type cloudPlatform }
|
|
control { id name }
|
|
createdAt
|
|
}
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
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", {}) or {}
|
|
entity = wiz_issue.get("entity", {}) or {}
|
|
rule_id = control.get("name") or wiz_issue.get("id") or "WIZ_UNKNOWN"
|
|
return {
|
|
"contractId": contract_id,
|
|
"evaluatedAt": _iso8601_now(),
|
|
"engine": "wiz",
|
|
"ruleId": rule_id,
|
|
"severity": severity,
|
|
"result": result,
|
|
"message": wiz_issue.get("title", control.get("name", "")),
|
|
"evidence": {
|
|
"resource": entity.get("id"),
|
|
"resource_name": entity.get("name"),
|
|
"cloud_platform": entity.get("cloudPlatform"),
|
|
},
|
|
"resourceRef": 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 or WIZ_API_URL not set); degraded gracefully (D-052).",
|
|
"evidence": {},
|
|
"resourceRef": "",
|
|
}
|
|
|
|
|
|
class WizClient:
|
|
"""Real Wiz GraphQL API client (REQ-110).
|
|
|
|
Reads WIZ_API_TOKEN + WIZ_API_URL from the environment. `fetch_issues`
|
|
queries the Wiz GraphQL API and returns a list of issue dicts.
|
|
Pagination is handled via pageInfo.hasNextPage + endCursor.
|
|
"""
|
|
|
|
def __init__(self, token=None, url=None):
|
|
self.token = token or os.environ.get("WIZ_API_TOKEN", "")
|
|
self.url = (url or os.environ.get("WIZ_API_URL", "")).rstrip("/")
|
|
if not self.token or not self.url:
|
|
raise RuntimeError("WizClient requires WIZ_API_TOKEN + WIZ_API_URL")
|
|
|
|
def _post(self, query, variables):
|
|
import urllib.request
|
|
endpoint = f"{self.url}/graphql"
|
|
payload = json.dumps({"query": query, "variables": variables}).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
endpoint,
|
|
data=payload,
|
|
headers={
|
|
"Authorization": f"Bearer {self.token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
|
|
def fetch_issues(self, filter_by=None, max_pages=10):
|
|
issues = []
|
|
after = None
|
|
for _ in range(max_pages):
|
|
data = self._post(_ISSUES_QUERY, {"filterBy": filter_by or {}, "after": after})
|
|
root = data.get("data", {}).get("issues", {})
|
|
nodes = root.get("nodes", [])
|
|
issues.extend(nodes)
|
|
page_info = root.get("pageInfo", {})
|
|
if not page_info.get("hasNextPage"):
|
|
break
|
|
after = page_info.get("endCursor")
|
|
return issues
|
|
|
|
|
|
def fetch_and_adapt(contract_id, filter_by=None, client=None):
|
|
"""Fetch Wiz issues via the real client and translate to PolicyCheckResult.
|
|
|
|
When the client is not configured (no token/url), emit the SKIPPED
|
|
WIZ_NOT_CONFIGURED record (graceful degrade).
|
|
"""
|
|
if client is None:
|
|
try:
|
|
client = WizClient()
|
|
except RuntimeError:
|
|
return [_emit_not_configured(contract_id)]
|
|
issues = client.fetch_issues(filter_by=filter_by)
|
|
if not issues:
|
|
return [_emit_not_configured(contract_id)]
|
|
return [_to_pcr(i, contract_id) for i in issues]
|
|
|
|
|
|
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
|
|
# or a full GraphQL response shape ({data: {issues: {nodes: [...]}}}).
|
|
if isinstance(data, list):
|
|
issues = data
|
|
elif "data" in data and "issues" in data.get("data", {}):
|
|
issues = data["data"]["issues"].get("nodes", [])
|
|
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") and os.environ.get("WIZ_API_URL"))
|
|
|
|
|
|
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)) |