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:
- Vulnerable Dependencies: Your project is only as secure as its weakest dependency. Using third-party libraries with known vulnerabilities is a primary attack vector.
- 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.
- Insecure Secrets Management: Hardcoding or improperly storing secrets like API keys, database credentials, and encryption keys makes them easy targets for theft.
- 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,assertin production, unsafesubprocesscalls, insecure deserialization). This replaces the standalonebandittool 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 tomise run check:lint). -
Vulnerable dependencies:
pip-auditchecks 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-auditBoth flags matter.
--skip-editableexcludes your own project, which is installed in editable mode byuv syncand has no advisory database entry to look up.--cache-dir .cache/pip-auditmoves 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:
gitleaksscans your working tree and git history for accidentally committed credentials (mise run check:leaks). -
Misconfigurations, secrets, licenses, and vulnerabilities in the checkout:
trivyscans 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: truehides 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-dirsis not cosmetic.mise run checkruns its subtasks in parallel, socheck:vuln(pip-audit) is writing into.cache/pip-auditat the same momentcheck:scanwalks 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.gitremoves 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:
- Before it exists in history — the
pre-commithook runsmise 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. -
In the recent history — the
check:leakstask 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' -
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: 0is the whole point: the default shallow checkout cannot see the commit where the secret was introduced.--redact=100keeps 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.
uvcovers your Python dependencies,github-actionscovers the actions your workflows call, anddockercovers base images in yourDockerfile. A project that monitors only Python is still running last year'sactions/checkoutand last year's base image. day: "monday"turns dependency review into a predictable Monday-morning task instead of an unscheduled interruption.groupscollapses every minor and patch bump in one ecosystem into a single pull request. Becauseupdate-typeslists onlyminorandpatch, 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. Thecliff.tomlused 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 asbuild(deps): ...(orbuild(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:
- The upper bound is stale, not protective. MLflow does not depend on anything
cryptography50 removed; the bound was written before 50 existed. - 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.
- The reason is written down where the override lives. The comment names the advisory, the version that fixes it, and the condition for removal.
- 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.