Skip to content

4.4. Security

What is software security?

Software security is a specialized field of engineering focused on designing software to be resilient against malicious attacks and threats. It involves implementing a set of best practices and safeguards to protect data, preserve application integrity, and ensure that systems function as intended without unauthorized access or manipulation. In essence, it's about building software that can defend itself.

Why is software security crucial for any project?

Software security is non-negotiable for several fundamental reasons:

  • Data Protection: It erects a barrier against unauthorized access to sensitive data, including user information, financial records, and proprietary intellectual property.
  • Trust and Reputation: Secure software builds and maintains user trust. A single security breach can irreparably damage a company's reputation.
  • Regulatory Compliance: Many industries are governed by strict data protection regulations (like GDPR or HIPAA). Adhering to security standards is often a legal necessity.
  • Financial Stability: Breaches lead to direct financial losses from theft, regulatory fines, and the cost of remediation. They also cause indirect losses by eroding customer confidence.
  • Service Availability: Robust security ensures that your applications remain operational, preventing costly downtime and disruptions to business continuity.

What are the primary security risks in Python and MLOps?

While Python and MLOps environments have unique challenges, the core security risks can be categorized as follows:

  1. Vulnerable Dependencies: Your project is only as secure as its weakest dependency. Using third-party libraries with known vulnerabilities is a primary attack vector.
  2. Improper Input Validation: Failing to validate and sanitize inputs from users or other systems can expose your application to injection attacks (e.g., SQL injection, command injection) or other exploits.
  3. Insecure Secrets Management: Hardcoding or improperly storing secrets like API keys, database credentials, and encryption keys makes them easy targets for theft.
  4. Model-Specific Threats: MLOps introduces unique vulnerabilities, including model poisoning (corrupting training data to compromise the model), data leakage (sensitive data being inadvertently exposed through model predictions), and inference attacks (reverse-engineering the model or its training data).

While the isolated nature of many MLOps backend processes offers some protection from direct internet threats, any component exposed to external interaction—such as an online inference API—must be rigorously secured.

How can you enhance security in a Python environment?

Using automated tools to scan for security problems is a highly effective strategy. Rather than a single tool, the canonical stack layers a few focused scanners, each mapped to a mise run check:* subtask so the same commands run locally and in CI:

  • Static security linting: Ruff enforces the flake8-bandit (S) rules, which detect common security issues in Python code (hardcoded passwords, assert in production, unsafe subprocess calls, insecure deserialization). This replaces the standalone bandit tool with a single, much faster linter. Enable it by adding "S" to your Ruff selection:

    [tool.ruff.lint]
    select = ["S"] # flake8-bandit security rules (plus your other rules)
    
    [tool.ruff.lint.per-file-ignores]
    # asserts are fine in tests, so silence S101 there
    "tests/**" = ["S101"]
    

    Run it as part of linting with uv run ruff check (wired to mise run check:lint).

  • Vulnerable dependencies: pip-audit checks your resolved dependencies against known vulnerability databases.

    # Install pip-audit into your "dev" dependency group
    uv add --group dev pip-audit
    
    # Audit the project's dependencies (mise run check:vuln)
    uv run pip-audit --skip-editable --cache-dir .cache/pip-audit
    

    Both flags matter. --skip-editable excludes your own project, which is installed in editable mode by uv sync and has no advisory database entry to look up. --cache-dir .cache/pip-audit moves the advisory cache from the user's home directory into the repository, so a CI runner caches it with the rest of the checkout and every developer sees the same behavior; add .cache/ to your .gitignore.

  • Leaked secrets: gitleaks scans your working tree and git history for accidentally committed credentials (mise run check:leaks).

  • Misconfigurations, secrets, licenses, and vulnerabilities in the checkout: trivy scans the repository against a policy you commit alongside it (mise run check:scan).

Together these give you defense in depth: Ruff S rules catch insecure code patterns, pip-audit catches vulnerable dependencies, gitleaks catches exposed secrets, and trivy catches risky configuration.

How should you invoke Trivy?

This is a place where an obvious-looking command quietly does far less than you think. The command the reference repositories run is:

# mise run check:scan
trivy --config trivy.yaml fs .

Two details in that line each fix a real bug.

Why --config trivy.yaml is explicit

Trivy discovers its configuration through a precedence chain, and an environment variable sits above the file in your repository. If a developer (or a base image, or a shell profile) exports TRIVY_CONFIG pointing at another policy, that policy silently wins over the trivy.yaml you committed. The scan still succeeds, still prints a clean result, and never applies a single one of your rules.

Passing --config trivy.yaml on the command line puts your repository's policy at the top of the chain. The scan now enforces what the repository says it enforces, on every machine, regardless of the surrounding environment.

