From cb65f868c396230e1a9f365d04084c12772fec3b Mon Sep 17 00:00:00 2001
From: Tigah <88289044+TBX3D@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:47:54 -0700
Subject: [PATCH] feat(modules): add terraform tfvars, ansible vault and ci
config exposure modules (#299)
* feat(modules): add tfvars, ansible-vault and ci config exposure
* chore(modules): trim redundant module header comments
drop the top-line comments on the new iac config exposure modules that
just restated the id/name fields already in the info block
---
.../modules/iac_ci_config_exposure_test.go | 259 ++++++++++++++++++
modules/recon/ansible-vault-exposure.yaml | 47 ++++
modules/recon/gitlab-ci-config-exposure.yaml | 55 ++++
modules/recon/jenkinsfile-exposure.yaml | 55 ++++
modules/recon/terraform-tfvars-exposure.yaml | 64 +++++
5 files changed, 480 insertions(+)
create mode 100644 internal/modules/iac_ci_config_exposure_test.go
create mode 100644 modules/recon/ansible-vault-exposure.yaml
create mode 100644 modules/recon/gitlab-ci-config-exposure.yaml
create mode 100644 modules/recon/jenkinsfile-exposure.yaml
create mode 100644 modules/recon/terraform-tfvars-exposure.yaml
diff --git a/internal/modules/iac_ci_config_exposure_test.go b/internal/modules/iac_ci_config_exposure_test.go
new file mode 100644
index 0000000..7e3f47f
--- /dev/null
+++ b/internal/modules/iac_ci_config_exposure_test.go
@@ -0,0 +1,259 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · Blazing-fast pentesting suite :
+: ▄█ █ █▀ · BSD 3-Clause License :
+: :
+: (c) 2022-2026 vmfunc, xyzeva, :
+: lunchcat alumni & contributors :
+: :
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+*/
+
+package modules_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/vmfunc/sif/internal/modules"
+)
+
+func runIACModule(t *testing.T, file string, status int, body string) *modules.Result {
+ t.Helper()
+ def, err := modules.ParseYAMLModule(file)
+ if err != nil {
+ t.Fatalf("parse %s: %v", file, err)
+ }
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(body))
+ }))
+ defer srv.Close()
+
+ res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
+ Timeout: 5 * time.Second,
+ Threads: 2,
+ })
+ if err != nil {
+ t.Fatalf("execute %s: %v", file, err)
+ }
+ return res
+}
+
+func iacExtract(res *modules.Result, key string) string {
+ for _, f := range res.Findings {
+ if v := f.Extracted[key]; v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func TestTerraformTfvarsExposure(t *testing.T) {
+ const file = "../../modules/recon/terraform-tfvars-exposure.yaml"
+
+ t.Run("real tfvars with region and password leaks", func(t *testing.T) {
+ body := "region = \"us-east-1\"\n" +
+ "instance_type = \"t3.medium\"\n" +
+ "db_password = \"hunter2wow\"\n"
+ res := runIACModule(t, file, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a tfvars finding")
+ }
+ if v := iacExtract(res, "tfvars_first_key"); v != "region" {
+ t.Errorf("tfvars_first_key=%q, want region", v)
+ }
+ })
+
+ t.Run("a single assignment line is not enough structure", func(t *testing.T) {
+ body := "region = \"us-east-1\"\n"
+ if res := runIACModule(t, file, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a lone assignment line should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("two assignment lines without infra vocabulary is not a leak", func(t *testing.T) {
+ body := "greeting = \"hello\"\nfarewell = \"bye\"\n"
+ if res := runIACModule(t, file, 200, body); len(res.Findings) > 0 {
+ t.Errorf("generic key/value pairs should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("an html page quoting a tfvars sample is not a leak", func(t *testing.T) {
+ body := "
docsexample:\n" +
+ "region = \"us-east-1\"\ndb_password = \"hunter2wow\"\n
"
+ if res := runIACModule(t, file, 200, body); len(res.Findings) > 0 {
+ t.Errorf("an html docs page should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a plain 200 body is not a leak", func(t *testing.T) {
+ if res := runIACModule(t, file, 200, "ok"); len(res.Findings) > 0 {
+ t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a 404 is not a leak", func(t *testing.T) {
+ if res := runIACModule(t, file, 404, "not found"); len(res.Findings) > 0 {
+ t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
+ }
+ })
+}
+
+func TestAnsibleVaultExposure(t *testing.T) {
+ const file = "../../modules/recon/ansible-vault-exposure.yaml"
+
+ t.Run("a real vault header with hex ciphertext leaks", func(t *testing.T) {
+ body := "$ANSIBLE_VAULT;1.1;AES256\n" +
+ "66306233383530323332383937616434373966336134393634356164616662653933\n" +
+ "3934363865616461393866346336336336616337663764303431653534380a3833\n"
+ res := runIACModule(t, file, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected an ansible vault finding")
+ }
+ if v := iacExtract(res, "vault_format_version"); v != "1" {
+ t.Errorf("vault_format_version=%q, want 1", v)
+ }
+ })
+
+ t.Run("a 1.2 vault header also leaks", func(t *testing.T) {
+ body := "$ANSIBLE_VAULT;1.2;AES256\n" +
+ "66306233383530323332383937616434373966336134393634356164616662653933\n"
+ res := runIACModule(t, file, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected an ansible vault finding")
+ }
+ if v := iacExtract(res, "vault_format_version"); v != "2" {
+ t.Errorf("vault_format_version=%q, want 2", v)
+ }
+ })
+
+ t.Run("a docs page mentioning the vault header mid-body is not a leak", func(t *testing.T) {
+ body := "ansible vault docs" +
+ "a vault file starts with $ANSIBLE_VAULT;1.1;AES256 followed by hex" +
+ ""
+ if res := runIACModule(t, file, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a docs page should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("the bare header without a ciphertext blob is not a leak", func(t *testing.T) {
+ body := "$ANSIBLE_VAULT;1.1;AES256\n"
+ if res := runIACModule(t, file, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a header with no ciphertext should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a plain 200 body is not a leak", func(t *testing.T) {
+ if res := runIACModule(t, file, 200, "ok"); len(res.Findings) > 0 {
+ t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a 404 is not a leak", func(t *testing.T) {
+ if res := runIACModule(t, file, 404, "not found"); len(res.Findings) > 0 {
+ t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
+ }
+ })
+}
+
+func TestGitlabCIConfigExposure(t *testing.T) {
+ const file = "../../modules/recon/gitlab-ci-config-exposure.yaml"
+
+ t.Run("a real gitlab-ci pipeline leaks the build image", func(t *testing.T) {
+ body := "stages:\n - build\n - deploy\n\nbuild:\n stage: build\n" +
+ " image: registry.internal.example.com/builder:1.4\n" +
+ " script:\n - make build\n artifacts:\n paths:\n - dist/\n"
+ res := runIACModule(t, file, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a gitlab-ci finding")
+ }
+ if v := iacExtract(res, "ci_image"); v != "registry.internal.example.com/builder:1.4" {
+ t.Errorf("ci_image=%q, want registry.internal.example.com/builder:1.4", v)
+ }
+ })
+
+ t.Run("a script key alone without stage vocabulary is not a leak", func(t *testing.T) {
+ body := "script: this word appears in prose too\n"
+ if res := runIACModule(t, file, 200, body); len(res.Findings) > 0 {
+ t.Errorf("a bare script mention should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a blog post discussing gitlab-ci as html is not a leak", func(t *testing.T) {
+ body := "gitlab ci guide" +
+ "a pipeline needs stages: and script: to run jobs" +
+ ""
+ if res := runIACModule(t, file, 200, body); len(res.Findings) > 0 {
+ t.Errorf("an html blog post should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a plain 200 body is not a leak", func(t *testing.T) {
+ if res := runIACModule(t, file, 200, "ok"); len(res.Findings) > 0 {
+ t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a 404 is not a leak", func(t *testing.T) {
+ if res := runIACModule(t, file, 404, "not found"); len(res.Findings) > 0 {
+ t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
+ }
+ })
+}
+
+func TestJenkinsfileExposure(t *testing.T) {
+ const file = "../../modules/recon/jenkinsfile-exposure.yaml"
+
+ t.Run("a real declarative Jenkinsfile leaks the agent", func(t *testing.T) {
+ body := "pipeline {\n agent docker\n stages {\n stage('Build') {\n" +
+ " steps {\n sh 'make build'\n }\n }\n }\n}\n"
+ res := runIACModule(t, file, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a jenkinsfile finding")
+ }
+ if v := iacExtract(res, "jenkinsfile_agent"); v != "docker" {
+ t.Errorf("jenkinsfile_agent=%q, want docker", v)
+ }
+ })
+
+ t.Run("a scripted Jenkinsfile with node and stage also leaks", func(t *testing.T) {
+ body := "node {\n stage('checkout') {\n checkout scm\n }\n}\n"
+ res := runIACModule(t, file, 200, body)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a jenkinsfile finding")
+ }
+ })
+
+ t.Run("prose mentioning a deployment pipeline without DSL syntax is not a leak", func(t *testing.T) {
+ body := "our deployment pipeline runs several stages before release\n"
+ if res := runIACModule(t, file, 200, body); len(res.Findings) > 0 {
+ t.Errorf("prose should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("an html page quoting Jenkinsfile syntax is not a leak", func(t *testing.T) {
+ body := "jenkins docs" +
+ "pipeline { agent any stages { stage('x') { steps { } } } }" +
+ ""
+ if res := runIACModule(t, file, 200, body); len(res.Findings) > 0 {
+ t.Errorf("an html docs page should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a plain 200 body is not a leak", func(t *testing.T) {
+ if res := runIACModule(t, file, 200, "ok"); len(res.Findings) > 0 {
+ t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a 404 is not a leak", func(t *testing.T) {
+ if res := runIACModule(t, file, 404, "not found"); len(res.Findings) > 0 {
+ t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
+ }
+ })
+}
diff --git a/modules/recon/ansible-vault-exposure.yaml b/modules/recon/ansible-vault-exposure.yaml
new file mode 100644
index 0000000..faf75ae
--- /dev/null
+++ b/modules/recon/ansible-vault-exposure.yaml
@@ -0,0 +1,47 @@
+id: ansible-vault-exposure
+info:
+ name: Ansible Vault Exposure
+ author: sif
+ severity: medium
+ description: Detects an exposed ansible-vault encrypted file, confirming ansible-managed infrastructure and giving an attacker ciphertext to brute force offline
+ tags: [ansible, vault, iac, secrets, exposure, recon]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/vault.yml"
+ - "{{BaseURL}}/secrets.yml"
+ - "{{BaseURL}}/group_vars/all/vault.yml"
+ - "{{BaseURL}}/group_vars/production/vault.yml"
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ - type: regex
+ part: body
+ regex:
+ - '(?s)^\$ANSIBLE_VAULT;1\.[12];AES256\r?\n[0-9a-fA-F\s]+'
+
+ - type: word
+ part: body
+ negative: true
+ condition: or
+ words:
+ - ""
+ - ""
+
+ extractors:
+ - type: regex
+ name: vault_format_version
+ part: body
+ regex:
+ - '\$ANSIBLE_VAULT;1\.([12]);AES256'
+ group: 1
diff --git a/modules/recon/gitlab-ci-config-exposure.yaml b/modules/recon/gitlab-ci-config-exposure.yaml
new file mode 100644
index 0000000..acc03de
--- /dev/null
+++ b/modules/recon/gitlab-ci-config-exposure.yaml
@@ -0,0 +1,55 @@
+id: gitlab-ci-config-exposure
+info:
+ name: Gitlab CI Config Exposure
+ author: sif
+ severity: low
+ description: Detects an exposed gitlab-ci pipeline definition that leaks build stages, deploy scripts and internal registry or host references
+ tags: [gitlab, ci, cd, pipeline, info-disclosure, exposure, recon]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/.gitlab-ci.yml"
+ - "{{BaseURL}}/.gitlab-ci.yaml"
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ - type: word
+ part: body
+ words:
+ - "script:"
+
+ - type: word
+ part: body
+ condition: or
+ words:
+ - "stages:"
+ - "before_script:"
+ - "after_script:"
+ - "artifacts:"
+ - "rules:"
+
+ - type: word
+ part: body
+ negative: true
+ condition: or
+ words:
+ - ""
+ - ""
+
+ extractors:
+ - type: regex
+ name: ci_image
+ part: body
+ regex:
+ - 'image:\s*["'']?([^\s"'']+)'
+ group: 1
diff --git a/modules/recon/jenkinsfile-exposure.yaml b/modules/recon/jenkinsfile-exposure.yaml
new file mode 100644
index 0000000..9dcaf57
--- /dev/null
+++ b/modules/recon/jenkinsfile-exposure.yaml
@@ -0,0 +1,55 @@
+id: jenkinsfile-exposure
+info:
+ name: Jenkinsfile Exposure
+ author: sif
+ severity: low
+ description: Detects an exposed Jenkinsfile pipeline definition that leaks build stages, agent labels and deploy steps
+ tags: [jenkins, ci, cd, pipeline, groovy, info-disclosure, exposure, recon]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/Jenkinsfile"
+ - "{{BaseURL}}/jenkinsfile"
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ - type: word
+ part: body
+ condition: or
+ words:
+ - "pipeline {"
+ - "node {"
+
+ - type: word
+ part: body
+ condition: or
+ words:
+ - "stages {"
+ - "stage("
+ - "steps {"
+
+ - type: word
+ part: body
+ negative: true
+ condition: or
+ words:
+ - ""
+ - ""
+
+ extractors:
+ - type: regex
+ name: jenkinsfile_agent
+ part: body
+ regex:
+ - 'agent\s*\{?\s*(\w+)'
+ group: 1
diff --git a/modules/recon/terraform-tfvars-exposure.yaml b/modules/recon/terraform-tfvars-exposure.yaml
new file mode 100644
index 0000000..60b8e18
--- /dev/null
+++ b/modules/recon/terraform-tfvars-exposure.yaml
@@ -0,0 +1,64 @@
+id: terraform-tfvars-exposure
+info:
+ name: Terraform Tfvars Exposure
+ author: sif
+ severity: high
+ description: Detects an exposed terraform variable definitions file that commonly carries plaintext infrastructure credentials
+ tags: [terraform, iac, tfvars, secrets, exposure, recon]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/terraform.tfvars"
+ - "{{BaseURL}}/.terraform.tfvars"
+ - "{{BaseURL}}/secret.auto.tfvars"
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ - type: regex
+ part: body
+ regex:
+ - '(?m)^[A-Za-z_][A-Za-z0-9_-]*[ \t]*=[ \t]*"[^"\n]*"[ \t]*\r?\n[A-Za-z_][A-Za-z0-9_-]*[ \t]*=[ \t]*"[^"\n]*"[ \t]*$'
+
+ - type: word
+ part: body
+ condition: or
+ words:
+ - "region"
+ - "instance_type"
+ - "vpc"
+ - "subnet"
+ - "cidr"
+ - "access_key"
+ - "secret_key"
+ - "api_key"
+ - "password"
+ - "db_"
+ - "ami"
+ - "zone"
+ - "project_id"
+
+ - type: word
+ part: body
+ negative: true
+ condition: or
+ words:
+ - ""
+ - ""
+
+ extractors:
+ - type: regex
+ name: tfvars_first_key
+ part: body
+ regex:
+ - '(?m)^([A-Za-z_][A-Za-z0-9_-]*)[ \t]*=[ \t]*"[^"\n]*"'
+ group: 1