Skip to content

Write your first template

You will write a Python template that confirms an exposed .git directory, run it standalone, validate it, and then execute it through cxg against a target on your own machine.

The detection is deliberately one a YAML template cannot express: the second request it makes is chosen by parsing the first response, and it reports nothing unless the whole chain holds. That is the difference between finding a .git directory and proving one. See Why polyglot templates for the reasoning.

  • cxg installed and on your PATH. See Installation.
  • Python 3 and git.
  • A working directory you can delete afterwards.

Everything below uses only the Python standard library, so the template runs anywhere python3 does.

Give yourself something with a real, exposed .git directory.

  1. Create a repository with one commit and serve the directory over HTTP.

    Terminal window
    mkdir cxg-first-template
    cd cxg-first-template
    git init -q .
    git -c user.email=you@example.com -c user.name=You commit -q --allow-empty -m "demo"
    python3 -m http.server 8000 --bind 127.0.0.1
  2. In a second terminal, in the same directory, confirm .git/HEAD is reachable.

    Terminal window
    curl -s http://127.0.0.1:8000/.git/HEAD
    ref: refs/heads/master

    If your git defaults to main, you will see refs/heads/main. The template reads whichever it finds, which is the whole point.

cxg runs a template as a child process and reads its standard output. Three things matter.

The annotation header. cxg parses @-prefixed comments at the top of the file for the metadata it uses to list, filter, and search templates. @id, @name, @severity, and @description are the ones you always want.

Environment variables carry the target. cxg sets these before running your template:

Variable Meaning
CERT_X_GEN_TARGET_HOST Target host or IP
CERT_X_GEN_TARGET_PORT Target port
CERT_X_GEN_MODE engine when cxg is the caller
CERT_X_GEN_CONTEXT JSON passed through from --context

Findings go to stdout as a JSON array. An empty array means no finding, and that is the normal result. Anything you write to stderr is ignored by the parser, which makes stderr the right place for diagnostics.

You can print a scaffold for any supported language with cxg template skeleton python. The skeleton is a class-based starting point that imports requests; the template below is written from scratch against the standard library instead, so it has no dependency to install.

Create git-head-exposed.py in your working directory.

git-head-exposed.py
#!/usr/bin/env python3
#
# @id: exposed-git-head-verified
# @name: Exposed .git directory (verified)
# @author: Your Name
# @severity: high
# @description: Confirms an exposed .git directory by following HEAD to its ref and checking that the ref resolves to a real commit object ID.
# @tags: exposure, information-disclosure, web, git
# @cwe: CWE-527
# @confidence: 95
# @references: https://cwe.mitre.org/data/definitions/527.html

@id is what you pass to --templates later, so make it stable. @severity is one of critical, high, medium, low, info.

Three steps, each gating the next.

git-head-exposed.py
import json
import os
import re
import sys
import urllib.error
import urllib.request
TIMEOUT = 5
def get(url):
"""Fetch a URL, returning the body as text, or None on any failure."""
try:
with urllib.request.urlopen(url, timeout=TIMEOUT) as response:
if response.status != 200:
return None
return response.read(8192).decode("utf-8", errors="replace")
except (urllib.error.URLError, OSError):
return None
def scan(host, port):
scheme = "https" if port == 443 else "http"
base = f"{scheme}://{host}:{port}"
# Step 1: is there a HEAD file, and does it look like a git HEAD?
head = get(f"{base}/.git/HEAD")
if not head:
return []
ref_match = re.match(r"^ref:\s+(refs/[\w./-]+)\s*$", head.strip())
if not ref_match:
return []
ref = ref_match.group(1)
# Step 2: follow the ref HEAD named. A server that returns a soft-404 body
# for every path fails here, which is the point: the second request is
# chosen by the first response's content.
ref_body = get(f"{base}/.git/{ref}")
if not ref_body:
return []
commit = ref_body.strip()
# Step 3: the ref must resolve to a 40-character object ID. This is what
# separates a real repository from a page that happens to contain the word
# "ref:".
if not re.fullmatch(r"[0-9a-f]{40}", commit):
return []
return [
{
"template_id": "exposed-git-head-verified",
"template_name": "Exposed .git directory (verified)",
"matched_at": f"{base}/.git/{ref}",
"severity": "high",
"confidence": 95,
"title": "Exposed .git directory",
"description": (
f"{base}/.git/ is served. HEAD points at {ref}, which resolves "
f"to commit {commit}. Source history is retrievable."
),
"evidence": {
"request": f"GET {base}/.git/{ref}",
"response": commit,
"matched_patterns": ["ref:", "40-hex object id"],
"data": {"head": head.strip(), "ref": ref, "commit": commit},
},
"cwe": "CWE-527",
"remediation": "Stop serving the .git directory. Block /.git/ at the web server or reverse proxy.",
"references": ["https://cwe.mitre.org/data/definitions/527.html"],
}
]

template_name and matched_at are worth setting: cxg template validate flags findings that omit them.

Read the target from the environment when cxg is calling, and from argv when you are testing by hand. Supporting both is what makes the template debuggable.

