id: terraform-drift-detection-remediation-planner
namespace: company.team
description: |
Detect out-of-band infrastructure drift using Terraform refresh-only planning,
heuristically classify blast radius and operational risk, alert team channels,
and generate structured advisory remediation runbooks for human review.
triggers:
- id: daily_drift_schedule
type: io.kestra.plugin.core.trigger.Schedule
description: Scheduled daily infrastructure drift scan. Shipped disabled by
default; enable in production once backend and provider credentials are
configured.
cron: "0 6 * * *"
disabled: true
inputs:
- id: environment
type: STRING
defaults: production
description: Target deployment environment name (e.g., production, staging,
development).
- id: drift_risk_tolerance
type: SELECT
values: [ "LOW", "MEDIUM", "HIGH" ]
defaults: "MEDIUM"
description: Maximum allowable drift risk threshold before alert escalation.
- id: fail_on_critical_drift
type: BOOL
defaults: false
description: Whether to fail pipeline execution when CRITICAL drift is detected
(e.g., deleted stateful or security resources). Shipped false for
non-blocking observation.
- id: notify_slack
type: BOOL
defaults: false
description: Whether to send a formatted risk alert to Slack when infrastructure
drift is detected.
- id: terraform_code
type: STRING
defaults: |
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "2.5.2"
}
random = {
source = "hashicorp/random"
version = "3.6.3"
}
}
}
resource "random_id" "server_id" {
byte_length = 8
}
resource "local_file" "app_manifest" {
filename = "app_manifest.json"
content = jsonencode({
service = "payment-gateway"
environment = "production"
version = "2.4.0"
log_level = "INFO"
})
}
description: Terraform HCL configuration to evaluate. For live environments,
replace with your cloud provider configuration and remote state backend.
tasks:
- id: drift_workspace
type: io.kestra.plugin.core.flow.WorkingDirectory
description: Isolated workspace containing Terraform configuration, refresh-only
plan artifacts, and generated remediation reports.
inputFiles:
main.tf: "{{ inputs.terraform_code }}"
outputFiles:
- drift_report.json
- drift_summary.md
- drift_plan.json
tasks:
- id: detect_drift
type: io.kestra.plugin.terraform.cli.TerraformCLI
description: Initialize Terraform and run a read-only refresh-only plan to
inspect live infrastructure without applying changes.
containerImage: hashicorp/terraform:1.9.8
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
beforeCommands:
- terraform init -input=false -no-color
commands:
- |
rc=0
terraform plan -refresh-only -detailed-exitcode -input=false -no-color -out=tfdrift || rc=$?
if [ $rc -ne 0 ] && [ $rc -ne 2 ]; then
echo "[ERROR] Terraform refresh-only plan failed with unexpected return code $rc." >&2
exit $rc
fi
terraform show -json tfdrift > drift_plan.json
echo "[INFO] Terraform refresh-only plan completed with return code $rc."
- id: analyze_and_plan_remediation
type: io.kestra.plugin.scripts.python.Script
description: Parse plan JSON, inspect resource_drift, score blast radius and
risk heuristics, redact sensitive fields, and produce remediation
artifacts.
containerImage: python:3.11-slim
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
env:
ENVIRONMENT: "{{ inputs.environment }}"
DRIFT_RISK_TOLERANCE: "{{ inputs.drift_risk_tolerance }}"
script: |
import json
import os
import sys
import datetime
# Stateful infrastructure keywords for risk heuristic scoring
STATEFUL_KEYWORDS = [
"db_instance", "rds_cluster", "dynamodb_table", "sql_database",
"database_instance", "cosmosdb_", "mongodb_", "redis_instance",
"elasticache_cluster", "s3_bucket", "storage_bucket", "storage_container",
"gcs_bucket", "ebs_volume", "persistent_disk", "compute_disk",
"managed_disk", "persistent_volume", "postgresql_server", "mysql_server",
"mssql_database", "mssql_server", "mariadb_server"
]
# Security and network perimeter keywords
SECURITY_NETWORK_KEYWORDS = [
"security_group", "firewall", "iam_role", "iam_policy", "iam_user",
"vault_", "key_vault", "kms_key", "certificate", "acm_certificate",
"network_security_group", "route_table", "internet_gateway", "nat_gateway",
"vpc", "virtual_network", "subnet", "peering"
]
# Non-functional metadata attributes (low risk drift)
METADATA_ATTRIBUTES = {
"tags", "tags_all", "labels", "description", "comment", "metadata"
}
# High-risk sensitive attributes
HIGH_RISK_ATTRIBUTES = {
"cidr_blocks", "cidr_block", "ingress", "egress", "policy", "assume_role_policy",
"key_ring", "kms_key_id", "publicly_accessible", "allow_public_access",
"password", "master_password", "allocated_storage"
}
# Standard known stateless utility and common cloud resource prefixes for heuristic recognition
STANDARD_KNOWN_TYPES = {
"local_file", "local_sensitive_file", "random_id", "random_pet",
"random_string", "random_uuid", "random_integer", "random_shuffle",
"null_resource", "time_sleep", "time_static", "terraform_data"
}
KNOWN_PROVIDER_PREFIXES = (
"aws_", "google_", "azurerm_", "kubernetes_", "cloudflare_",
"helm_", "datadog_", "pagerduty_", "github_", "gitlab_"
)
plan_file = "drift_plan.json"
if not os.path.exists(plan_file):
print(f"[ERROR] Plan artifact '{plan_file}' not found. Cannot assess drift without plan.", file=sys.stderr)
sys.exit(1)
try:
with open(plan_file, "r", encoding="utf-8") as f:
plan_data = json.load(f)
except Exception as exc:
print(f"[ERROR] Failed to parse '{plan_file}' as valid JSON: {exc}", file=sys.stderr)
sys.exit(1)
if not isinstance(plan_data, dict):
print(f"[ERROR] Malformed plan: root element must be a JSON object, got {type(plan_data).__name__}.", file=sys.stderr)
sys.exit(1)
if "format_version" not in plan_data:
print("[ERROR] Invalid plan format: missing required 'format_version' metadata.", file=sys.stderr)
sys.exit(1)
# In Terraform refresh-only mode, resource_drift records live cloud divergence from state.
# Fail closed if resource_drift is missing, null, or not a list to prevent incomplete scans from passing.
if "resource_drift" not in plan_data:
print("[ERROR] Invalid plan format: missing required 'resource_drift' section. Ensure plan was generated with -refresh-only.", file=sys.stderr)
sys.exit(1)
raw_drift = plan_data.get("resource_drift")
if raw_drift is None or not isinstance(raw_drift, list):
print(f"[ERROR] Invalid plan format: 'resource_drift' must be a non-null list, got {type(raw_drift).__name__}.", file=sys.stderr)
sys.exit(1)
resource_drift = raw_drift
environment = os.environ.get("ENVIRONMENT", "production")
risk_tolerance = os.environ.get("DRIFT_RISK_TOLERANCE", "MEDIUM").upper()
findings = []
deleted_count = 0
updated_count = 0
created_count = 0
unclassified_count = 0
for idx, item in enumerate(resource_drift):
if not isinstance(item, dict):
print(f"[ERROR] Malformed drift entry at index {idx}: expected dict.", file=sys.stderr)
sys.exit(1)
addr = str(item.get("address", f"unknown_resource_{idx}"))
res_type = str(item.get("type", "unknown"))
provider = str(item.get("provider_name", "unknown"))
change = item.get("change")
if not isinstance(change, dict):
print(f"[ERROR] Malformed change block for '{addr}': expected dict.", file=sys.stderr)
sys.exit(1)
actions = change.get("actions", [])
if not isinstance(actions, list) or len(actions) == 0:
print(f"[ERROR] Malformed actions for '{addr}': must be a non-empty list.", file=sys.stderr)
sys.exit(1)
# Detect attribute changes and redact sensitive values
before = change.get("before") or {}
after = change.get("after") or {}
before_sens = change.get("before_sensitive") or {}
after_sens = change.get("after_sensitive") or {}
changed_keys = []
sanitized_diff = {}
sensitive_redacted_count = 0
if isinstance(before, dict) and isinstance(after, dict):
all_keys = set(before.keys()).union(set(after.keys()))
for k in sorted(all_keys):
if before.get(k) != after.get(k):
changed_keys.append(k)
is_sens = False
if isinstance(before_sens, dict) and before_sens.get(k):
is_sens = True
elif isinstance(after_sens, dict) and after_sens.get(k):
is_sens = True
elif before_sens is True or after_sens is True:
is_sens = True
if is_sens:
sensitive_redacted_count += 1
sanitized_diff[k] = {
"before": "(sensitive value redacted)",
"after": "(sensitive value redacted)",
"sensitive": True
}
else:
b_str = str(before.get(k))
a_str = str(after.get(k))
if len(b_str) > 80:
b_str = b_str[:77] + "..."
if len(a_str) > 80:
a_str = a_str[:77] + "..."
sanitized_diff[k] = {
"before": b_str,
"after": a_str,
"sensitive": False
}
elif before != after:
changed_keys.append("(value)")
is_sens = before_sens is True or after_sens is True
if is_sens:
sensitive_redacted_count += 1
sanitized_diff["(value)"] = {
"before": "(sensitive value redacted)",
"after": "(sensitive value redacted)",
"sensitive": True
}
else:
sanitized_diff["(value)"] = {
"before": str(before)[:80],
"after": str(after)[:80],
"sensitive": False
}
# Classify action counts
if "delete" in actions:
deleted_count += 1
elif "update" in actions:
updated_count += 1
elif "create" in actions:
created_count += 1
# Risk evaluation heuristic
res_lower = res_type.lower()
is_stateful = any(kw in res_lower for kw in STATEFUL_KEYWORDS)
is_security_net = any(kw in res_lower for kw in SECURITY_NETWORK_KEYWORDS)
is_known_type = is_stateful or is_security_net or (res_type in STANDARD_KNOWN_TYPES) or any(res_type.startswith(p) for p in KNOWN_PROVIDER_PREFIXES)
only_metadata = len(changed_keys) > 0 and all(k in METADATA_ATTRIBUTES for k in changed_keys)
has_high_risk_attr = any(k in HIGH_RISK_ATTRIBUTES for k in changed_keys)
item_risk = "LOW"
risk_reason = ""
remediation_advice = ""
is_unclassified = False
if "delete" in actions:
if is_stateful:
item_risk = "CRITICAL"
risk_reason = "Stateful resource was deleted out-of-band in cloud environment."
remediation_advice = "CRITICAL: Stateful resource deleted. Investigate incident immediately. Do not blindly apply. Confirm whether data loss occurred before reconciling state."
elif is_security_net:
item_risk = "CRITICAL"
risk_reason = "Security/Network boundary resource was deleted out-of-band."
remediation_advice = "CRITICAL: Security boundary deleted. Restore configuration via 'terraform apply' or investigate authorized deprecation."
elif not is_known_type:
item_risk = "HIGH"
is_unclassified = True
unclassified_count += 1
risk_reason = f"Unclassified resource '{addr}' ({res_type}) was deleted out-of-band."
remediation_advice = "Inspect unclassified resource in provider console or API. Triage manually before reconciling."
else:
item_risk = "HIGH"
risk_reason = "Resource was deleted out-of-band in cloud environment."
remediation_advice = "Re-create resource with 'terraform apply' or remove definition from IaC repo if decommissioning was intended."
elif "update" in actions:
if has_high_risk_attr and (is_security_net or is_stateful):
item_risk = "CRITICAL"
risk_reason = f"High-risk attributes ({', '.join(changed_keys[:3])}) modified out-of-band."
remediation_advice = "Review security and stateful changes. If change was unauthorized, run 'terraform apply' to revert. If authorized, update HCL and commit."
elif is_security_net:
item_risk = "HIGH"
risk_reason = "Security/Network resource configuration altered out-of-band."
remediation_advice = "Audit change in cloud console. Revert using 'terraform apply' or backport changes to HCL code."
elif is_stateful and not only_metadata:
item_risk = "HIGH"
risk_reason = f"Stateful resource configuration altered out-of-band: {', '.join(changed_keys[:3])}."
remediation_advice = "Verify database/storage parameter changes. If intentional, accept with 'terraform apply -refresh-only'; otherwise revert."
elif only_metadata:
item_risk = "LOW"
risk_reason = f"Metadata/tags updated out-of-band: {', '.join(changed_keys[:3])}."
remediation_advice = "Accept harmless metadata drift into state via 'terraform apply -refresh-only' or sync tags in HCL."
elif not is_known_type:
# Conservative risk classification for unrecognized resource types with operational changes
is_unclassified = True
unclassified_count += 1
item_risk = "HIGH" if risk_tolerance in ["LOW", "MEDIUM"] else "MEDIUM"
risk_reason = f"Conservative heuristic: unclassified resource type '{res_type}' experienced operational property drift: {', '.join(changed_keys[:3])}."
remediation_advice = "Inspect unclassified resource in provider console or API. Triage manually before reconciling."
else:
item_risk = "MEDIUM"
risk_reason = f"Operational resource properties drifted: {', '.join(changed_keys[:3])}."
remediation_advice = "Review configuration changes. Accept via 'terraform apply -refresh-only' if intentional, or revert via 'terraform apply'."
else:
item_risk = "HIGH"
is_unclassified = True
unclassified_count += 1
risk_reason = f"Heuristic warning: unclassified drift action {actions} on '{res_type}'."
remediation_advice = "Inspect detailed plan JSON artifact. Triage manually."
findings.append({
"address": addr,
"type": res_type,
"provider": provider,
"actions": actions,
"risk_level": item_risk,
"reason": risk_reason,
"unclassified": is_unclassified,
"changed_attributes": changed_keys,
"sanitized_diff": sanitized_diff,
"sensitive_redacted_count": sensitive_redacted_count,
"remediation_advice": remediation_advice
})
total_drifted = len(findings)
drift_detected = total_drifted > 0
SEVERITY_ORDER = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "NONE": 0}
overall_risk = "NONE"
if drift_detected:
max_sev = max(SEVERITY_ORDER.get(f["risk_level"], 1) for f in findings)
for k, v in SEVERITY_ORDER.items():
if v == max_sev:
overall_risk = k
break
if not drift_detected:
verdict = "IN_SYNC"
advisory_action = "NO_ACTION_REQUIRED"
summary_text = f"Clean: No infrastructure drift detected for '{environment}'. State is fully synchronized."
elif overall_risk == "CRITICAL":
verdict = "CRITICAL_DRIFT"
advisory_action = "IMMEDIATE_TRIAGE_REQUIRED"
summary_text = f"CRITICAL: {total_drifted} drifted resource(s) found in '{environment}' (Deleted: {deleted_count}, Updated: {updated_count}). Immediate triage required."
elif overall_risk == "HIGH":
verdict = "ACTION_REQUIRED"
advisory_action = "REVERT_OR_SYNC"
summary_text = f"HIGH RISK: {total_drifted} drifted resource(s) in '{environment}'. Action required to reconcile divergence."
elif overall_risk == "MEDIUM":
verdict = "ADVISORY_REVIEW"
advisory_action = "REVIEW_AND_ACCEPT_OR_REVERT"
summary_text = f"MEDIUM RISK: {total_drifted} operational drift finding(s) in '{environment}'. Review changes and reconcile."
else:
verdict = "ADVISORY_REVIEW"
advisory_action = "ACCEPT_DRIFT_REFRESH"
summary_text = f"LOW RISK: {total_drifted} metadata/tag drift finding(s) in '{environment}'. Run 'terraform apply -refresh-only' to align state."
if findings:
sample = [f"{f['address']} [{f['risk_level']}]" for f in findings[:3]]
summary_text += f" Sample: {'; '.join(sample)}"
if len(findings) > 3:
summary_text += f" (+{len(findings) - 3} more)"
print(f"[INFO] Infrastructure Drift Assessment for '{environment}':")
print(f" Drift Detected: {drift_detected} | Verdict: {verdict} | Risk: {overall_risk}")
print(f" Drifted Count: {total_drifted} (Deleted: {deleted_count}, Updated: {updated_count}, Other: {unclassified_count})")
print(f" Advisory Action: {advisory_action}")
# Write machine-readable JSON report
report_data = {
"metadata": {
"flow_id": "terraform-drift-detection-remediation-planner",
"environment": environment,
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"drift_detected": drift_detected,
"risk_level": overall_risk,
"verdict": verdict,
"advisory_action": advisory_action,
"total_drifted_resources": total_drifted,
"counts": {
"deleted": deleted_count,
"updated": updated_count,
"created": created_count,
"unclassified": unclassified_count
}
},
"findings": findings
}
with open("drift_report.json", "w", encoding="utf-8") as f:
json.dump(report_data, f, indent=2)
# Write human-readable Markdown summary
md_lines = [
"# Infrastructure Drift Audit Summary",
"",
f"**Environment:** `{environment}` ",
f"**Audit Status:** `{verdict}` ",
f"**Overall Risk:** `{overall_risk}` ",
f"**Total Drifted Resources:** `{total_drifted}` ",
f"**Primary Advisory Action:** `{advisory_action}` ",
"",
"## Executive Summary",
"",
f"{summary_text}",
"",
"| Metric | Count |",
"| :--- | :--- |",
f"| **Deletions (Out-of-band)** | `{deleted_count}` |",
f"| **Modifications (Out-of-band)** | `{updated_count}` |",
f"| **Other / Unclassified** | `{unclassified_count}` |",
""
]
if findings:
md_lines.extend([
"## Drifted Resources Detail",
"",
"| Resource Address | Type | Actions | Risk | Changed Attributes | Advisory Remediation |",
"| :--- | :--- | :--- | :--- | :--- | :--- |"
])
for f in findings:
attrs_str = ", ".join(f["changed_attributes"][:4])
if len(f["changed_attributes"]) > 4:
attrs_str += f" (+{len(f['changed_attributes']) - 4} more)"
if not attrs_str:
attrs_str = "*(entire resource)*"
acts_str = ", ".join(f["actions"])
addr_clean = f["address"].replace("|", "\\|")
type_clean = f["type"].replace("|", "\\|")
advice_clean = f["remediation_advice"].replace("|", "\\|")
md_lines.append(f"| `{addr_clean}` | `{type_clean}` | `{acts_str}` | **{f['risk_level']}** | `{attrs_str}` | {advice_clean} |")
md_lines.extend([
"",
"## Recommended Remediation Runbook",
"",
"1. **Review Findings:** Examine the table above and inspect the machine-readable `drift_report.json` artifact.",
"2. **Advisory Decision Path:**",
" - **Accept Intentional Drift:** If changes were authorized cloud adjustments, update state cleanly by running:",
" ```bash",
" terraform apply -refresh-only",
" ```",
" - **Revert Unauthorized Changes:** If changes diverge from approved configuration, restore infrastructure to match version-controlled HCL by running:",
" ```bash",
" terraform plan",
" terraform apply",
" ```",
" - **Backport to Git:** If changes are legitimate, update your HCL code in Git to reflect live values, open a pull request, and merge.",
"3. **Human Approval:** Never automate destructive reconciliation without human verification."
])
else:
md_lines.extend([
"## Drift Status",
"",
"No infrastructure drift detected. Live cloud resources precisely match the recorded Terraform state."
])
with open("drift_summary.md", "w", encoding="utf-8") as f:
f.write("\n".join(md_lines) + "\n")
# Emit Kestra output protocol
output_payload = {
"drift_detected": drift_detected,
"drifted_resources_count": total_drifted,
"risk_level": overall_risk,
"verdict": verdict,
"advisory_action": advisory_action,
"deleted_count": deleted_count,
"updated_count": updated_count,
"summary": summary_text
}
print("::" + json.dumps({"outputs": output_payload}) + "::")
- id: evaluate_drift_results
type: io.kestra.plugin.core.flow.If
description: Evaluate drift findings, dispatch Slack alerts if enabled, and
enforce optional policy gates on critical divergence.
condition: "{{ outputs.analyze_and_plan_remediation.vars.drift_detected }}"
then:
- id: check_slack_notification
type: io.kestra.plugin.core.flow.If
description: Dispatch Slack alert when notify_slack is enabled.
condition: "{{ inputs.notify_slack }}"
then:
- id: send_slack_alert
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Send structured drift findings and remediation runbook summary to
Slack.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":warning: *Terraform Drift Alert - {{ inputs.environment }}*\n• *Verdict:* `{{ outputs.analyze_and_plan_remediation.vars.verdict }}`\n• *Risk Level:* `{{ outputs.analyze_and_plan_remediation.vars.risk_level }}`\n• *Drifted Resources:* `{{ outputs.analyze_and_plan_remediation.vars.drifted_resources_count }}` (Deleted: {{ outputs.analyze_and_plan_remediation.vars.deleted_count }}, Updated: {{ outputs.analyze_and_plan_remediation.vars.updated_count }})\n• *Advisory Action:* `{{ outputs.analyze_and_plan_remediation.vars.advisory_action }}`\n• *Summary:* {{ outputs.analyze_and_plan_remediation.vars.summary }}\n• *Execution ID:* `{{ execution.id }}`"
}
- id: check_critical_drift_policy
type: io.kestra.plugin.core.flow.If
description: Check whether critical drift should halt workflow execution based
on the fail_on_critical_drift input.
condition: "{{ outputs.analyze_and_plan_remediation.vars.risk_level ==
'CRITICAL' and inputs.fail_on_critical_drift }}"
then:
- id: fail_on_critical_drift
type: io.kestra.plugin.core.execution.Fail
description: Halt pipeline execution when unmanaged critical infrastructure
drift is detected.
errorMessage: >-
Terraform Drift Critical Policy Gate Triggered: Critical unmanaged
infrastructure drift detected for '{{ inputs.environment }}'.
Drifted resources: {{
outputs.analyze_and_plan_remediation.vars.drifted_resources_count
}}. Summary: {{ outputs.analyze_and_plan_remediation.vars.summary
}}
else:
- id: log_drift_detected
type: io.kestra.plugin.core.log.Log
description: Log drift findings when non-critical or when failure gating is
disabled.
message: >-
[NOTICE] Infrastructure drift detected for '{{ inputs.environment
}}' (Risk: {{ outputs.analyze_and_plan_remediation.vars.risk_level
}}). Advisory action: {{
outputs.analyze_and_plan_remediation.vars.advisory_action }}.
Review drift_summary.md in execution outputs.
else:
- id: log_clean_state
type: io.kestra.plugin.core.log.Log
description: Log clean state verification when no infrastructure drift is found.
message: >-
[CLEAN] No infrastructure drift detected for '{{ inputs.environment
}}'. Terraform state and live infrastructure are in complete
synchronization.
errors:
- id: on_scan_failure
type: io.kestra.plugin.core.log.Log
description: Log error details if the drift scan fails to prevent silent failures.
message: >-
[ERROR] Terraform drift scan failed unexpectedly for execution {{
execution.id }} in environment {{ inputs.environment }}. Review task logs.
A failed scan must never be assumed clean.
outputs:
- id: drift_detected
type: BOOL
description: Whether any out-of-band infrastructure drift was detected.
value: "{{ outputs.analyze_and_plan_remediation.vars.drift_detected }}"
- id: drifted_resources_count
type: INT
description: Total number of drifted resources identified in the plan.
value: "{{ outputs.analyze_and_plan_remediation.vars.drifted_resources_count }}"
- id: risk_level
type: STRING
description: Highest evaluated risk severity across all drifted resources
(CRITICAL, HIGH, MEDIUM, LOW, NONE).
value: "{{ outputs.analyze_and_plan_remediation.vars.risk_level }}"
- id: verdict
type: STRING
description: Overall audit verdict (IN_SYNC, ADVISORY_REVIEW, ACTION_REQUIRED,
CRITICAL_DRIFT).
value: "{{ outputs.analyze_and_plan_remediation.vars.verdict }}"
- id: advisory_action
type: STRING
description: Recommended primary remediation strategy for the team.
value: "{{ outputs.analyze_and_plan_remediation.vars.advisory_action }}"
- id: deleted_count
type: INT
description: Number of resources deleted out-of-band.
value: "{{ outputs.analyze_and_plan_remediation.vars.deleted_count }}"
- id: updated_count
type: INT
description: Number of resources modified out-of-band.
value: "{{ outputs.analyze_and_plan_remediation.vars.updated_count }}"
- id: summary
type: STRING
description: Sanitized summary of the drift audit and remediation recommendations.
value: "{{ outputs.analyze_and_plan_remediation.vars.summary }}"
- id: report_uri
type: STRING
description: Internal URI of the generated machine-readable drift_report.json artifact.
value: "{{ outputs.drift_workspace.outputFiles['drift_report.json'] }}"
- id: summary_uri
type: STRING
description: Internal URI of the generated human-readable drift_summary.md artifact.
value: "{{ outputs.drift_workspace.outputFiles['drift_summary.md'] }}"