193 lines
6.1 KiB
Python
193 lines
6.1 KiB
Python
"""Wiz adapter — translate Wiz API results to Nova 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)) |