git-head-exposed.py
def main():
host = os.environ.get("CERT_X_GEN_TARGET_HOST")
port = int(os.environ.get("CERT_X_GEN_TARGET_PORT", "80"))
if not host:
if len(sys.argv) < 2:
sys.exit("usage: git-head-exposed.py <host> [port]")
host = sys.argv[1]
if len(sys.argv) > 2:
port = int(sys.argv[2])
findings = scan(host, port)
print(json.dumps(findings, indent=2))
if __name__ == "__main__":
main()

Before involving cxg, check the template works on its own. This is the fastest loop you will have.

Terminal window
python3 git-head-exposed.py 127.0.0.1 8000
[
{
"template_id": "exposed-git-head-verified",
"template_name": "Exposed .git directory (verified)",
"matched_at": "http://127.0.0.1:8000/.git/refs/heads/master",
"severity": "high",
"confidence": 95,
"title": "Exposed .git directory",
"description": "http://127.0.0.1:8000/.git/ is served. HEAD points at refs/heads/master, which resolves to commit 2b02d7659178aee5532b0a9471af692c8897a1e2. Source history is retrievable.",
"evidence": {
"request": "GET http://127.0.0.1:8000/.git/refs/heads/master",
"response": "2b02d7659178aee5532b0a9471af692c8897a1e2",
"matched_patterns": [
"ref:",
"40-hex object id"
],
"data": {
"head": "ref: refs/heads/master",
"ref": "refs/heads/master",
"commit": "2b02d7659178aee5532b0a9471af692c8897a1e2"
}
},
"cwe": "CWE-527",
"remediation": "Stop serving the .git directory. Block /.git/ at the web server or reverse proxy.",
"references": [
"https://cwe.mitre.org/data/definitions/527.html"
]
}
]

Your commit hash will differ.

Terminal window
cxg template validate git-head-exposed.py
════════════════════════════════════════════════════════════════════════════════
CERT-X-GEN Template Validator
════════════════════════════════════════════════════════════════════════════════
Found 1 template(s) to validate
✓ git-head-exposed.py
════════════════════════════════════════════════════════════════════════════════
Validation Summary
════════════════════════════════════════════════════════════════════════════════
Total Templates: 1
✓ Passed: 1
✗ Failed: 0
Success Rate: 100%

The validator also reports [info] lines under the template name when something is worth knowing anyway: a missing template_name, or a print() that is not json.dumps(). Those do not fail the template, but each one is a real trap and worth clearing.

Put the template in a directory and point a scan at it.

Terminal window
mkdir -p my-templates
cp git-head-exposed.py my-templates/
cxg scan --scope http://127.0.0.1:8000 --template-dir ./my-templates
════════════════════════════════════════════════════════════════════════════════
Scan Summary
════════════════════════════════════════════════════════════════════════════════
Scan ID: 90229b6b-315f-496b-aebe-e9463a4a9491
Duration: 0.30s
Targets Scanned: 1
Templates Executed: 1
Findings by Severity:
CRITICAL: 0
HIGH: 1
MEDIUM: 0
LOW: 0
INFO: 0
TOTAL: 1
════════════════════════════════════════════════════════════════════════════════

--template-dir replaces the normal discovery locations, so only your template runs. Check the recorded finding in scan-results.json:

Terminal window
python3 -c "import json;print(json.load(open('scan-results.json'))['findings'][0]['description'])"
http://127.0.0.1:8000/.git/ is served. HEAD points at refs/heads/master, which resolves to commit 2b02d7659178aee5532b0a9471af692c8897a1e2. Source history is retrievable.

A detection that always fires is not a detection. Take the .git directory away and scan again.

Terminal window
mv .git .git-hidden
cxg scan --scope http://127.0.0.1:8000 --template-dir ./my-templates
Findings by Severity:
CRITICAL: 0
HIGH: 0
MEDIUM: 0
LOW: 0
INFO: 0
TOTAL: 0

Restore it with mv .git-hidden .git.

This is the test worth repeating for anything you write. Run it against a target that should not match, and confirm it stays quiet. A template that has only ever been run against a vulnerable target has not been tested.

Stop the Python server with Ctrl+C, then remove the directory.

Terminal window
cd ..
rm -rf cxg-first-template
  • Report more precisely. Set cvss_score and remediation in the finding. They flow straight into scan-results.json and every report format.

  • Go one step further. Fetch .git/config and include the url of the origin remote in the evidence. It is one more dependent request, and it tells a reader which repository was exposed.

  • Try another language. The same contract applies to all twelve: an annotation header, environment variables in, a JSON array out. cxg template skeleton go prints the Go scaffold.

  • Read the templates that ship with cxg. The set installed on your machine comes from cert-x-gen-templates, where templates are grouped by subject into web, network, databases, devops, recon, and others. Reading a few that target something close to your own check is the quickest way to pick up the conventions, and the repository’s template writing guide covers template anatomy language by language.

  • Contribute it. If your check would be useful to other people, CONTRIBUTING.md sets out the quality standards a submitted template has to meet, which directory it belongs in, and the fork, validate, and pull request workflow.