How I Built a CI/CD Linter with Python, OPA, and Rego to Secure GitHub Workflows


CI/CD is the backbone of modern software delivery โ but too often, it's treated as an afterthought when it comes to validation and security.
We lint our code.
We test our deployments.
But our GitHub Actions YAMLs? They're often pushed and merged with minimal checks.
This blog explores how I designed and built a modular, policy-driven CI/CD YAML validator using:
Python for custom linting
Open Policy Agent (OPA) and Rego for structured rule evaluation
GitHub Actions for automation and CI integration
๐ก The Motivation
I've worked with teams that heavily rely on GitHub Actions, and Iโve repeatedly seen the same misconfigurations cause issues:
Using
@latest
or unpinned versions of actionsOmitting
permissions:
fields, resulting in full token accessHardcoded secrets in environment variables
Using unsafe commands like
set-env
, which were deprecated due to vulnerabilities
These are small mistakes, but they can lead to serious problems โ from leaking credentials to introducing backdoors. Code reviewers often miss them, especially when they're tucked away in YAML files.
So I asked myself:
โWhat if we could treat our CI/CD configuration like code โ with linting, policy enforcement, and test coverage?โ
Thatโs where this project began.
๐ฏ The Vision
Build a lightweight, extendable scanner that can:
โ
Parse and lint GitHub Actions YAML files
โ
Enforce best practices through policy-as-code
โ
Integrate directly into GitHub pull requests
โ
Be testable, reusable, and maintainable
๐ ๏ธ The Stack
Tool | Role |
Python | Custom YAML parsing and static linting |
OPA (Open Policy Agent) | Policy engine for security rules |
Rego | DSL for writing structured policy logic |
GitHub Actions | Runs the validator in CI pipelines |
pytest | Python unit test framework |
opa test | Policy test framework |
๐๏ธ Folder Structure
Here's a simplified view of how the repo is organized:
ci-cd-linter-validator-scanner/
โโโ scanner/ # Python CLI logic
โ โโโ linter.py # YAML anti-pattern checker
โ โโโ opa_runner.py # Invokes OPA
โ โโโ utils.py # Common helpers
โ
โโโ policies/ # Rego rule sets
โ โโโ base/ # Best-practice policies
โ โโโ strict/ # Org-enforced rules
โ โโโ custom/ # Add-your-own policies
โ
โโโ examples/ # Good and bad YAMLs
โโโ tests/ # pytest + opa test files
โโโ .github/workflows/ # GitHub Actions for validation
โโโ action.yml # Reusable GitHub Action interface
โโโ README.md
๐งช Writing the Python Linter
The Python linter uses PyYAML
to parse the workflow YAML and then checks for:
@latest
or branch-based actions (uses: actions/checkout@master
)Missing
permissions:
at the job levelInsecure environment variable patterns
Use of deprecated GitHub Action commands
Example check for @latest
:
def find_issues(yaml_data):
issues = []
jobs = yaml_data.get("jobs", {})
for job_name, job in jobs.items():
for step in job.get("steps", []):
uses = step.get("uses", "")
if "@latest" in uses or "@master" in uses:
issues.append(f"Step '{uses}' in job '{job_name}' uses an unpinned action version.")
return issues
๐ก๏ธ Defining Policy Rules in Rego
OPA policies let you formalize your orgโs security posture. Each rule looks for specific violations and outputs a list of messages (deny[msg]
).
Example Rego rule (unversioned actions):
package ci_cdpipeline
deny[msg] {
some job
some step
input.jobs[job].steps[_] = step
step.uses
not contains(step.uses, "@")
msg := sprintf("Action '%s' in job '%s' is not version pinned.", [step.uses, job])
}
OPA is invoked from Python using subprocess and passed the YAML as input. The result? You get flexible policy enforcement layered over your linter.
๐ Using It as a GitHub Action
A major goal was to make this reusable as a drop-in GitHub Action:
- uses: amankc-neo/ci-cd-linter-validator-scanner@v1
with:
target_file: ".github/workflows/deploy.yml"
strict_mode: "true"
This works by referencing the action.yml
definition:
inputs:
target_file:
description: "Path to YAML to scan"
required: true
runs:
using: "composite"
steps:
- run: python scanner/linter.py ${{ inputs.target_file }}
You can even integrate this into your organizationโs CI pipeline templates or GitHub App logic later on.
๐งช Testing the Rules
Tests were essential for building trust in this tool:
โ Python tests via
pytest
โ Rego rule tests via
opa test
โ CI validation with real bad/good YAMLs
Sample Python test:
def test_latest_tag_violation():
data = {
"jobs": {
"build": {
"steps": [{"uses": "docker/build-push-action@latest"}]
}
}
}
issues = find_issues(data)
assert any("unpinned" in i for i in issues)
Sample Rego policy test:
test_unpinned_action_violation {
input := {
"jobs": {
"build": {
"steps": [
{"uses": "actions/setup-node@latest"}
]
}
}
}
deny[_] == "Action 'actions/setup-node@latest' in job 'build' is not version pinned."
}
๐ฎ Future Roadmap
Here's where I see this project going:
๐
scan_all
flag to lint all workflows at once๐งฉ Plugin model to support GitLab CI, Bitbucket Pipelines
๐ GitHub App or CLI tool for rich reporting
๐ Secrets detection via entropy analysis
๐ฏ Rule categories:
security
,performance
,maintainability
๐ GitHub Repo
๐ https://github.com/amankc-neo/ci-cd-linter-validator-scanner
๐ฌ Final Thoughts
This project was a deep dive into DevSecOps, GitHub Actions internals, and policy-as-code tooling. Itโs also a reminder that even in the CI layer, we need structure, repeatability, and enforcement.
Security and quality should never be an afterthought โ especially not in the pipelines that ship your code.
Whether you're a solo developer or running a platform team, I hope this tool inspires you to bring the same level of rigor to your pipelines that you bring to your codebase.
If youโve worked on anything similar, or have rules youโd love to see included โ drop a comment or raise a PR.
Thanks for reading ๐
Subscribe to my newsletter
Read articles from Aman Choudhary directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
