T09 · Insecure Skill Coding Practices
- Location
scripts/portainer_manager.py:11- Finding
Privileged Portainer API Token Transmitted Without TLS Certificate Verification
- Content
View full analysis
- Remediation
View remediation
Security audit
Security checks for vulnerabilities and agentic risk
This Portainer skill is mostly transparent about managing Docker infrastructure, but it exposes very powerful infrastructure controls with weak safety boundaries and insecure TLS handling.
Install only if you trust the operator context and can limit the Portainer API token to the least privileges needed. Avoid using this against production infrastructure until TLS verification is fixed, raw Docker proxying is restricted or removed, and destructive actions require explicit confirmation.
scripts/portainer_manager.py:11Privileged Portainer API Token Transmitted Without TLS Certificate Verification
package.json:5Python Dependencies Declared and Installed Through Unsafe, Inconsistent Mechanisms
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
try:
token = get_portainer_api_token()
headers = {"X-API-Key": token, "Content-Type": "application/json"}
response = requests.get(f"{PORTAINER_API_URL}/endpoints", headers=headers, timeout=10, verify=False)
response.raise_for_status()
output_data = response.json()
print(json.dumps(output_data, indent=2), flush=True)
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
try:
token = get_portainer_api_token()
headers = {"X-API-Key": token, "Content-Type": "application/json"}
response = requests.get(f"{PORTAINER_API_URL}/stacks", headers=headers, timeout=10, verify=False)
response.raise_for_status()
stacks = response.json()
if environment_id:
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
try:
token = get_portainer_api_token()
headers = {"X-API-Key": token, "Content-Type": "application/json"}
response = requests.get(f"{PORTAINER_API_URL}/stacks/{stack_id}", headers=headers, timeout=10, verify=False)
response.raise_for_status()
output_data = response.json()
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
# Also fetch the stack file content if possible
try:
file_response = requests.get(f"{PORTAINER_API_URL}/stacks/{stack_id}/file", headers=headers, timeout=10, verify=False)
if file_response.status_code == 200:
output_data["StackFileContent"] = file_response.json().get("StackFileContent", "")
except:
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
headers = {"X-API-Key": token, "Content-Type": "application/json"}
url = f"{PORTAINER_API_URL}/stacks/create/standalone/string?endpointId={endpoint_id}"
payload = {"name": name, "stackFileContent": stack_content, "env": []}
response = requests.post(url, headers=headers, json=payload, timeout=30, verify=False)
response.raise_for_status()
output_data = response.json()
print(json.dumps(output_data, indent=2), flush=True)
Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.
"env": [],
"prune": prune
}
response = requests.put(url, headers=headers, json=payload, timeout=30, verify=False)
response.raise_for_status()
output_data = response.json()
print(json.dumps(output_data, indent=2), flush=True)
Unlike the other TT3 findings, this request is part of a generic raw Docker API proxy that accepts arbitrary user-supplied paths and forwards authenticated requests through Portainer. That greatly expands the trust boundary and can turn the skill into a general-purpose privileged API tunnel, enabling unauthorized or destructive Docker operations if exposed to untrusted prompts or users.
url = f"{PORTAINER_API_URL}/endpoints/{environment_id}/docker{path}"
if method.upper() == "GET":
response = requests.get(url, headers=headers, params=payload, timeout=10, verify=False)
elif method.upper() == "POST":
response = requests.post(url, headers=headers, json=payload, timeout=10, verify=False)
elif method.upper() == "DELETE":
This POST path is part of the same unrestricted Docker API proxy and allows arbitrary authenticated write operations against Docker through Portainer. In practice, this can be abused to create containers, mount sensitive host paths, alter networks, or otherwise take over the managed environment.
if method.upper() == "GET":
response = requests.get(url, headers=headers, params=payload, timeout=10, verify=False)
elif method.upper() == "POST":
response = requests.post(url, headers=headers, json=payload, timeout=10, verify=False)
elif method.upper() == "DELETE":
response = requests.delete(url, headers=headers, json=payload, timeout=10, verify=False)
else:
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.
## Prerequisites
- An active [Portainer CE](https://www.portainer.io/) instance.
- An **API Access Token** from your Portainer user settings.
- OpenClaw installed and running.
## Installation
Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.
## Prerequisites
- An active [Portainer CE](https://www.portainer.io/) instance.
- An **API Access Token** from your Portainer user settings.
- OpenClaw installed and running.
## Installation
The skill exposes raw Docker API execution through Portainer and labels it only as 'Advanced' without explaining that it can directly control containers, images, networks, volumes, and potentially host-adjacent resources. In the context of infrastructure management, this is especially dangerous because a broad proxy endpoint can be used for highly privileged actions leading to outage, data destruction, lateral movement, or container escape enabling conditions.
The skill claims to manage Portainer environments and stacks, but it also exposes a generic raw Docker API proxy. That is significantly more powerful than the stated purpose and can enable full container-host compromise patterns, destructive actions, secret access, and persistence depending on Docker daemon privileges. In this skill context, that mismatch makes the capability especially dangerous because users may not realize they are granting near-administrative infrastructure control.
The generic Docker API executor permits potentially destructive operations with no warnings, confirmation, or policy gating. Because Docker APIs can create privileged containers, remove workloads, alter networking, and expose host resources, this is highly dangerous in an agent-exposed interface.
The README advertises deployment, removal, and raw Docker API execution capabilities without any guidance to require explicit user confirmation for destructive or low-level actions. In an agentic context, this increases the chance of accidental infrastructure changes, service disruption, or abuse of powerful functionality through prompt manipulation or ambiguous user requests.
The skill advertises high-impact capabilities involving environment access and network-reachable infrastructure operations, but it does not declare any explicit tool scope or permission boundaries. In a skill that can manage Portainer and proxy Docker API calls, missing scope metadata increases the risk of overbroad invocation, unsafe delegation, and misuse of privileged credentials.
The skill exposes a destructive operation, stack deletion, without any warning, confirmation requirement, or safety guidance. Because stack removal can cause service outage and data loss in live environments, describing it as a normal function without guardrails makes accidental or unauthorized destructive use more likely.
TLS certificate verification is explicitly disabled for requests carrying the Portainer API key. This allows man-in-the-middle interception or spoofing of the Portainer server, potentially exposing credentials and permitting tampering with responses and administrative actions.
try:
token = get_portainer_api_token()
headers = {"X-API-Key": token, "Content-Type": "application/json"}
response = requests.get(f"{PORTAINER_API_URL}/endpoints", headers=headers, timeout=10, verify=False)
response.raise_for_status()
output_data = response.json()
print(json.dumps(output_data, indent=2), flush=True)
Disabling TLS verification on authenticated API requests undermines the security of the entire Portainer session. An attacker on the network could impersonate the server, harvest the API key, and control the data returned to the skill.
try:
token = get_portainer_api_token()
headers = {"X-API-Key": token, "Content-Type": "application/json"}
response = requests.get(f"{PORTAINER_API_URL}/stacks", headers=headers, timeout=10, verify=False)
response.raise_for_status()
stacks = response.json()
if environment_id:
This insecure default affects stack inspection requests that include privileged authentication headers. With verify=False, the client cannot authenticate the server identity, enabling interception of sensitive stack metadata and credentials in transit.
try:
token = get_portainer_api_token()
headers = {"X-API-Key": token, "Content-Type": "application/json"}
response = requests.get(f"{PORTAINER_API_URL}/stacks/{stack_id}", headers=headers, timeout=10, verify=False)
response.raise_for_status()
output_data = response.json()
Fetching stack file content over a connection that does not verify TLS certificates risks disclosure of full compose files, which may contain secrets, environment values, image references, and infrastructure details. Because this is admin-plane data, compromise impact is substantial.
# Also fetch the stack file content if possible
try:
file_response = requests.get(f"{PORTAINER_API_URL}/stacks/{stack_id}/file", headers=headers, timeout=10, verify=False)
if file_response.status_code == 200:
output_data["StackFileContent"] = file_response.json().get("StackFileContent", "")
except:
The manifest emphasizes deployment, status checks, and network management, but the implementation also supports stack deletion. While deletion may be legitimate for an admin tool, omitting that destructive capability from the framing can mislead users and downstream policy about the true risk of invoking the skill.
remove_stack performs an irreversible destructive action immediately, with no confirmation, dry-run, dependency check, or safeguard beyond a log message. In an agent setting, this increases the chance of accidental deletion due to prompt confusion, parameter mix-ups, or malicious instruction injection through user content.
A destructive delete request is sent over a TLS connection with certificate validation disabled. That means an attacker able to intercept traffic could potentially spoof responses or capture the API key used to authorize deletions, compounding the risk.
try:
token = get_portainer_api_token()
headers = {"X-API-Key": token, "Content-Type": "application/json"}
response = requests.delete(f"{PORTAINER_API_URL}/stacks/{stack_id}", headers=headers, timeout=10, verify=False)
response.raise_for_status()
print(f"Successfully removed stack {stack_id}.", flush=True)
return {"status": "success"}
Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.
headers = {"X-API-Key": token, "Content-Type": "application/json"}
url = f"{PORTAINER_API_URL}/stacks/create/standalone/string?endpointId={endpoint_id}"
payload = {"name": name, "stackFileContent": stack_content, "env": []}
response = requests.post(url, headers=headers, json=payload, timeout=30, verify=False)
response.raise_for_status()
output_data = response.json()
print(json.dumps(output_data, indent=2), flush=True)
Stack deployment sends potentially sensitive infrastructure configuration over a connection with verify=False. This can leak secrets embedded in compose content and let an attacker alter or replay deployment requests.
headers = {"X-API-Key": token, "Content-Type": "application/json"}
url = f"{PORTAINER_API_URL}/stacks/create/standalone/string?endpointId={endpoint_id}"
payload = {"name": name, "stackFileContent": stack_content, "env": []}
response = requests.post(url, headers=headers, json=payload, timeout=30, verify=False)
response.raise_for_status()
output_data = response.json()
print(json.dumps(output_data, indent=2), flush=True)
No suspicious patterns detected.