Skip to content

Secure coding practices for network automation

Automation code is part of the attack surface. A script that renders device configuration, holds credentials, and runs with high privilege is a tempting target — and a single flaw in it can reach every device it touches. This article covers five habits that harden automation code: input validation, strong authentication, secret management, least privilege with safe command construction, and dependency hygiene.

Never trust input

Input — whether from a user, an API, a file, or another system — is untrusted until you have checked it. Validate type, range, and format before acting on it, prefer allowlists (an explicit set of accepted values) over blocklists, and validate structured payloads against a schema rather than reaching into raw dictionaries.

In Python, pydantic makes schema validation concise. Defining the shape you expect rejects malformed input at the boundary, long before it reaches a device:

from pydantic import BaseModel, field_validator


class VlanRequest(BaseModel):
    vlan_id: int
    name: str

    @field_validator("vlan_id")
    @classmethod
    def in_range(cls, v: int) -> int:
        if not 1 <= v <= 4094:
            raise ValueError("vlan_id must be 1-4094")
        return v


# A bad vlan_id (or a non-integer) raises before any config is built.
req = VlanRequest(vlan_id=10, name="SALES")

The validator coerces and range-checks vlan_id and raises on anything outside 1–4094, so an out-of-range VLAN never becomes a device command.

Avoid injection: pass arguments, never build a string

The most dangerous validation failure is injection — letting untrusted text be interpreted as a command. The classic mistake is interpolating input into a shell string:

import subprocess

# DANGEROUS: shell=True runs the whole string through a shell
subprocess.run("ping " + user_host, shell=True)

# SAFE: arguments as a list, no shell involved
subprocess.run(["ping", "-c", "1", user_host], shell=False, timeout=5)

With shell=True, a user_host of 8.8.8.8; rm -rf / turns a friendly ping into a disaster: the shell sees two commands and runs both. Passing an argument list with shell=False means the input can only ever be an argument to ping, never a new command — the exact mitigation Python's own subprocess security notes and CWE-78 call for. Combine it with the allowlist/validation above: check the host is well-formed and pass it as an argument. The same principle applies to device CLI — build commands from validated fields, never by concatenating raw input.

Authenticate with least privilege

Authenticate strongly, then give each credential the narrowest reach that still lets it do its job:

  • Per-service accounts, not a shared admin login. The account that reads inventory should not be the same all-powerful account that pushes configuration.
  • Least privilege. Scope each credential to only the commands, devices, or API scopes the task requires. A read-only collection account and a change account limited to the relevant devices bound the blast radius if either leaks.
  • Short-lived tokens over static passwords. Prefer OAuth2 tokens where supported, and rotate whatever long-lived credentials you must keep.

Least privilege is a design decision, not a runtime check: you make it when you provision the account, and it pays off the day a credential is exposed.

Keep secrets out of code, Git, and logs

A secret — a password, API token, or private key — must never be hard-coded or committed to version control. Store it in a dedicated system (HashiCorp Vault, Ansible Vault, a cloud secret manager, or masked CI/CD variables) and read it from the environment at run time:

import os

# NEVER:
# password = "S3cr3t!"        # committed to Git forever, in history too

# DO: injected from the secret store at run time
password = os.environ["DEVICE_PASSWORD"]

Committing a token exposes it permanently — deleting it later does not remove it from history, so a leaked secret must be rotated, not just deleted. The cheapest place to stop a leak is before the commit lands. A pre-commit hook running a scanner such as detect-secrets or gitleaks blocks any commit that looks like it contains a key, and the same scan runs in the pipeline as a backstop:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks:
      - id: detect-secrets

Secrets must also stay out of logs, which are widely readable and long-retained — scrub credentials before logging, and keep webhook URLs and tokens in secret storage.

Pin and scan your dependencies

Your automation is only as trustworthy as the libraries it imports. Pulling latest at deploy time means shipping whatever the internet published this morning — the opposite of the controlled, reviewable change discipline the rest of the pipeline enforces. Two habits close the gap:

  • Pin to known versions with a lockfile so builds are reproducible and a malicious or broken release cannot slip in silently.
  • Scan for known vulnerabilities as a pipeline stage. pip-audit checks installed packages against the Python Packaging Advisory Database:
pip-audit -r requirements.txt

A non-zero exit fails the stage, so a dependency with a published CVE stops the build instead of reaching production.

Putting it together

The pillars reinforce each other in a single hardened call. Here a secret comes from the environment, TLS verification stays on (a CA-signed certificate makes that safe), and retries ride out transient errors:

import os
import requests
from requests.adapters import HTTPAdapter, Retry

token = os.environ["API_TOKEN"]          # from the secret manager, not code

session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=Retry(
    total=3, backoff_factor=1, status_forcelist=[429, 502, 503])))

resp = session.get(
    "https://device.example.com/restconf/data",
    headers={"Authorization": f"Bearer {token}"},
    timeout=10,
    verify=True,                         # keep certificate verification ON
)
resp.raise_for_status()

Key takeaways

  • Never trust input. Validate type, range, and format; use a schema (pydantic) and allowlists; reject bad data at the boundary.
  • Avoid injection. Pass arguments as a list with shell=False; never build a shell or device command by string concatenation.
  • Authenticate with least privilege. Per-service accounts, credentials scoped to exactly what the task needs, short-lived tokens, and rotation.
  • Manage secrets properly. Store them in a vault or masked CI/CD variables, read from the environment at run time, and never hard-code, commit, or log them — scan commits to catch leaks early.
  • Keep dependencies trustworthy. Pin versions with a lockfile and scan for known vulnerabilities as a pipeline stage.

Sources: OWASP Input Validation Cheat Sheet, Python subprocess security considerations, CWE-78: OS Command Injection, pydantic validators, detect-secrets and pip-audit.