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: + - "<!DOCTYPE" + - "<!doctype" + - "<html" + - "<HTML" + - "<head>" + - "<title>" + + 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: + - "<!DOCTYPE" + - "<!doctype" + - "<html" + - "<HTML" + - "<head>" + - "<title>" + + 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: + - "<!DOCTYPE" + - "<!doctype" + - "<html" + - "<HTML" + - "<head>" + - "<title>" + + extractors: + - type: regex + name: tfvars_first_key + part: body + regex: + - '(?m)^([A-Za-z_][A-Za-z0-9_-]*)[ \t]*=[ \t]*"[^"\n]*"' + group: 1