Introduction
Conjtest is a command-line utility heavily inspired and partially based on conftest. It allows you to write policies against structured configuration data using a robust and practical language. You can, for example, write policies against your EDN files, Kubernetes configurations, Terraform code, or against other common configuration formats.
Who is this for?
This tool is suited for you if:
-
You want to validate your configuration files using a Policy-as-Code tool as part of a CI/CD pipeline.
-
Catch problems or security issues before they become incidents & enforce compliance.
-
You want maintain the policies using Clojure/Babashka.
Installation
Installer script
Download & run the installer script using bash
.
bash < <(curl -s https://raw.githubusercontent.com/ilmoraunio/conjtest/master/install)
By default the script will install the binary to /usr/local/bin
(you may need
to use sudo
).
You can install the binary to another location using --install-dir
.
curl -sO https://raw.githubusercontent.com/ilmoraunio/conjtest/master/install
chmod u+x install
./install --install-dir .
To install a specific version, you may provide --version
.
./install --version 0.1.0
The full list of versions can be found from here.
GitHub releases
Download the binaries for the MacOS (arm64) and Linux (amd64) platforms from
the repository’s latest
release and install the binary to usr/local/bin
.
curl -sL https://github.com/ilmoraunio/conjtest/releases/download/v0.1.0/conjtest-0.1.0-macos-arm64.zip -o conjtest.zip
unzip conjtest.zip conjtest
sudo mv conjtest /usr/local/bin
curl -sL https://github.com/ilmoraunio/conjtest/releases/download/v0.1.0/conjtest-0.1.0-linux-x86_64.tar.gz -o conjtest.tar.gz
tar -xvzf conjtest.zip conjtest
sudo mv conjtest /usr/local/bin
curl -sL https://github.com/ilmoraunio/conjtest/releases/download/v0.1.0/conjtest-0.1.0-linux-arm64.tar.gz -o conjtest.tar.gz
tar -xvzf conjtest.tar.gz conjtest
sudo mv conjtest /usr/local/bin
Getting started
Here’s how to get started with a simple deny policy.
First, let’s initialize conjtest configuration. This will create a default
conjtest.edn
file to your current directory.
conjtest init
After that, add an nginx-based Kubernetes ingress definition defined using YAML.
cat <<EOF > my-ingress.yaml
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/cors-allow-origin: '*'
name: service-a
namespace: foobar
EOF
Now add a policy called policy.clj
containing a rule to fail if the field
nginx.ingress.kubernetes.io/cors-allow-origin
contains a "*"
.
cat <<EOF > policy.clj
(ns policy)
(defn deny-*-cors
[input]
(when (= "*" (get-in input
[:metadata
:annotations
:nginx.ingress.kubernetes.io/cors-allow-origin]))
"CORS is too permissive"))
EOF
Putting these together, you should expect to see conjtest fail due to the
forbidden asterisk "*"
.
$ conjtest test my-ingress.yaml -p policy.clj
FAIL - my-ingress.yaml - deny-*-cors - CORS is too permissive
1 tests, 0 passed, 0 warnings, 1 failures
$ echo $?
1
Once you’ve seen your policy fail, it’s time to make it pass.
cat <<EOF > my-ingress.yaml
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/cors-allow-origin: 'https://safe.site.com'
name: service-a
namespace: foobar
EOF
Your policy should now pass without failure.
$ conjtest test my-ingress.yaml -p policy.clj
1 tests, 1 passed, 0 warnings, 0 failures
$ echo $?
0
And that’s how you create a policy! Your next steps can be to attach the run commands as part of your CI pipeline or to your githooks and, of course, to create more policies.
Usage
Policies
Policies are single-arity functions which must return either of the following values:
-
true
(or truthy) -
false
(or falsey) -
string, eg.
"error message"
-
a collection, eg.
["error message 1" "error message 2"]
Returning a string or a non-empty collection from the function will always result in a policy failure. For truthy/falsey values, the outcome depends on the policy’s type. An empty collection is regarded as a falsey value.
For deny policies, function must return false
(or falsey) to succeed. For
allow policies, function must return true
(or truthy) to succeed. For
warn policies, function must return false
(or falsey) to succeed.
In conjtest
, policy (function) names must start with either deny-
,
allow-
, or warn-
.
When conjtest
is provided a policy file, it will gather every public var in
that file starting with deny-
, allow-
, or warn-
and evaluate them.
Functions using this naming conventions will be evaluated, functions not using
this convention will be filtered out and not evaluated.
Policy type | Function name (begins with) | Policy success |
---|---|---|
Deny |
|
|
Allow |
|
|
Warn |
|
|
(ns policy)
(defn- deployment?
[input]
(= "Deployment" (:kind input)))
(defn deny-should-not-run-as-root
[input]
(let [name (-> input :metadata :name)]
(when (and (deployment? input)
(not (true? (get-in input
[:spec
:template
:spec
:securityContext
:runAsNonRoot]))))
(format "Containers must not run as root in Deployment \"%s\"" name))))
(ns policy)
(defn is-allowlisted?
[allowlist x]
(assert (coll? allowlist))
(some? ((into #{} allowlist) x)))
(def ^:private allowlist ["hello-kubernetes"])
(defn allow-allowlisted-selector-only
[input]
(and (= "Service" (:kind input))
(is-allowlisted? allowlist (get-in input [:spec :selector :app]))))
(ns policy)
(defn warn-when-not-port-80
[input]
(and (= "v1" (:apiVersion input))
(= "Service" (:kind input))
(not= 80 (-> input :spec :ports first :port))))
Policy metadata
You can define policy name, top-level error message (if the rule otherwise fails), and the policy type using metadata.
Supported keys | Supported values | Superseded by |
---|---|---|
|
|
Function name (begins with |
|
string or keyword |
Function name |
|
string |
Function returns an error message |
(defn ^{:rule/type :deny
:rule/message "port should be 80"}
differently-named-deny-rule
[input]
(and (= "v1" (:apiVersion input))
(= "Service" (:kind input))
(not= 80 (-> input :spec :ports first :port))))
Running policies
Once you have a configuration file and a policy, you can perform policy testing using the following command syntax:
conjtest test <configuration_file> [configuration_file [...]] [flags]
You can provide file(s) directly, use globs, or directories for both configuration files and policies.
conjtest test infra/deployment.yaml --policy policies/policy.clj
conjtest test infra/deployment.yaml infra/my-other-deployment.yml --policy policies/policy.clj --policy other_policies/another-policy.clj
conjtest test infra/**/*.{yaml,yml} --policy **/*.clj
conjtest test infra/ --policy policies/
Exit codes
conjtest test
command normally returns exit code 0 on success. Failing deny
or allow policies result in exit code 1. Warn policies will result in exit
code 0.
--fail-on-warn
If the option --fail-on-warn
is provided to the conjtest test
command, then
failing warn policies result in error code 1. Failing deny policies will result
in exit code 2.
Supported runtime libraries
Policies are run using Babashka/Clojure runtime. Conjtest runs policies inside a SCI sandbox which places limitations on code that can be run.
Currently, only the following namespaces (and their contents) can be required:
-
clojure.core
-
clojure.set
-
clojure.edn
-
clojure.repl
-
clojure.string
-
clojure.walk
-
clojure.template
-
Locally defined namespaces (see: Local file requires)
Keyworded keys
Some configuration formats support keyworded keys by default.
$ conjtest parse deps.edn
{:deps
{org.clojure/clojure #:mvn{:version "1.12.0"},
ilmoraunio/conjtest-clj #:local{:root "../conjtest-clj"}}}
To return keyworded keys for all configuration formats, you can provide the
keywordize?
option via --config
.
{:keywordize? true}
$ conjtest parse examples/hcl2/terraform.tf --config conjtest.edn
{:resource
{:aws_alb_listener
{:my-alb-listener [{:port "80", :protocol "HTTP"}]},
:aws_db_security_group {:my-group [{}]},
:aws_s3_bucket
{:valid
[{:acl "private",
:bucket "validBucket",
:tags {:environment "prod", :owner "devops"}}]},
:aws_security_group_rule
{:my-rule [{:cidr_blocks ["0.0.0.0/0"], :type "ingress"}]},
:azurerm_managed_disk
{:source [{:encryption_settings [{:enabled false}]}]}}}
$ conjtest test examples/hcl2/terraform.tf -p examples/hcl2/policy.clj --config conjtest.edn
FAIL - examples/hcl2/terraform.tf - deny-fully-open-ingress - ASG rule ':my-rule' defines a fully open ingress
FAIL - examples/hcl2/terraform.tf - deny-http - ALB listener ':my-alb-listener' is using HTTP rather than HTTPS
FAIL - examples/hcl2/terraform.tf - deny-missing-tags - AWS resource: :aws_alb_listener named ':my-alb-listener' is missing required tags: #{:environment :owner}
FAIL - examples/hcl2/terraform.tf - deny-missing-tags - AWS resource: :aws_db_security_group named ':my-group' is missing required tags: #{:environment :owner}
FAIL - examples/hcl2/terraform.tf - deny-missing-tags - AWS resource: :aws_security_group_rule named ':my-rule' is missing required tags: #{:environment :owner}
FAIL - examples/hcl2/terraform.tf - deny-unencrypted-azure-disk - Azure disk ':source' is not encrypted
4 tests, 0 passed, 0 warnings, 4 failures
Local file requires
It’s possible to require your locally defined namespaces by passing a
configuration file via --config
and :paths
key.
conjtest test infra/deployment.yaml --policy policies/policy.clj --config conjtest.edn
{:paths ["policies/"]}
Conjtest will will recursively include namespaces of all .clj
, .cljc
, and
.bb
files.
Now you can require any local Clojure namespaces.
(ns conjtest.example-local-require
(:require [conjtest.util.core :as util]))
(def ^:private allowlist ["hello-kubernetes"])
(defn allow-allowlisted-selector-only
[input]
(and (= "Service" (:kind input))
(util/is-allowlisted? allowlist (get-in input [:spec :selector :app]))))
Parsing configuration files
Conjtest uses multiple parsers under-the-hood to provide wide support for different configuration file formats, using either Clojure-based parsers or Go-based (ie. Conftest-based) parsers.
Conjtest will try to parse using Clojure-based parsers if a suitable parser can be found, otherwise it will use a Go-based parser. If a suitable parser cannot be found, exit code 1 is returned.
Conjtest supports returning keys using keywords for Clojure-based parsers. For Go-based parsers, keys are returned as a string by default, but can be changed via configuration file (see: Keyworded keys).
If necessary, you can force Conjtest to use Go-based parsers via
--go-parsers-only
.
Debugging
Conjtest supports multiple ways to develop & debug policies.
Parsing files
To debug configuration file contents, you can use conjtest parse
.
$ conjtest parse examples/yaml/kubernetes/deployment.yaml
{:apiVersion "apps/v1",
:kind "Deployment",
:metadata
{:name "hello-kubernetes",
:labels
#:app.kubernetes.io{:name "mysql",
:version "5.7.21",
:component "database",
:part-of "wordpress",
:managed-by "helm"}},
:spec
{:replicas 3,
:selector {:matchLabels {:app "hello-kubernetes"}},
:template
{:metadata {:labels {:app "hello-kubernetes"}},
:spec
{:containers
({:name "hello-kubernetes",
:image "paulbouwer/hello-kubernetes:1.5",
:ports ({:containerPort 8080})})}}}}
You can copy-paste this output and pass it as an argument to your policy function.
--trace
If you need to understand policy behavior under-the-hood, you can use the
--trace
flag to debug the policy more closely.
$ conjtest test test-resources/invalid.yaml --policy test-resources/conjtest/example_deny_rules.clj --trace
Policy argument(s): test-resources/conjtest/example_deny_rules.clj
Filenames parsed: test-resources/invalid.yaml
Policies used: test-resources/conjtest/example_deny_rules.clj
TRACE:
---
Rule name: deny-my-absolute-bare-rule
Input file: test-resources/invalid.yaml
Parsed input: {:apiVersion "v1",
:kind "Service",
:metadata {:name "hello-kubernetes"},
:spec
{:type "LoadBalancer",
:ports ({:port 81, :targetPort 8080}),
:selector {:app "bad-hello-kubernetes"}}}
Result: true
...
...
...
FAIL - test-resources/invalid.yaml - deny-my-absolute-bare-rule - :conjtest/rule-validation-failed
FAIL - test-resources/invalid.yaml - deny-my-bare-rule - port should be 80
FAIL - test-resources/invalid.yaml - deny-my-rule - port should be 80
FAIL - test-resources/invalid.yaml - differently-named-deny-rule - port should be 80
4 tests, 0 passed, 0 warnings, 4 failures
REPL
It’s possible to connect to a network REPL using
babashka.nrepl.server
.
$ conjtest repl
Started nREPL server at 0.0.0.0:1667
Executing this command will open up an nREPL connection to port 1667.
This allows you to develop your policy within the SCI sandbox.
$ clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "0.5.3"}}}' -m nrepl.cmdline -c --host 127.0.0.1 --port 1667
...
user=> (require '[pod-ilmoraunio-conjtest.api :as api])
nil
user=> (defn deny-my-policy
[input]
(when ((into #{} (:paths input)) "evil-dir")
"evil-dir found!"))
#'user/deny-my-policy
user=> (deny-my-policy (first (vals (api/parse "deps.edn"))))
nil
user=> (deny-my-policy (update (first (vals (api/parse "deps.edn")))
:paths
conj
"evil-dir"))
"evil-dir found!"
user=> (conjtest/test! [(update (first (vals (api/parse "deps.edn")))
:paths
conj
"evil-dir")] #'deny-my-policy)
{:summary {:total 1, :passed 0, :warnings 0, :failures 1}, :summary-report "1 tests, 0 passed, 0 warnings, 1 failures\n", :result ({:message "evil-dir found!", :name "deny-my-policy", :rule-type :deny, :failure? true})}
Use via Babashka
Conjtest supports usage via Babashka directly with conjtest-clj library and pod-ilmoraunio-conjtest.
This can be useful if you need to use your own libraries, parser, or reporting mechanism.
Simple example using babashka tasks.
Contributing
Contributions are welcome!
Join the #conjtest channel in the Clojurians Slack for discussion.
You can also make an issue or PR to the following project repositories:
Documentation
To contribute to this user guide, make an issue and/or PR to the repository.
License
Copyright © 2025 Ilmo Raunio
Licensed under CC BY-SA 4.0: https://creativecommons.org/licenses/by-sa/4.0
With attributions to:
https://github.com/babashka/book, Copyright © 2020-2021 Michiel Borkent, licensed under CC BY-SA 4.0.