mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
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
This commit is contained in:
@@ -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 := "<html><head><title>docs</title></head><body>example:<pre>\n" +
|
||||
"region = \"us-east-1\"\ndb_password = \"hunter2wow\"\n</pre></body></html>"
|
||||
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 := "<html><head><title>ansible vault docs</title></head><body>" +
|
||||
"a vault file starts with $ANSIBLE_VAULT;1.1;AES256 followed by hex" +
|
||||
"</body></html>"
|
||||
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 := "<html><head><title>gitlab ci guide</title></head><body>" +
|
||||
"a pipeline needs stages: and script: to run jobs" +
|
||||
"</body></html>"
|
||||
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 := "<html><head><title>jenkins docs</title></head><body><pre>" +
|
||||
"pipeline { agent any stages { stage('x') { steps { } } } }" +
|
||||
"</pre></body></html>"
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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:
|
||||
- "<!DOCTYPE"
|
||||
- "<!doctype"
|
||||
- "<html"
|
||||
- "<HTML"
|
||||
- "<head>"
|
||||
- "<title>"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: vault_format_version
|
||||
part: body
|
||||
regex:
|
||||
- '\$ANSIBLE_VAULT;1\.([12]);AES256'
|
||||
group: 1
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user