Shipping Infrastructure Code into Restricted Environments

SecurityDevSecOpsInfrastructure as CodeSupply ChainAnsibleCompliance

No Pipeline Reaches the Target

In many regulated environments the code that hardens the systems cannot be pulled from a Git server. The target network is separated; files enter through a controlled transfer process; someone on the other side unpacks an archive and runs it.

That changes what a "release" is. It is no longer a tag in a repository. It is a file that crosses a security boundary, and everything inside it goes wherever the archive goes.

This article covers the release step and the earlier gates that keep the wrong content out of it.

Build from Commits, Not from the Working Tree

The first version of a release script for such an environment usually looks like this:

rsync -a --exclude .git ./ansible/ "$STAGING/"
zip -r release.zip "$STAGING"

It works, and it is dangerous. The working tree contains everything that .gitignore keeps out of Git: decrypted or encrypted vault.yml files, deployment logs with host names and operator identities, local SSH keys, .env files, caches. An exclude list in the release script has to repeat .gitignore perfectly, forever.

Build from the committed tree instead:

# Refuse to build from a dirty tree: the archive must match a commit
if [[ -n "$(git status --porcelain -- "$SRC_DIR")" ]]; then
  echo "ERROR: uncommitted changes in $SRC_DIR"
  exit 1
fi

git archive --format=tar "HEAD:$SRC_DIR" | tar -x -C "$STAGING"

git archive only knows committed files. Ignored and untracked files cannot reach the archive by construction, and the release corresponds to exactly one commit that anyone can check out again.

Refuse to Package Secrets

Building from Git does not help if a secret was committed by mistake. The release script checks the staging area before anything is compressed:

SECRET_FILES="$(find "$STAGING" -type f \( \
    -name 'vault.yml' -o -name 'vault.yaml' \
    -o -name '*.key' -o -name '*.pem' \
    -o -name 'id_rsa*' -o -name 'id_ecdsa*' -o -name 'id_ed25519*' \
    -o -name '.env*' \) | sed "s|^$STAGING/||")"

if [[ -n "$SECRET_FILES" ]]; then
  echo "ERROR: secret material in the release staging area:"
  echo "$SECRET_FILES" | sed 's/^/  /'
  exit 1
fi

This is a last line of defence, deliberately crude. It matches by file name, fails the build, and lists the offending paths. Secrets for the target environment are provisioned there, not shipped.

Prove What Was Shipped

Each archive gets a metadata file next to it:

release        = 2.6.0
built_at       = 2026-03-17T14:02:11Z
git_branch     = main
git_commit     = 7d1e9b2
files          = 388
roles          = 22
sha256         = 9c1e…

The SHA-256 checksum lets the receiving side confirm that the archive that arrived is the archive that was built. The commit ID connects it to the source. File and role counts are a quick plausibility check after extraction.

Generate counts from the archive, never hard-code them. A release script that once wrote a fixed control count and verification date into its metadata will keep writing them long after both have changed.

Stop Leaks Before the Commit

The release checks are the last gate. Most leaks should never get that far. Pre-commit hooks stop them on the developer’s machine:

repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.21.2
    hooks:
      - id: gitleaks
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: detect-private-key
      - id: check-added-large-files
        args: ['--maxkb=512']
      - id: no-commit-to-branch
        args: ['--branch', 'main']
  - repo: local
    hooks:
      - id: no-env-files
        name: Block .env file commits
        entry: 'bash -c "echo ERROR: .env files must not be committed && exit 1"'
        language: system
        files: '\.env(\..+)?$'
        exclude: '\.env(\..+)?\.example$'
      - id: no-binary-secrets
        name: Block key and keystore files
        entry: 'bash -c "echo ERROR: key material must not be committed && exit 1"'
        language: system
        files: '\.(p12|pfx|key|pem|jks|keystore|gpg)$'

Topology Is Sensitive Too

In restricted environments, secrets are not the only problem. Internal host names and IP addresses describe the network, and that description can itself be protected information. gitleaks can be extended with custom rules:

[extend]
  useDefault = true

[[rules]]
  id = "internal-hostname"
  description = "Internal infrastructure hostname detected"
  regex = '''[a-zA-Z0-9_-]+\.(intern|internal|corp\.example)'''

  # A fictional lab domain used in documentation and tests
  [rules.allowlist]
    regexTarget = "match"
    regexes = ['''^lab\.intern(al)?$''']

[[rules]]
  id = "private-ipv4-address"
  description = "Private IPv4 address (potential topology leak)"
  regex = '''\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2[0-9]|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b'''
  [rules.allowlist]
    paths = ['''\.md$''', '''\.example$''', '''inventory/''', '''group_vars/''', '''host_vars/''']

Two details took a while to get right:

  • Scope allowlists to the rule. A global allowlist for the lab domain would also silence it for every other rule.
  • Know what the allowlist is matched against. Without a secret group, gitleaks treats the first capture group as the "secret". An allowlist regex written for the full host name then never matches. regexTarget = "match" makes the allowlist apply to the whole match.

The IP rule is allowlisted where addresses legitimately live (inventories and variables, which stay in the protected repository) and enforced everywhere else: scripts, roles, templates, Terraform modules.

Classification Markings Do Not Belong in Code

Documents in regulated environments carry classification markings. Source code usually should not: a marking in a role or template means that protected content was pasted into code, or that the file will be treated as classified wherever it goes.

A simple local hook catches this:

- id: no-classified-markers
  name: Check for classification markings in source
  entry: 'bash -c "if grep -rlPi \"(RESTRICTED|CONFIDENTIAL|SECRET)\" \"$@\"; then echo ERROR: classification markings found; exit 1; fi" --'
  language: system
  types: [text]
  files: '\.(tf|yml|yaml|py|sh|j2|hcl)$'
  exclude: '(\.pre-commit-config\.yaml|scripts/release\.sh)$'

The exclusions are deliberate and visible: the hook configuration contains the patterns, and the release script legitimately writes the marking of the release into its metadata. Every other file fails the commit. Adjust the pattern to the scheme your environment uses.

Install Hooks Automatically

Hooks only help if they are installed. The development environment (a pinned toolchain via devbox or a similar tool) runs pre-commit install in its init hook, so every clone that enters the environment gets the hooks without a separate step.

Hooks can be skipped with --no-verify. If that matters in your setup, run pre-commit run --all-files in CI as well, so a bypass on one machine still fails the pipeline.

Conclusion

When code crosses a security boundary as an archive, the release script becomes a security control. Build from commits so ignored files cannot leak; refuse secrets by name as a last line of defence; ship a checksum and the commit ID; and move most of the protection earlier, into hooks that catch secrets, topology and classification markings before they are ever committed.

Shipping Infrastructure Code into Restricted Environments - Patrick Paechnatz