Why fs and not config

trivy config . reads naturally as "scan my configuration", and that is exactly the trap. The config subcommand enables only the misconfiguration scanner. It does not honor the scan.scanners list in your configuration file — it has already decided what to run.

So a repository whose trivy.yaml declares four scanners, invoked as trivy config ., actually runs one of them. Three quarters of the policy never executed, and nothing in the output said so.

trivy fs . scans the filesystem with the scanners the configuration file declares. Same target, same policy file, four scanners instead of one.

What the policy file contains

# trivy.yaml
severity:
  - HIGH
  - CRITICAL
scan:
  scanners:
    - license
    - misconfig
    - secret
    - vuln
  # Caches and virtualenvs are not source. `.cache` in particular is written by
  # `check:vuln` (pip-audit) while this scan runs in parallel, and trivy aborts when a
  # temporary file disappears mid-walk.
  skip-dirs:
    - .cache
    - .venv
    - .git
vulnerability:
  ignore-unfixed: true

Three choices are worth explaining:

  • severity: [HIGH, CRITICAL] keeps the gate actionable. A scanner that reports every LOW finding trains people to ignore it.
  • ignore-unfixed: true hides vulnerabilities with no released fix. You cannot act on them today, and a permanently red build is a build nobody reads. Revisit this if you ship to a regulated environment that requires tracking them.
  • skip-dirs is not cosmetic. mise run check runs its subtasks in parallel, so check:vuln (pip-audit) is writing into .cache/pip-audit at the same moment check:scan walks the tree. Trivy aborts with a fatal error when a file it has listed disappears mid-walk, which makes the whole gate fail intermittently for a reason that has nothing to do with your code. Excluding caches, the virtualenv, and .git removes the race and cuts the scan time, because none of those directories are your source anyway.

How do you lint your CI/CD workflows and container image?

Two more scanners belong in mise run check, because workflow files and Dockerfiles are code with production consequences:

# mise.toml
[tasks."check:actions"]
description = "Lint and audit GitHub Actions workflows (actionlint + zizmor)"
run = ["actionlint", "zizmor --offline .github/workflows/"]

[tasks."check:dockerfile"]
description = "Lint the container image definition (hadolint)"
run = "hadolint Dockerfile"

actionlint catches syntax and expression errors in workflow files; zizmor audits them for security weaknesses such as script injection through untrusted github.event values or over-broad permissions. 5.3. CI/CD Workflows covers what these two find and how the workflows are written to satisfy them.

One zizmor default deserves a note here, because it is a genuine policy disagreement rather than a bug. By default zizmor requires actions to be pinned to a commit hash. The reference repositories pin to major-version tags instead, so that security patches within a major arrive automatically. Relaxing the rule records that decision explicitly rather than leaving the audit permanently red:

# .github/zizmor.yml
# Actions are deliberately pinned to major-version tags: tags track security patches
# within a major, at the cost of trusting the tag. zizmor's default policy demands
# hash-pins; relax it to ref-pins so the audit enforces the actual policy.
rules:
  unpinned-uses:
    config:
      policies:
        "*": ref-pin

hadolint lints the Dockerfile. It is the reason the reference image declares a numeric user instead of a name: rule DL3066 flags a non-numeric user id, because a host cannot resolve a username that only exists inside the image's /etc/passwd.

# Fixed numeric uid/gid: stable file ownership across rebuilds and bind mounts, and
# resolvable by a host that does not share this image's /etc/passwd.
RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -m app
USER 10001:10001
COPY --from=build --chown=10001:10001 /app/.venv /app/.venv

Where should secret scanning run?

Once, in one place, is not enough. A committed secret has three distinct lifetimes, and each needs a different scan:

  1. Before it exists in history — the pre-commit hook runs mise run check:leaks --staged, which scans only the staged diff. This is the scan that actually saves you: a secret caught here never enters a commit object, so there is nothing to rotate and no history to rewrite.
  2. In the recent history — the check:leaks task itself is deliberately bounded so it stays fast enough to run on every commit and every CI job:

    # mise.toml
    [tasks."check:leaks"]
    description = "Audit codebase for leaked secrets (gitleaks)"
    run = 'gitleaks git --log-opts="--max-count=100" --verbose'
    
  3. Anywhere in the full history — a secret that was committed and later deleted is invisible to a bounded scan forever after. That case needs a scheduled, full-depth job:

    # .github/workflows/security.yml
    on:
      schedule:
        - cron: "17 3 * * 1"
      workflow_dispatch:
    jobs:
      scan:
        runs-on: ubuntu-latest
        steps:
          - name: Checkout complete history
            uses: actions/checkout@v7
            with:
              fetch-depth: 0
              persist-credentials: false # scanners only read the checkout
          - name: Install mise system
            uses: jdx/mise-action@v4
          - name: Scan complete Git history
            run: gitleaks git --redact=100 --verbose
          - name: Scan full checkout
            run: trivy fs .
    

    fetch-depth: 0 is the whole point: the default shallow checkout cannot see the commit where the secret was introduced. --redact=100 keeps the secret itself out of the public workflow logs.

How can GitHub help manage security risks?

GitHub provides powerful, integrated tools to automate security monitoring. Dependabot is a key feature that automatically scans your project's dependencies for known vulnerabilities and opens pull requests to update them to secure versions.

To enable Dependabot, create a configuration file at .github/dependabot.yml. This file tells Dependabot which package ecosystems to monitor, how often to check, how to group updates, and how to word its commits.

# .github/dependabot.yml
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference
# Minor and patch updates arrive grouped, one pull request per ecosystem; majors stay
# separate so a breaking bump is always reviewed on its own.
version: 2
updates:
  - package-ecosystem: "uv"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
    commit-message:
      prefix: "chore(deps)"
    groups:
      python:
        patterns: ["*"]
        update-types: ["minor", "patch"]
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
    commit-message:
      prefix: "chore(deps)"
    groups:
      actions:
        patterns: ["*"]
        update-types: ["minor", "patch"]
  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
    commit-message:
      prefix: "chore(deps)"
    groups:
      docker:
        patterns: ["*"]
        update-types: ["minor", "patch"]

Four choices here are worth the extra lines:

  • Three ecosystems, not one. uv covers your Python dependencies, github-actions covers the actions your workflows call, and docker covers base images in your Dockerfile. A project that monitors only Python is still running last year's actions/checkout and last year's base image.
  • day: "monday" turns dependency review into a predictable Monday-morning task instead of an unscheduled interruption.
  • groups collapses every minor and patch bump in one ecosystem into a single pull request. Because update-types lists only minor and patch, major bumps stay on their own pull request, where a breaking change gets reviewed in isolation.
  • commit-message.prefix: "chore(deps)" aligns Dependabot with your changelog configuration, and this one bit us. The cliff.toml used to generate the changelog (see 6.3. Releases) already excluded dependency noise:

    # cliff.toml, inside [git] commit_parsers
    { message = "^chore\\(deps\\)", skip = true },
    

    But Dependabot's default prefix is build, so its commits arrived as build(deps): ... (or build(deps-dev): ... for development dependencies). The filter never matched anything, and ten dependency bumps ended up in a published changelog written for humans. Nothing was broken enough to notice; the two configuration files simply disagreed about a string. Setting the prefix explicitly makes the filter do the job it was written for.

When may you override an upstream version constraint?

Sooner or later pip-audit reports a vulnerability whose fix you cannot install, because one of your dependencies declares an upper bound that excludes the patched release. You then have two options that look superficially similar and are fundamentally opposite.

Suppressing the scanner means telling pip-audit to ignore the advisory (or lowering severity in trivy.yaml until the finding disappears). The vulnerable code is still installed and still running. You have removed the warning, not the risk.

Overriding the constraint means installing the patched library anyway, against the stale upper bound, and then proving with your test suite that the dependency still works. The vulnerable code is gone.

The MLOps Python Package needed the second one:

[tool.uv]
# MLflow 3.15 still declares `cryptography<50`, but the fix for PYSEC-2026-3552
# (PKCS#7 Bleichenbacher oracle) only ships in 50.0.0. Overriding the stale upper
# bound installs the patched library; the test suite is what proves MLflow still
# works with it. Drop this override once MLflow relaxes the constraint upstream.
override-dependencies = ["cryptography>=50"]

Remove that override and uv re-locks cryptography to 49.0.0, and mise run check:vuln fails on the advisory again. The override is what keeps the gate green and the installation safe.

An override is legitimate only when all four of these hold:

  1. The upper bound is stale, not protective. MLflow does not depend on anything cryptography 50 removed; the bound was written before 50 existed.
  2. Your test suite exercises the affected dependency. This is the load-bearing condition. The tests are the evidence that overriding the bound did not break MLflow, and without them you are simply guessing.
  3. The reason is written down where the override lives. The comment names the advisory, the version that fixes it, and the condition for removal.
  4. The override has an exit. "Drop this once MLflow relaxes the constraint upstream" turns a permanent hack into a temporary one with a defined end.

If you cannot satisfy those, do not reach for a suppression instead — the honest outcome is to record the risk, pin to the last safe version, or replace the dependency.

By combining automated tools like Ruff's S rules, pip-audit, gitleaks, trivy, actionlint, zizmor, and hadolint with GitHub's native security features, you can build a robust defense against common security threats.

Additional Resources