Repository: gitleaks/gitleaks
Pull request #1650 (2024-11-22 02:05:38)

Allow user-defined report templates
### Description:
This is a follow-up to #1616. It allows users to customize the Gitleaks report format using [templates](https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go).

For instance, the change required by that PR could now be done with:
```sh
./gitleaks dir /tmp/ --config config/gitleaks.toml --report-path "report.json" --report-format template --report-template testdata/report/jsonextra.tmpl
```

**Prior art:** This is _heavily inspired_ by https://github.com/brittonhayes/pillager.

### Checklist:

* [x] Does your PR pass tests?
* [x] Have you written new tests for your changes?
* [x] Have you lint your code locally prior to submission?


zricethezav: @rgmz LGTM, last thing that would be useful is an update in the README. We probably should add a section on `reporting`
rgmz: Absolutely. I've updated the README to include usage information, LMK your thoughts.
rgmz: BTW @taiki45, I've used #1616 as an example use case.

Would this (specifying a custom template file) work for you, or would removing `jsonextra` as an option for `--report-format` be disruptive?  
zricethezav: > Would this (specifying a custom template file) work for you, or would removing jsonextra as an option for --report-format be disruptive?

imo let's leave `jsonextra` in place. Doing so avoids breakage in case anyone else uses `jsonextra`. A little bit of redundancy never hurt anyone (I think).
taiki45: @rgmz Thank you so much for your work on this!

Specifying a custom template file works perfectly for us, and removing the `jsonextra` option for `--report-format` wouldn't cause any issues on our side. +1

Patch:
diff --git a/README.md b/README.md
index 0b65d88..8719bfb 100644
--- a/README.md
+++ b/README.md
@@ -162,8 +162,9 @@ Flags:
       --no-banner                     suppress banner
       --no-color                      turn off color for verbose output
       --redact uint[=100]             redact secrets from logs and stdout. To redact only parts of the secret just apply a percent value from 0..100. For example --redact=20 (default 100%)
-  -f, --report-format string          output format (json, jsonextra, csv, junit, sarif) (default "json")
+  -f, --report-format string          output format (json, csv, junit, sarif) (default "json")
   -r, --report-path string            report file
+      --report-template string        template file used to generate the report (implies --report-format=template)
   -v, --verbose                       show verbose output from scan
       --version                       version for gitleaks
 
@@ -393,6 +394,47 @@ Currently supported encodings:
 
 - `base64` (both standard and base64url)
 
+#### Reporting
+
+Gitleaks has built-in support for several report formats: [`json`](https://github.com/gitleaks/gitleaks/blob/master/testdata/expected/report/json_simple.json), [`csv`](https://github.com/gitleaks/gitleaks/blob/master/testdata/expected/report/csv_simple.csv?plain=1), [`junit`](https://github.com/gitleaks/gitleaks/blob/master/testdata/expected/report/junit_simple.xml), and [`sarif`](https://github.com/gitleaks/gitleaks/blob/master/testdata/expected/report/sarif_simple.sarif).
+
+If none of these formats fit your need, you can create your own report format with a [Go `text/template` .tmpl file](https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go#step-4-writing-a-template) and the `--report-template` flag. The template can use [extended functionality from the `Masterminds/sprig` template library](https://masterminds.github.io/sprig/).
+
+For example, the following template provides a custom JSON output:
+```gotemplate
+# jsonextra.tmpl
+[{{ $lastFinding := (sub (len . ) 1) }}
+{{- range $i, $finding := . }}{{with $finding}}
+    {
+        "Description": {{ quote .Description }},
+        "StartLine": {{ .StartLine }},
+        "EndLine": {{ .EndLine }},
+        "StartColumn": {{ .StartColumn }},
+        "EndColumn": {{ .EndColumn }},
+        "Line": {{ quote .Line }},
+        "Match": {{ quote .Match }},
+        "Secret": {{ quote .Secret }},
+        "File": "{{ .File }}",
+        "SymlinkFile": {{ quote .SymlinkFile }},
+        "Commit": {{ quote .Commit }},
+        "Entropy": {{ .Entropy }},
+        "Author": {{ quote .Author }},
+        "Email": {{ quote .Email }},
+        "Date": {{ quote .Date }},
+        "Message": {{ quote .Message }},
+        "Tags": [{{ $lastTag := (sub (len .Tags ) 1) }}{{ range $j, $tag := .Tags }}{{ quote . }}{{ if ne $j $lastTag }},{{ end }}{{ end }}],
+        "RuleID": {{ quote .RuleID }},
+        "Fingerprint": {{ quote .Fingerprint }}
+    }{{ if ne $i $lastFinding }},{{ end }}
+{{- end}}{{ end }}
+]
+```
+
+Usage:
+```sh
+$ gitleaks dir ~/leaky-repo/ --report-path "report.json" --report-format template --report-template testdata/report/jsonextra.tmpl
+```
+
 ## Sponsorships
 
 <p align="left">
diff --git a/cmd/detect.go b/cmd/detect.go
index db9c18b..cd14f7c 100644
--- a/cmd/detect.go
+++ b/cmd/detect.go
@@ -129,5 +129,5 @@ func runDetect(cmd *cobra.Command, args []string) {
 		}
 	}
 
-	findingSummaryAndExit(findings, cmd, cfg, exitCode, start, err)
+	findingSummaryAndExit(detector, findings, exitCode, start, err)
 }
diff --git a/cmd/directory.go b/cmd/directory.go
index a4289bd..553ad64 100644
--- a/cmd/directory.go
+++ b/cmd/directory.go
@@ -72,5 +72,5 @@ func runDirectory(cmd *cobra.Command, args []string) {
 		log.Error().Err(err).Msg("failed scan directory")
 	}
 
-	findingSummaryAndExit(findings, cmd, cfg, exitCode, start, err)
+	findingSummaryAndExit(detector, findings, exitCode, start, err)
 }
diff --git a/cmd/generate/config/base/config.go b/cmd/generate/config/base/config.go
index 60d3e6c..bb1a95d 100644
--- a/cmd/generate/config/base/config.go
+++ b/cmd/generate/config/base/config.go
@@ -2,9 +2,10 @@ package base
 
 import (
 	"fmt"
-	"github.com/zricethezav/gitleaks/v8/config"
 	"regexp"
 	"strings"
+
+	"github.com/zricethezav/gitleaks/v8/config"
 )
 
 func CreateGlobalConfig() config.Config {
diff --git a/cmd/generate/config/main.go b/cmd/generate/config/main.go
index 444ab24..4ccd824 100644
--- a/cmd/generate/config/main.go
+++ b/cmd/generate/config/main.go
@@ -4,10 +4,9 @@ import (
 	"os"
 	"text/template"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/base"
-
 	"github.com/rs/zerolog/log"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/base"
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/rules"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
diff --git a/cmd/generate/config/rules/1password.go b/cmd/generate/config/rules/1password.go
index 07519a0..6074b38 100644
--- a/cmd/generate/config/rules/1password.go
+++ b/cmd/generate/config/rules/1password.go
@@ -1,10 +1,11 @@
 package rules
 
 import (
+	"regexp"
+
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
-	"regexp"
 )
 
 // https://developer.1password.com/docs/service-accounts/security/?token-example=encoded
diff --git a/cmd/generate/config/rules/age.go b/cmd/generate/config/rules/age.go
index a45291d..62986e4 100644
--- a/cmd/generate/config/rules/age.go
+++ b/cmd/generate/config/rules/age.go
@@ -1,9 +1,9 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
 
diff --git a/cmd/generate/config/rules/authress.go b/cmd/generate/config/rules/authress.go
index ae578b7..c304332 100644
--- a/cmd/generate/config/rules/authress.go
+++ b/cmd/generate/config/rules/authress.go
@@ -2,8 +2,8 @@ package rules
 
 import (
 	"fmt"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
diff --git a/cmd/generate/config/rules/aws.go b/cmd/generate/config/rules/aws.go
index 3a789a1..10f48db 100644
--- a/cmd/generate/config/rules/aws.go
+++ b/cmd/generate/config/rules/aws.go
@@ -1,10 +1,10 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"regexp"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
 
diff --git a/cmd/generate/config/rules/azure.go b/cmd/generate/config/rules/azure.go
index 1ce88cd..0f73cf7 100644
--- a/cmd/generate/config/rules/azure.go
+++ b/cmd/generate/config/rules/azure.go
@@ -2,11 +2,12 @@ package rules
 
 import (
 	"fmt"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 // References:
diff --git a/cmd/generate/config/rules/clojars.go b/cmd/generate/config/rules/clojars.go
index e13f947..0109624 100644
--- a/cmd/generate/config/rules/clojars.go
+++ b/cmd/generate/config/rules/clojars.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func Clojars() *config.Rule {
diff --git a/cmd/generate/config/rules/curl.go b/cmd/generate/config/rules/curl.go
index 0df8b6e..5b978c2 100644
--- a/cmd/generate/config/rules/curl.go
+++ b/cmd/generate/config/rules/curl.go
@@ -2,9 +2,9 @@ package rules
 
 import (
 	"fmt"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
 
diff --git a/cmd/generate/config/rules/doppler.go b/cmd/generate/config/rules/doppler.go
index b9f4da7..2f26977 100644
--- a/cmd/generate/config/rules/doppler.go
+++ b/cmd/generate/config/rules/doppler.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func Doppler() *config.Rule {
diff --git a/cmd/generate/config/rules/duffel.go b/cmd/generate/config/rules/duffel.go
index 2a183f0..0bbe7ff 100644
--- a/cmd/generate/config/rules/duffel.go
+++ b/cmd/generate/config/rules/duffel.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func Duffel() *config.Rule {
diff --git a/cmd/generate/config/rules/dynatrace.go b/cmd/generate/config/rules/dynatrace.go
index d0f8494..9051242 100644
--- a/cmd/generate/config/rules/dynatrace.go
+++ b/cmd/generate/config/rules/dynatrace.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func Dynatrace() *config.Rule {
diff --git a/cmd/generate/config/rules/easypost.go b/cmd/generate/config/rules/easypost.go
index fa7f0eb..2719be1 100644
--- a/cmd/generate/config/rules/easypost.go
+++ b/cmd/generate/config/rules/easypost.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func EasyPost() *config.Rule {
diff --git a/cmd/generate/config/rules/etsy.go b/cmd/generate/config/rules/etsy.go
index c967295..3107a8c 100644
--- a/cmd/generate/config/rules/etsy.go
+++ b/cmd/generate/config/rules/etsy.go
@@ -2,6 +2,7 @@ package rules
 
 import (
 	"fmt"
+
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
diff --git a/cmd/generate/config/rules/flutterwave.go b/cmd/generate/config/rules/flutterwave.go
index 42dcf7b..1f9add4 100644
--- a/cmd/generate/config/rules/flutterwave.go
+++ b/cmd/generate/config/rules/flutterwave.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func FlutterwavePublicKey() *config.Rule {
diff --git a/cmd/generate/config/rules/frameio.go b/cmd/generate/config/rules/frameio.go
index cb7b4c9..2de2dc2 100644
--- a/cmd/generate/config/rules/frameio.go
+++ b/cmd/generate/config/rules/frameio.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func FrameIO() *config.Rule {
diff --git a/cmd/generate/config/rules/freemius.go b/cmd/generate/config/rules/freemius.go
index 1513383..933647a 100644
--- a/cmd/generate/config/rules/freemius.go
+++ b/cmd/generate/config/rules/freemius.go
@@ -1,9 +1,10 @@
 package rules
 
 import (
+	"regexp"
+
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
-	"regexp"
 )
 
 func Freemius() *config.Rule {
diff --git a/cmd/generate/config/rules/gcp.go b/cmd/generate/config/rules/gcp.go
index c9df6e0..854d371 100644
--- a/cmd/generate/config/rules/gcp.go
+++ b/cmd/generate/config/rules/gcp.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 // TODO this one could probably use some work
diff --git a/cmd/generate/config/rules/generic.go b/cmd/generate/config/rules/generic.go
index 4b449a6..45a893f 100644
--- a/cmd/generate/config/rules/generic.go
+++ b/cmd/generate/config/rules/generic.go
@@ -1,10 +1,11 @@
 package rules
 
 import (
+	"regexp"
+
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
-	"regexp"
 )
 
 func GenericCredential() *config.Rule {
diff --git a/cmd/generate/config/rules/github.go b/cmd/generate/config/rules/github.go
index d9e0222..f719cd2 100644
--- a/cmd/generate/config/rules/github.go
+++ b/cmd/generate/config/rules/github.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 var githubAllowlist = []config.Allowlist{
diff --git a/cmd/generate/config/rules/gitlab.go b/cmd/generate/config/rules/gitlab.go
index 2af8d02..b36481b 100644
--- a/cmd/generate/config/rules/gitlab.go
+++ b/cmd/generate/config/rules/gitlab.go
@@ -4,9 +4,9 @@ import (
 	"regexp"
 
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
+	"github.com/zricethezav/gitleaks/v8/config"
 
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
-	"github.com/zricethezav/gitleaks/v8/config"
 )
 
 // overview with all GitLab tokens:
diff --git a/cmd/generate/config/rules/harness.go b/cmd/generate/config/rules/harness.go
index 981d91b..151c1f8 100644
--- a/cmd/generate/config/rules/harness.go
+++ b/cmd/generate/config/rules/harness.go
@@ -3,10 +3,11 @@ package rules
 import (
 	"regexp"
 
+	"github.com/zricethezav/gitleaks/v8/config"
+
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
-	"github.com/zricethezav/gitleaks/v8/config"
 )
 
 func HarnessApiKey() *config.Rule {
diff --git a/cmd/generate/config/rules/hashicorp.go b/cmd/generate/config/rules/hashicorp.go
index 0bf1f69..3c491bf 100644
--- a/cmd/generate/config/rules/hashicorp.go
+++ b/cmd/generate/config/rules/hashicorp.go
@@ -2,11 +2,12 @@ package rules
 
 import (
 	"fmt"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func HashiCorpTerraform() *config.Rule {
diff --git a/cmd/generate/config/rules/hashicorp_vault.go b/cmd/generate/config/rules/hashicorp_vault.go
index 881f676..4ea935c 100644
--- a/cmd/generate/config/rules/hashicorp_vault.go
+++ b/cmd/generate/config/rules/hashicorp_vault.go
@@ -3,9 +3,10 @@ package rules
 import (
 	"regexp"
 
+	"github.com/zricethezav/gitleaks/v8/config"
+
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
-	"github.com/zricethezav/gitleaks/v8/config"
 )
 
 func VaultServiceToken() *config.Rule {
diff --git a/cmd/generate/config/rules/jfrog.go b/cmd/generate/config/rules/jfrog.go
index 35f6371..b4e0165 100644
--- a/cmd/generate/config/rules/jfrog.go
+++ b/cmd/generate/config/rules/jfrog.go
@@ -2,8 +2,8 @@ package rules
 
 import (
 	"fmt"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
diff --git a/cmd/generate/config/rules/jwt.go b/cmd/generate/config/rules/jwt.go
index b24b882..fca9195 100644
--- a/cmd/generate/config/rules/jwt.go
+++ b/cmd/generate/config/rules/jwt.go
@@ -3,10 +3,10 @@ package rules
 import (
 	b64 "encoding/base64"
 	"fmt"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"regexp"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
 
diff --git a/cmd/generate/config/rules/kubernetes.go b/cmd/generate/config/rules/kubernetes.go
index a1f3eab..c29e169 100644
--- a/cmd/generate/config/rules/kubernetes.go
+++ b/cmd/generate/config/rules/kubernetes.go
@@ -4,8 +4,9 @@ import (
 	"fmt"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 )
 
 // KubernetesSecret validates if we detected a kubernetes secret which contains data!
diff --git a/cmd/generate/config/rules/linear.go b/cmd/generate/config/rules/linear.go
index b4cdc39..3ff257a 100644
--- a/cmd/generate/config/rules/linear.go
+++ b/cmd/generate/config/rules/linear.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func LinearAPIToken() *config.Rule {
diff --git a/cmd/generate/config/rules/nuget.go b/cmd/generate/config/rules/nuget.go
index 0b463f3..0035ac0 100644
--- a/cmd/generate/config/rules/nuget.go
+++ b/cmd/generate/config/rules/nuget.go
@@ -3,9 +3,9 @@ package rules
 import (
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
-
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 )
 
 func NugetConfigPassword() *config.Rule {
diff --git a/cmd/generate/config/rules/openshift.go b/cmd/generate/config/rules/openshift.go
index c75197c..8ecf1b8 100644
--- a/cmd/generate/config/rules/openshift.go
+++ b/cmd/generate/config/rules/openshift.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 // OpenShift 4 user tokens are prefixed with `sha256~`.
diff --git a/cmd/generate/config/rules/plaid.go b/cmd/generate/config/rules/plaid.go
index c0ebc33..c488ad0 100644
--- a/cmd/generate/config/rules/plaid.go
+++ b/cmd/generate/config/rules/plaid.go
@@ -2,8 +2,8 @@ package rules
 
 import (
 	"fmt"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
diff --git a/cmd/generate/config/rules/privatekey.go b/cmd/generate/config/rules/privatekey.go
index ad6a557..dca4383 100644
--- a/cmd/generate/config/rules/privatekey.go
+++ b/cmd/generate/config/rules/privatekey.go
@@ -1,9 +1,9 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
 
diff --git a/cmd/generate/config/rules/pypi.go b/cmd/generate/config/rules/pypi.go
index d9b2df5..8b6f985 100644
--- a/cmd/generate/config/rules/pypi.go
+++ b/cmd/generate/config/rules/pypi.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func PyPiUploadToken() *config.Rule {
diff --git a/cmd/generate/config/rules/sentry.go b/cmd/generate/config/rules/sentry.go
index adbd082..945ec92 100644
--- a/cmd/generate/config/rules/sentry.go
+++ b/cmd/generate/config/rules/sentry.go
@@ -2,10 +2,11 @@ package rules
 
 import (
 	"encoding/base64"
+	"regexp"
+
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
-	"regexp"
 )
 
 func SentryAccessToken() *config.Rule {
diff --git a/cmd/generate/config/rules/shopify.go b/cmd/generate/config/rules/shopify.go
index 2032ed6..87bddd6 100644
--- a/cmd/generate/config/rules/shopify.go
+++ b/cmd/generate/config/rules/shopify.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func ShopifySharedSecret() *config.Rule {
diff --git a/cmd/generate/config/rules/sidekiq.go b/cmd/generate/config/rules/sidekiq.go
index 7eac661..711db08 100644
--- a/cmd/generate/config/rules/sidekiq.go
+++ b/cmd/generate/config/rules/sidekiq.go
@@ -1,10 +1,10 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"regexp"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 	"github.com/zricethezav/gitleaks/v8/config"
 )
 
diff --git a/cmd/generate/config/rules/slack.go b/cmd/generate/config/rules/slack.go
index ba8d3c1..51baf2b 100644
--- a/cmd/generate/config/rules/slack.go
+++ b/cmd/generate/config/rules/slack.go
@@ -2,11 +2,12 @@ package rules
 
 import (
 	"fmt"
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 // https://api.slack.com/authentication/token-types#bot
diff --git a/cmd/generate/config/rules/teams.go b/cmd/generate/config/rules/teams.go
index 19b622e..aae5aa9 100644
--- a/cmd/generate/config/rules/teams.go
+++ b/cmd/generate/config/rules/teams.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func TeamsWebhook() *config.Rule {
diff --git a/cmd/generate/config/rules/twilio.go b/cmd/generate/config/rules/twilio.go
index 42f802c..0ab5986 100644
--- a/cmd/generate/config/rules/twilio.go
+++ b/cmd/generate/config/rules/twilio.go
@@ -1,11 +1,12 @@
 package rules
 
 import (
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"regexp"
 
-	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/utils"
 	"github.com/zricethezav/gitleaks/v8/config"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/generate/secrets"
 )
 
 func Twilio() *config.Rule {
diff --git a/cmd/generate/config/utils/validate.go b/cmd/generate/config/utils/validate.go
index e4a893d..9f4a47a 100644
--- a/cmd/generate/config/utils/validate.go
+++ b/cmd/generate/config/utils/validate.go
@@ -5,11 +5,13 @@
 package utils
 
 import (
+	"strings"
+
 	"github.com/rs/zerolog/log"
+
 	"github.com/zricethezav/gitleaks/v8/cmd/generate/config/base"
 	"github.com/zricethezav/gitleaks/v8/config"
 	"github.com/zricethezav/gitleaks/v8/detect"
-	"strings"
 )
 
 func Validate(rule config.Rule, truePositives []string, falsePositives []string) *config.Rule {
diff --git a/cmd/git.go b/cmd/git.go
index a18585a..ed17da5 100644
--- a/cmd/git.go
+++ b/cmd/git.go
@@ -93,5 +93,5 @@ func runGit(cmd *cobra.Command, args []string) {
 		log.Error().Err(err).Msg("failed to scan Git repository")
 	}
 
-	findingSummaryAndExit(findings, cmd, cfg, exitCode, start, err)
+	findingSummaryAndExit(detector, findings, exitCode, start, err)
 }
diff --git a/cmd/protect.go b/cmd/protect.go
index 239d3ec..ebac2fd 100644
--- a/cmd/protect.go
+++ b/cmd/protect.go
@@ -47,5 +47,5 @@ func runProtect(cmd *cobra.Command, args []string) {
 	}
 	findings, err = detector.DetectGit(gitCmd)
 
-	findingSummaryAndExit(findings, cmd, cfg, exitCode, start, err)
+	findingSummaryAndExit(detector, findings, exitCode, start, err)
 }
diff --git a/cmd/root.go b/cmd/root.go
index 47066d7..caf5959 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -2,6 +2,7 @@ package cmd
 
 import (
 	"fmt"
+	"io"
 	"os"
 	"path/filepath"
 	"strings"
@@ -44,7 +45,8 @@ func init() {
 	rootCmd.PersistentFlags().StringP("config", "c", "", configDescription)
 	rootCmd.PersistentFlags().Int("exit-code", 1, "exit code when leaks have been encountered")
 	rootCmd.PersistentFlags().StringP("report-path", "r", "", "report file")
-	rootCmd.PersistentFlags().StringP("report-format", "f", "json", "output format (json, jsonextra, csv, junit, sarif)")
+	rootCmd.PersistentFlags().StringP("report-format", "f", "json", "output format (json, jsonextra, csv, junit, sarif, template)")
+	rootCmd.PersistentFlags().StringP("report-template", "", "", "template file used to generate the report (implies --report-format=template)")
 	rootCmd.PersistentFlags().StringP("baseline-path", "b", "", "path to baseline with issues that can be ignored")
 	rootCmd.PersistentFlags().StringP("log-level", "l", "info", "log level (trace, debug, info, warn, error, fatal)")
 	rootCmd.PersistentFlags().BoolP("verbose", "v", false, "show verbose output from scan")
@@ -95,6 +97,7 @@ func initConfig(source string) {
 	if !hideBanner {
 		_, _ = fmt.Fprint(os.Stderr, banner)
 	}
+
 	cfgPath, err := rootCmd.Flags().GetString("config")
 	if err != nil {
 		log.Fatal().Msg(err.Error())
@@ -251,8 +254,8 @@ func Detector(cmd *cobra.Command, cfg config.Config, source string) *detect.Dete
 		log.Info().Msg("Overriding enabled rules: " + strings.Join(rules, ", "))
 		ruleOverride := make(map[string]config.Rule)
 		for _, ruleName := range rules {
-			if rule, ok := cfg.Rules[ruleName]; ok {
-				ruleOverride[ruleName] = rule
+			if r, ok := cfg.Rules[ruleName]; ok {
+				ruleOverride[ruleName] = r
 			} else {
 				log.Fatal().Msgf("Requested rule %s not found in rules", ruleName)
 			}
@@ -260,10 +263,65 @@ func Detector(cmd *cobra.Command, cfg config.Config, source string) *detect.Dete
 		detector.Config.Rules = ruleOverride
 	}
 
+	// Validate report settings.
+	reportPath := mustGetStringFlag("report-path")
+	if reportPath != "" {
+		if reportPath != report.StdoutReportPath {
+			// Ensure the path is writable.
+			if f, err := os.Create(reportPath); err != nil {
+				log.Fatal().Err(err).Msgf("Report path is not writable: %s", reportPath)
+			} else {
+				_ = f.Close()
+				_ = os.Remove(reportPath)
+			}
+		}
+
+		// Build report writer.
+		var (
+			reporter       report.Reporter
+			reportFormat   = mustGetStringFlag("report-format")
+			reportTemplate = mustGetStringFlag("report-template")
+		)
+		switch strings.TrimSpace(strings.ToLower(reportFormat)) {
+		case "csv":
+			reporter = &report.CsvReporter{}
+		case "json":
+			reporter = &report.JsonReporter{}
+		case "junit":
+			reporter = &report.JunitReporter{}
+		case "sarif":
+			reporter = &report.SarifReporter{
+				OrderedRules: cfg.GetOrderedRules(),
+			}
+		case "template":
+			if reporter, err = report.NewTemplateReporter(reportTemplate); err != nil {
+				log.Fatal().Err(err).Msg("Invalid report template")
+			}
+		default:
+			log.Fatal().Msgf("unknown report format %s", reportFormat)
+		}
+
+		// Sanity check.
+		if reportTemplate != "" && reportFormat != "template" {
+			log.Fatal().Msgf("Report format must be 'template' if --report-template is specified")
+		}
+
+		detector.ReportPath = reportPath
+		detector.Reporter = reporter
+	}
+
 	return detector
 }
 
-func findingSummaryAndExit(findings []report.Finding, cmd *cobra.Command, cfg config.Config, exitCode int, start time.Time, err error) {
+func mustGetStringFlag(name string) string {
+	reportPath, err := rootCmd.Flags().GetString(name)
+	if err != nil {
+		log.Fatal().Msg(err.Error())
+	}
+	return reportPath
+}
+
+func findingSummaryAndExit(detector *detect.Detector, findings []report.Finding, exitCode int, start time.Time, err error) {
 	if err == nil {
 		log.Info().Msgf("scan completed in %s", FormatDuration(time.Since(start)))
 		if len(findings) != 0 {
@@ -281,20 +339,32 @@ func findingSummaryAndExit(findings []report.Finding, cmd *cobra.Command, cfg co
 	}
 
 	// write report if desired
-	reportPath, _ := cmd.Flags().GetString("report-path")
-	ext, _ := cmd.Flags().GetString("report-format")
-
-	if reportPath != "" {
-		reportWriter := os.Stdout
-		if reportPath != "-" {
-			reportWriter, err = os.Create(reportPath)
-			if err != nil {
-				log.Fatal().Err(err).Msg("could not create report file")
+	if detector.Reporter != nil && len(findings) > 0 {
+		var (
+			file      io.WriteCloser
+			reportErr error
+		)
+
+		if detector.ReportPath == report.StdoutReportPath {
+			file = os.Stdout
+		} else {
+			// Open the file.
+			if file, reportErr = os.Create(detector.ReportPath); reportErr != nil {
+				goto ReportEnd
 			}
+			defer func() {
+				_ = file.Close()
+			}()
 		}
 
-		if err = report.Write(findings, cfg, ext, reportWriter); err != nil {
-			log.Fatal().Err(err).Msg("could not write")
+		// Write to the file.
+		if reportErr = detector.Reporter.Write(file, findings); reportErr != nil {
+			goto ReportEnd
+		}
+
+	ReportEnd:
+		if reportErr != nil {
+			log.Fatal().Err(reportErr).Msg("failed to write report")
 		}
 	}
 
@@ -305,7 +375,6 @@ func findingSummaryAndExit(findings []report.Finding, cmd *cobra.Command, cfg co
 	if len(findings) != 0 {
 		os.Exit(exitCode)
 	}
-
 }
 
 func fileExists(fileName string) bool {
diff --git a/cmd/stdin.go b/cmd/stdin.go
index 8357eea..62fb486 100644
--- a/cmd/stdin.go
+++ b/cmd/stdin.go
@@ -47,5 +47,5 @@ func runStdIn(cmd *cobra.Command, args []string) {
 		log.Fatal().Err(err).Msg("failed scan input from stdin")
 	}
 
-	findingSummaryAndExit(findings, cmd, cfg, exitCode, start, err)
+	findingSummaryAndExit(detector, findings, exitCode, start, err)
 }
diff --git a/config/config.go b/config/config.go
index b2e7c85..0372dab 100644
--- a/config/config.go
+++ b/config/config.go
@@ -111,7 +111,7 @@ func (vc *ViperConfig) Translate() (Config, error) {
 			configPathRegex = regexp.MustCompile(vr.Path)
 		}
 
-		rule := Rule{
+		cr := Rule{
 			RuleID:      vr.ID,
 			Description: vr.Description,
 			Regex:       configRegex,
@@ -124,7 +124,7 @@ func (vc *ViperConfig) Translate() (Config, error) {
 		// Parse the allowlist, including the older format for backwards compatibility.
 		if vr.AllowList != nil {
 			if len(vr.Allowlists) > 0 {
-				return Config{}, fmt.Errorf("%s: [rules.allowlist] is deprecated, it cannot be used alongside [[rules.allowlist]]", rule.RuleID)
+				return Config{}, fmt.Errorf("%s: [rules.allowlist] is deprecated, it cannot be used alongside [[rules.allowlist]]", cr.RuleID)
 			}
 			vr.Allowlists = append(vr.Allowlists, *vr.AllowList)
 		}
@@ -137,7 +137,7 @@ func (vc *ViperConfig) Translate() (Config, error) {
 			case "", "OR", "||":
 				condition = AllowlistMatchOr
 			default:
-				return Config{}, fmt.Errorf("%s: unknown allowlist condition '%s' (expected 'and', 'or')", rule.RuleID, c)
+				return Config{}, fmt.Errorf("%s: unknown allowlist condition '%s' (expected 'and', 'or')", cr.RuleID, c)
 			}
 
 			// Validate the target.
@@ -148,7 +148,7 @@ func (vc *ViperConfig) Translate() (Config, error) {
 				case "match", "line":
 					// do nothing
 				default:
-					return Config{}, fmt.Errorf("%s: unknown allowlist |regexTarget| '%s' (expected 'match', 'line')", rule.RuleID, a.RegexTarget)
+					return Config{}, fmt.Errorf("%s: unknown allowlist |regexTarget| '%s' (expected 'match', 'line')", cr.RuleID, a.RegexTarget)
 				}
 			}
 			var allowlistRegexes []*regexp.Regexp
@@ -169,12 +169,12 @@ func (vc *ViperConfig) Translate() (Config, error) {
 				StopWords:      a.StopWords,
 			}
 			if err := allowlist.Validate(); err != nil {
-				return Config{}, fmt.Errorf("%s: %w", rule.RuleID, err)
+				return Config{}, fmt.Errorf("%s: %w", cr.RuleID, err)
 			}
-			rule.Allowlists = append(rule.Allowlists, allowlist)
+			cr.Allowlists = append(cr.Allowlists, allowlist)
 		}
-		orderedRules = append(orderedRules, rule.RuleID)
-		rulesMap[rule.RuleID] = rule
+		orderedRules = append(orderedRules, cr.RuleID)
+		rulesMap[cr.RuleID] = cr
 	}
 	var allowlistRegexes []*regexp.Regexp
 	for _, a := range vc.Allowlist.Regexes {
diff --git a/detect/detect.go b/detect/detect.go
index 8a410d2..7f1c28f 100644
--- a/detect/detect.go
+++ b/detect/detect.go
@@ -83,6 +83,10 @@ type Detector struct {
 
 	// Sema (https://github.com/fatih/semgroup) controls the concurrency
 	Sema *semgroup.Group
+
+	// report-related settings.
+	ReportPath string
+	Reporter   report.Reporter
 }
 
 // Fragment contains the data to be scanned
@@ -238,11 +242,11 @@ func (d *Detector) Detect(fragment Fragment) []report.Finding {
 }
 
 // detectRule scans the given fragment for the given rule and returns a list of findings
-func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.Rule, encodedSegments []EncodedSegment) []report.Finding {
+func (d *Detector) detectRule(fragment Fragment, currentRaw string, r config.Rule, encodedSegments []EncodedSegment) []report.Finding {
 	var (
 		findings []report.Finding
 		logger   = func() zerolog.Logger {
-			l := log.With().Str("rule-id", rule.RuleID)
+			l := log.With().Str("rule-id", r.RuleID)
 			if fragment.CommitSHA != "" {
 				l = l.Str("commit", fragment.CommitSHA)
 			}
@@ -252,7 +256,7 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.
 	)
 
 	// check if filepath or commit is allowed for this rule
-	for _, a := range rule.Allowlists {
+	for _, a := range r.Allowlists {
 		var (
 			isAllowed     bool
 			commitAllowed = a.CommitAllowed(fragment.CommitSHA)
@@ -290,30 +294,30 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.
 		}
 	}
 
-	if rule.Path != nil && rule.Regex == nil && len(encodedSegments) == 0 {
+	if r.Path != nil && r.Regex == nil && len(encodedSegments) == 0 {
 		// Path _only_ rule
-		if rule.Path.MatchString(fragment.FilePath) {
+		if r.Path.MatchString(fragment.FilePath) {
 			finding := report.Finding{
-				Description: rule.Description,
+				Description: r.Description,
 				File:        fragment.FilePath,
 				SymlinkFile: fragment.SymlinkFile,
-				RuleID:      rule.RuleID,
+				RuleID:      r.RuleID,
 				Match:       fmt.Sprintf("file detected: %s", fragment.FilePath),
-				Tags:        rule.Tags,
+				Tags:        r.Tags,
 			}
 			return append(findings, finding)
 		}
-	} else if rule.Path != nil {
+	} else if r.Path != nil {
 		// if path is set _and_ a regex is set, then we need to check both
 		// so if the path does not match, then we should return early and not
 		// consider the regex
-		if !rule.Path.MatchString(fragment.FilePath) {
+		if !r.Path.MatchString(fragment.FilePath) {
 			return findings
 		}
 	}
 
 	// if path only rule, skip content checks
-	if rule.Regex == nil {
+	if r.Regex == nil {
 		return findings
 	}
 
@@ -329,7 +333,7 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.
 	// use currentRaw instead of fragment.Raw since this represents the current
 	// decoding pass on the text
 MatchLoop:
-	for _, matchIndex := range rule.Regex.FindAllStringIndex(currentRaw, -1) {
+	for _, matchIndex := range r.Regex.FindAllStringIndex(currentRaw, -1) {
 		// Extract secret from match
 		secret := strings.Trim(currentRaw[matchIndex[0]:matchIndex[1]], "\n")
 
@@ -363,17 +367,17 @@ MatchLoop:
 		}
 
 		finding := report.Finding{
-			Description: rule.Description,
+			Description: r.Description,
 			File:        fragment.FilePath,
 			SymlinkFile: fragment.SymlinkFile,
-			RuleID:      rule.RuleID,
+			RuleID:      r.RuleID,
 			StartLine:   loc.startLine,
 			EndLine:     loc.endLine,
 			StartColumn: loc.startColumn,
 			EndColumn:   loc.endColumn,
 			Secret:      secret,
 			Match:       secret,
-			Tags:        append(rule.Tags, metaTags...),
+			Tags:        append(r.Tags, metaTags...),
 			Line:        fragment.Raw[loc.startLineIndex:loc.endLineIndex],
 		}
 
@@ -387,14 +391,14 @@ MatchLoop:
 
 		// Set the value of |secret|, if the pattern contains at least one capture group.
 		// (The first element is the full match, hence we check >= 2.)
-		groups := rule.Regex.FindStringSubmatch(finding.Secret)
+		groups := r.Regex.FindStringSubmatch(finding.Secret)
 		if len(groups) >= 2 {
-			if rule.SecretGroup > 0 {
-				if len(groups) <= rule.SecretGroup {
+			if r.SecretGroup > 0 {
+				if len(groups) <= r.SecretGroup {
 					// Config validation should prevent this
 					continue
 				}
-				finding.Secret = groups[rule.SecretGroup]
+				finding.Secret = groups[r.SecretGroup]
 			} else {
 				// If |secretGroup| is not set, we will use the first suitable capture group.
 				if len(groups) == 2 {
@@ -434,7 +438,7 @@ MatchLoop:
 		}
 
 		// check if the result matches any of the rule allowlists.
-		for _, a := range rule.Allowlists {
+		for _, a := range r.Allowlists {
 			allowlistTarget := finding.Secret
 			switch a.RegexTarget {
 			case "match":
@@ -485,8 +489,8 @@ MatchLoop:
 		// check entropy
 		entropy := shannonEntropy(finding.Secret)
 		finding.Entropy = float32(entropy)
-		if rule.Entropy != 0.0 {
-			if entropy <= rule.Entropy {
+		if r.Entropy != 0.0 {
+			if entropy <= r.Entropy {
 				// entropy is too low, skip this finding
 				continue
 			}
@@ -497,7 +501,7 @@ MatchLoop:
 			// What this bit of code does is check if the ruleid is prepended with "generic" and enforces the
 			// secret contains both digits and alphabetical characters.
 			// TODO: this should be replaced with stop words
-			if strings.HasPrefix(rule.RuleID, "generic") {
+			if strings.HasPrefix(r.RuleID, "generic") {
 				if !containsDigit(finding.Secret) {
 					continue
 				}
diff --git a/go.mod b/go.mod
index 18ef9ef..1c57794 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ toolchain go1.22.5
 
 require (
 	github.com/BobuSumisu/aho-corasick v1.0.3
+	github.com/Masterminds/sprig v2.22.0+incompatible
 	github.com/charmbracelet/lipgloss v0.5.0
 	github.com/fatih/semgroup v1.2.0
 	github.com/gitleaks/go-gitdiff v0.9.1
@@ -19,13 +20,21 @@ require (
 )
 
 require (
+	github.com/Masterminds/goutils v1.1.1 // indirect
+	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/huandu/xstrings v1.5.0 // indirect
+	github.com/imdario/mergo v0.3.16 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/mattn/go-runewidth v0.0.14 // indirect
+	github.com/mitchellh/copystructure v1.2.0 // indirect
+	github.com/mitchellh/reflectwalk v1.0.2 // indirect
 	github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 // indirect
 	github.com/muesli/termenv v0.15.1 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
+	golang.org/x/crypto v0.29.0 // indirect
 )
 
 require (
@@ -43,10 +52,10 @@ require (
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/subosito/gotenv v1.2.0 // indirect
-	golang.org/x/sync v0.8.0 // indirect
-	golang.org/x/sys v0.6.0 // indirect
-	golang.org/x/text v0.3.8 // indirect
+	golang.org/x/sync v0.9.0 // indirect
+	golang.org/x/sys v0.27.0 // indirect
+	golang.org/x/text v0.20.0 // indirect
 	gopkg.in/ini.v1 v1.62.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
-	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 7a291f8..a80d537 100644
--- a/go.sum
+++ b/go.sum
@@ -41,6 +41,12 @@ github.com/BobuSumisu/aho-corasick v1.0.3 h1:uuf+JHwU9CHP2Vx+wAy6jcksJThhJS9ehR8
 github.com/BobuSumisu/aho-corasick v1.0.3/go.mod h1:hm4jLcvZKI2vRF2WDU1N4p/jpWtpOzp3nLmi9AzX/XE=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
+github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
+github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
+github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
+github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60=
+github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
 github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@@ -146,6 +152,8 @@ github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
@@ -174,8 +182,12 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
 github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
 github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
+github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
+github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
+github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -208,6 +220,8 @@ github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWV
 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
 github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
@@ -216,6 +230,8 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
 github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
@@ -299,6 +315,8 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
+golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
+golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -396,8 +414,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
-golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
+golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -443,8 +461,8 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
+golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -454,8 +472,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -629,8 +647,9 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/report/csv.go b/report/csv.go
index 86d041d..32a5246 100644
--- a/report/csv.go
+++ b/report/csv.go
@@ -7,12 +7,16 @@ import (
 	"strings"
 )
 
-// writeCsv writes the list of findings to a writeCloser.
-func writeCsv(f []Finding, w io.WriteCloser) error {
-	if len(f) == 0 {
+type CsvReporter struct {
+}
+
+var _ Reporter = (*CsvReporter)(nil)
+
+func (r *CsvReporter) Write(w io.WriteCloser, findings []Finding) error {
+	if len(findings) == 0 {
 		return nil
 	}
-	defer w.Close()
+
 	cw := csv.NewWriter(w)
 	err := cw.Write([]string{"RuleID",
 		"Commit",
@@ -34,7 +38,7 @@ func writeCsv(f []Finding, w io.WriteCloser) error {
 	if err != nil {
 		return err
 	}
-	for _, f := range f {
+	for _, f := range findings {
 		err = cw.Write([]string{f.RuleID,
 			f.Commit,
 			f.File,
diff --git a/report/finding.go b/report/finding.go
index 8dc5747..d3ff741 100644
--- a/report/finding.go
+++ b/report/finding.go
@@ -14,7 +14,7 @@ type Finding struct {
 	StartColumn int
 	EndColumn   int
 
-	Line string `json:",omitempty"`
+	Line string `json:"-"`
 
 	Match string
 
diff --git a/report/json.go b/report/json.go
index 0e2d6d4..867c98a 100644
--- a/report/json.go
+++ b/report/json.go
@@ -5,23 +5,12 @@ import (
 	"io"
 )
 
-func writeJson(findings []Finding, w io.WriteCloser) error {
-	if len(findings) == 0 {
-		findings = []Finding{}
-	}
-	for i := range findings {
-		// Remove `Line` from JSON output
-		findings[i].Line = ""
-	}
-	return writeJsonExtra(findings, w)
+type JsonReporter struct {
 }
 
-func writeJsonExtra(findings []Finding, w io.WriteCloser) error {
-	if len(findings) == 0 {
-		findings = []Finding{}
-	}
-	defer w.Close()
+var _ Reporter = (*JsonReporter)(nil)
 
+func (t *JsonReporter) Write(w io.WriteCloser, findings []Finding) error {
 	encoder := json.NewEncoder(w)
 	encoder.SetIndent("", " ")
 	return encoder.Encode(findings)
diff --git a/report/junit.go b/report/junit.go
index 6cc82ad..e9ac57b 100644
--- a/report/junit.go
+++ b/report/junit.go
@@ -8,7 +8,12 @@ import (
 	"strconv"
 )
 
-func writeJunit(findings []Finding, w io.WriteCloser) error {
+type JunitReporter struct {
+}
+
+var _ Reporter = (*JunitReporter)(nil)
+
+func (r *JunitReporter) Write(w io.WriteCloser, findings []Finding) error {
 	testSuites := TestSuites{
 		TestSuites: getTestSuites(findings),
 	}
diff --git a/report/report.go b/report/report.go
index 77b34d6..db6cd22 100644
--- a/report/report.go
+++ b/report/report.go
@@ -2,32 +2,15 @@ package report
 
 import (
 	"io"
-	"strings"
-
-	"github.com/zricethezav/gitleaks/v8/config"
 )
 
 const (
 	// https://cwe.mitre.org/data/definitions/798.html
-	CWE             = "CWE-798"
-	CWE_DESCRIPTION = "Use of Hard-coded Credentials"
+	CWE              = "CWE-798"
+	CWE_DESCRIPTION  = "Use of Hard-coded Credentials"
+	StdoutReportPath = "-"
 )
 
-func Write(findings []Finding, cfg config.Config, ext string, report io.WriteCloser) error {
-	var err error
-	ext = strings.ToLower(ext)
-	switch ext {
-	case ".json", "json":
-		err = writeJson(findings, report)
-	case ".jsonextra", "jsonextra":
-		err = writeJsonExtra(findings, report)
-	case ".csv", "csv":
-		err = writeCsv(findings, report)
-	case ".xml", "junit":
-		err = writeJunit(findings, report)
-	case ".sarif", "sarif":
-		err = writeSarif(cfg, findings, report)
-	}
-
-	return err
+type Reporter interface {
+	Write(w io.WriteCloser, findings []Finding) error
 }
diff --git a/report/sarif.go b/report/sarif.go
index 59b4b37..e86cd7e 100644
--- a/report/sarif.go
+++ b/report/sarif.go
@@ -8,35 +8,40 @@ import (
 	"github.com/zricethezav/gitleaks/v8/config"
 )
 
-func writeSarif(cfg config.Config, findings []Finding, w io.WriteCloser) error {
+type SarifReporter struct {
+	OrderedRules []config.Rule
+}
+
+var _ Reporter = (*SarifReporter)(nil)
+
+func (r *SarifReporter) Write(w io.WriteCloser, findings []Finding) error {
 	sarif := Sarif{
 		Schema:  "https://json.schemastore.org/sarif-2.1.0.json",
 		Version: "2.1.0",
-		Runs:    getRuns(cfg, findings),
+		Runs:    r.getRuns(findings),
 	}
-	defer w.Close()
 
 	encoder := json.NewEncoder(w)
 	encoder.SetIndent("", " ")
 	return encoder.Encode(sarif)
 }
 
-func getRuns(cfg config.Config, findings []Finding) []Runs {
+func (r *SarifReporter) getRuns(findings []Finding) []Runs {
 	return []Runs{
 		{
-			Tool:    getTool(cfg),
+			Tool:    r.getTool(),
 			Results: getResults(findings),
 		},
 	}
 }
 
-func getTool(cfg config.Config) Tool {
+func (r *SarifReporter) getTool() Tool {
 	tool := Tool{
 		Driver: Driver{
 			Name:            driver,
 			SemanticVersion: version,
 			InformationUri:  "https://github.com/gitleaks/gitleaks",
-			Rules:           getRules(cfg),
+			Rules:           r.getRules(),
 		},
 	}
 
@@ -52,10 +57,10 @@ func hasEmptyRules(tool Tool) bool {
 	return len(tool.Driver.Rules) == 0
 }
 
-func getRules(cfg config.Config) []Rules {
+func (r *SarifReporter) getRules() []Rules {
 	// TODO	for _, rule := range cfg.Rules {
 	var rules []Rules
-	for _, rule := range cfg.GetOrderedRules() {
+	for _, rule := range r.OrderedRules {
 		rules = append(rules, Rules{
 			ID: rule.RuleID,
 			Description: ShortDescription{
diff --git a/report/template.go b/report/template.go
new file mode 100644
index 0000000..a78042a
--- /dev/null
+++ b/report/template.go
@@ -0,0 +1,46 @@
+package report
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"text/template"
+
+	"github.com/Masterminds/sprig"
+)
+
+type TemplateReporter struct {
+	template *template.Template
+}
+
+var _ Reporter = (*TemplateReporter)(nil)
+
+func NewTemplateReporter(templatePath string) (*TemplateReporter, error) {
+	if templatePath == "" {
+		return nil, fmt.Errorf("template path cannot be empty")
+	}
+
+	file, err := os.ReadFile(templatePath)
+	if err != nil {
+		return nil, fmt.Errorf("error reading file: %w", err)
+	}
+	templateText := string(file)
+
+	// TODO: Add helper functions like escaping for JSON, XML, etc.
+	t := template.New("custom")
+	t = t.Funcs(sprig.TxtFuncMap())
+	t, err = t.Parse(templateText)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing file: %w", err)
+	}
+	return &TemplateReporter{template: t}, nil
+}
+
+// writeTemplate renders the findings using the user-provided template.
+// https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go
+func (t *TemplateReporter) Write(w io.WriteCloser, findings []Finding) error {
+	if err := t.template.Execute(w, findings); err != nil {
+		return err
+	}
+	return nil
+}


Test patch:
diff --git a/config/config_test.go b/config/config_test.go
index 8a336a7..5fff1e2 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -2,10 +2,11 @@ package config
 
 import (
 	"fmt"
-	"github.com/google/go-cmp/cmp"
 	"regexp"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
+
 	"github.com/spf13/viper"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
diff --git a/detect/detect_test.go b/detect/detect_test.go
index e2d7611..2432e9d 100644
--- a/detect/detect_test.go
+++ b/detect/detect_test.go
@@ -2,12 +2,13 @@ package detect
 
 import (
 	"fmt"
-	"github.com/google/go-cmp/cmp"
 	"os"
 	"path/filepath"
 	"regexp"
 	"testing"
 
+	"github.com/google/go-cmp/cmp"
+
 	"github.com/spf13/viper"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
diff --git a/report/csv_test.go b/report/csv_test.go
index ac2e43b..9ad0322 100644
--- a/report/csv_test.go
+++ b/report/csv_test.go
@@ -48,22 +48,26 @@ func TestWriteCSV(t *testing.T) {
 		},
 	}
 
+	reporter := CsvReporter{}
 	for _, test := range tests {
 		t.Run(test.testReportName, func(t *testing.T) {
 			tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+".csv"))
 			require.NoError(t, err)
-			err = writeCsv(test.findings, tmpfile)
+
+			err = reporter.Write(tmpfile, test.findings)
 			require.NoError(t, err)
 			assert.FileExists(t, tmpfile.Name())
+
 			got, err := os.ReadFile(tmpfile.Name())
 			require.NoError(t, err)
 			if test.wantEmpty {
 				assert.Empty(t, got)
 				return
 			}
+
 			want, err := os.ReadFile(test.expected)
 			require.NoError(t, err)
-			assert.Equal(t, want, got)
+			assert.Equal(t, string(want), string(got))
 		})
 	}
 }
diff --git a/report/json_test.go b/report/json_test.go
index 1bb3ec0..863b204 100644
--- a/report/json_test.go
+++ b/report/json_test.go
@@ -13,7 +13,6 @@ var simpleFinding = Finding{
 	Description: "",
 	RuleID:      "test-rule",
 	Match:       "line containing secret",
-	Line:        "whole line containing secret",
 	Secret:      "a secret",
 	StartLine:   1,
 	EndLine:     2,
@@ -49,11 +48,12 @@ func TestWriteJSON(t *testing.T) {
 			findings:       []Finding{}},
 	}
 
+	reporter := JsonReporter{}
 	for _, test := range tests {
 		t.Run(test.testReportName, func(t *testing.T) {
 			tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+".json"))
 			require.NoError(t, err)
-			err = writeJson(test.findings, tmpfile)
+			err = reporter.Write(tmpfile, test.findings)
 			require.NoError(t, err)
 			assert.FileExists(t, tmpfile.Name())
 			got, err := os.ReadFile(tmpfile.Name())
@@ -64,27 +64,7 @@ func TestWriteJSON(t *testing.T) {
 			}
 			want, err := os.ReadFile(test.expected)
 			require.NoError(t, err)
-			assert.Equal(t, want, got)
+			assert.Equal(t, string(want), string(got))
 		})
 	}
 }
-
-func TestWriteJSONExtra(t *testing.T) {
-	findings := []Finding{
-		simpleFinding,
-	}
-	expected := filepath.Join(expectPath, "report", "json_extra_simple.json")
-
-	tmpfile, err := os.Create(filepath.Join(t.TempDir(), "simple_extra.json"))
-	require.NoError(t, err)
-
-	err = writeJsonExtra(findings, tmpfile)
-	require.NoError(t, err)
-	assert.FileExists(t, tmpfile.Name())
-
-	got, err := os.ReadFile(tmpfile.Name())
-	require.NoError(t, err)
-	want, err := os.ReadFile(expected)
-	require.NoError(t, err)
-	assert.Equal(t, want, got)
-}
diff --git a/report/junit_test.go b/report/junit_test.go
index 02983d4..96e724b 100644
--- a/report/junit_test.go
+++ b/report/junit_test.go
@@ -65,20 +65,24 @@ func TestWriteJunit(t *testing.T) {
 		},
 	}
 
+	reporter := JunitReporter{}
 	for _, test := range tests {
 		tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+".xml"))
 		require.NoError(t, err)
-		err = writeJunit(test.findings, tmpfile)
+
+		err = reporter.Write(tmpfile, test.findings)
 		require.NoError(t, err)
 		assert.FileExists(t, tmpfile.Name())
+
 		got, err := os.ReadFile(tmpfile.Name())
 		require.NoError(t, err)
 		if test.wantEmpty {
 			assert.Empty(t, got)
 			return
 		}
+
 		want, err := os.ReadFile(test.expected)
 		require.NoError(t, err)
-		assert.Equal(t, want, got)
+		assert.Equal(t, string(want), string(got))
 	}
 }
diff --git a/report/report_test.go b/report/report_test.go
index 78de056..7b9da4d 100644
--- a/report/report_test.go
+++ b/report/report_test.go
@@ -6,110 +6,31 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-
-	"github.com/zricethezav/gitleaks/v8/config"
 )
 
-const (
-	expectPath = "../testdata/expected/"
-)
+const expectPath = "../testdata/expected/"
+const configPath = "../testdata/config/"
+const templatePath = "../testdata/report/"
 
-func TestReport(t *testing.T) {
-	tests := []struct {
-		findings  []Finding
-		ext       string
-		wantEmpty bool
-	}{
-		{
-			ext: "json",
-			findings: []Finding{
-				{
-					RuleID: "test-rule",
-				},
-			},
-		},
-		{
-			ext: ".json",
-			findings: []Finding{
-				{
-					RuleID: "test-rule",
-				},
-			},
-		},
-		{
-			ext: ".jsonj",
-			findings: []Finding{
-				{
-					RuleID: "test-rule",
-				},
-			},
-			wantEmpty: true,
-		},
-		{
-			ext: ".csv",
-			findings: []Finding{
-				{
-					RuleID: "test-rule",
-				},
-			},
-		},
-		{
-			ext: "csv",
-			findings: []Finding{
-				{
-					RuleID: "test-rule",
-				},
-			},
-		},
-		{
-			ext: "CSV",
-			findings: []Finding{
-				{
-					RuleID: "test-rule",
-				},
-			},
-		},
-		{
-			ext: ".xml",
-			findings: []Finding{
-				{
-					RuleID: "test-rule",
-				},
-			},
-		},
+func TestWriteStdout(t *testing.T) {
+	// Arrange
+	reporter := JsonReporter{}
+	buf := testWriter{
+		bytes.NewBuffer(nil),
+	}
+	findings := []Finding{
 		{
-			ext: "junit",
-			findings: []Finding{
-				{
-					RuleID: "test-rule",
-				},
-			},
+			RuleID: "test-rule",
 		},
-		// {
-		// 	ext: "SARIF",
-		// 	findings: []Finding{
-		// 		{
-		// 			RuleID: "test-rule",
-		// 		},
-		// 	},
-		// },
 	}
 
-	for _, test := range tests {
-		t.Run(test.ext, func(t *testing.T) {
-			buf := testWriter{
-				bytes.NewBuffer(nil),
-			}
-			err := Write(test.findings, config.Config{}, test.ext, buf)
-			require.NoError(t, err)
-			got := buf.Bytes()
-			if test.wantEmpty {
-				assert.Empty(t, got)
-				return
-			}
-			assert.NotEmpty(t, got)
-		})
-	}
+	// Act
+	err := reporter.Write(buf, findings)
+	require.NoError(t, err)
+	got := buf.Bytes()
+
+	// Assert
+	assert.NotEmpty(t, got)
 }
 
 type testWriter struct {
diff --git a/report/sarif_test.go b/report/sarif_test.go
index 01026d7..68aa1c2 100644
--- a/report/sarif_test.go
+++ b/report/sarif_test.go
@@ -5,15 +5,12 @@ import (
 	"path/filepath"
 	"testing"
 
-	"github.com/spf13/viper"
+	"github.com/zricethezav/gitleaks/v8/config"
+
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-
-	"github.com/zricethezav/gitleaks/v8/config"
 )
 
-const configPath = "../testdata/config/"
-
 func TestWriteSarif(t *testing.T) {
 	tests := []struct {
 		findings       []Finding
@@ -29,8 +26,8 @@ func TestWriteSarif(t *testing.T) {
 			findings: []Finding{
 				{
 
-					Description: "A test rule",
 					RuleID:      "test-rule",
+					Description: "A test rule",
 					Match:       "line containing secret",
 					Secret:      "a secret",
 					StartLine:   1,
@@ -52,28 +49,31 @@ func TestWriteSarif(t *testing.T) {
 		t.Run(test.cfgName, func(t *testing.T) {
 			tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+".json"))
 			require.NoError(t, err)
-			viper.Reset()
-			viper.AddConfigPath(configPath)
-			viper.SetConfigName(test.cfgName)
-			viper.SetConfigType("toml")
-			err = viper.ReadInConfig()
-			require.NoError(t, err)
 
-			var vc config.ViperConfig
-			err = viper.Unmarshal(&vc)
-			require.NoError(t, err)
-
-			cfg, err := vc.Translate()
-			require.NoError(t, err)
-			err = writeSarif(cfg, test.findings, tmpfile)
+			reporter := SarifReporter{
+				OrderedRules: []config.Rule{
+					{
+						RuleID:      "aws-access-key",
+						Description: "AWS Access Key",
+					},
+					{
+						RuleID:      "pypi",
+						Description: "PyPI upload token",
+					},
+				},
+			}
+			err = reporter.Write(tmpfile, test.findings)
 			require.NoError(t, err)
 			assert.FileExists(t, tmpfile.Name())
+
 			got, err := os.ReadFile(tmpfile.Name())
 			require.NoError(t, err)
+
 			if test.wantEmpty {
 				assert.Empty(t, got)
 				return
 			}
+
 			want, err := os.ReadFile(test.expected)
 			require.NoError(t, err)
 			assert.Equal(t, string(want), string(got))
diff --git a/report/template_test.go b/report/template_test.go
new file mode 100644
index 0000000..7a0ec3b
--- /dev/null
+++ b/report/template_test.go
@@ -0,0 +1,94 @@
+package report
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWriteTemplate(t *testing.T) {
+	tests := []struct {
+		findings       []Finding
+		testReportName string
+		expected       string
+		wantEmpty      bool
+	}{
+		{
+			testReportName: "markdown",
+			expected:       filepath.Join(expectPath, "report", "template_markdown.md"),
+			findings: []Finding{
+				{
+
+					RuleID:      "test-rule",
+					Description: "A test rule",
+					Match:       "line containing secret",
+					Secret:      "a secret",
+					StartLine:   1,
+					EndLine:     2,
+					StartColumn: 1,
+					EndColumn:   2,
+					Message:     "opps",
+					File:        "auth.py",
+					Commit:      "0000000000000000",
+					Author:      "John Doe",
+					Email:       "johndoe@gmail.com",
+					Date:        "10-19-2003",
+					Tags:        []string{"tag1", "tag2", "tag3"},
+				},
+			},
+		},
+		{
+			testReportName: "jsonextra",
+			expected:       filepath.Join(expectPath, "report", "template_jsonextra.json"),
+			findings: []Finding{
+				{
+
+					RuleID:      "test-rule",
+					Description: "A test rule",
+					Line:        "whole line containing secret",
+					Match:       "line containing secret",
+					Secret:      "a secret",
+					StartLine:   1,
+					EndLine:     2,
+					StartColumn: 1,
+					EndColumn:   2,
+					Message:     "opps",
+					File:        "auth.py",
+					Commit:      "0000000000000000",
+					Author:      "John Doe",
+					Email:       "johndoe@gmail.com",
+					Date:        "10-19-2003",
+					Tags:        []string{"tag1", "tag2", "tag3"},
+				},
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.testReportName, func(t *testing.T) {
+			reporter, err := NewTemplateReporter(templatePath + test.testReportName + ".tmpl")
+			require.NoError(t, err)
+
+			tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+filepath.Ext(test.expected)))
+			require.NoError(t, err)
+
+			err = reporter.Write(tmpfile, test.findings)
+			require.NoError(t, err)
+			assert.FileExists(t, tmpfile.Name())
+
+			got, err := os.ReadFile(tmpfile.Name())
+			require.NoError(t, err)
+			if test.wantEmpty {
+				assert.Empty(t, got)
+				return
+			}
+
+			want, err := os.ReadFile(test.expected)
+			require.NoError(t, err)
+			assert.Equal(t, string(want), string(got))
+		})
+	}
+}
diff --git a/testdata/expected/report/json_extra_simple.json b/testdata/expected/report/json_extra_simple.json
deleted file mode 100644
index 55d995c..0000000
--- a/testdata/expected/report/json_extra_simple.json
+++ /dev/null
@@ -1,23 +0,0 @@
-[
- {
-  "Description": "",
-  "StartLine": 1,
-  "EndLine": 2,
-  "StartColumn": 1,
-  "EndColumn": 2,
-  "Line": "whole line containing secret",
-  "Match": "line containing secret",
-  "Secret": "a secret",
-  "File": "auth.py",
-  "SymlinkFile": "",
-  "Commit": "0000000000000000",
-  "Entropy": 0,
-  "Author": "John Doe",
-  "Email": "johndoe@gmail.com",
-  "Date": "10-19-2003",
-  "Message": "opps",
-  "Tags": [],
-  "RuleID": "test-rule",
-  "Fingerprint": ""
- }
-]
diff --git a/testdata/expected/report/sarif_simple.sarif b/testdata/expected/report/sarif_simple.sarif
index 61adf03..8197de3 100644
--- a/testdata/expected/report/sarif_simple.sarif
+++ b/testdata/expected/report/sarif_simple.sarif
@@ -15,210 +15,6 @@
         "text": "AWS Access Key"
        }
       },
-      {
-       "id": "aws-secret-key",
-       "shortDescription": {
-        "text": "AWS Secret Key"
-       }
-      },
-      {
-       "id": "aws-mws-key",
-       "shortDescription": {
-        "text": "AWS MWS key"
-       }
-      },
-      {
-       "id": "facebook-secret-key",
-       "shortDescription": {
-        "text": "Facebook Secret Key"
-       }
-      },
-      {
-       "id": "facebook-client-id",
-       "shortDescription": {
-        "text": "Facebook Client ID"
-       }
-      },
-      {
-       "id": "twitter-secret-key",
-       "shortDescription": {
-        "text": "Twitter Secret Key"
-       }
-      },
-      {
-       "id": "twitter-client-id",
-       "shortDescription": {
-        "text": "Twitter Client ID"
-       }
-      },
-      {
-       "id": "github-pat",
-       "shortDescription": {
-        "text": "Github Personal Access Token"
-       }
-      },
-      {
-       "id": "github-oauth",
-       "shortDescription": {
-        "text": "Github OAuth Access Token"
-       }
-      },
-      {
-       "id": "github-app",
-       "shortDescription": {
-        "text": "Github App Token"
-       }
-      },
-      {
-       "id": "github-refresh",
-       "shortDescription": {
-        "text": "Github Refresh Token"
-       }
-      },
-      {
-       "id": "linkedin-client",
-       "shortDescription": {
-        "text": "LinkedIn Client ID"
-       }
-      },
-      {
-       "id": "linkedin-secret",
-       "shortDescription": {
-        "text": "LinkedIn Secret Key"
-       }
-      },
-      {
-       "id": "slack",
-       "shortDescription": {
-        "text": "Slack"
-       }
-      },
-      {
-       "id": "apkey",
-       "shortDescription": {
-        "text": "Asymmetric Private Key"
-       }
-      },
-      {
-       "id": "google",
-       "shortDescription": {
-        "text": "Google (GCP) Service Account"
-       }
-      },
-      {
-       "id": "google",
-       "shortDescription": {
-        "text": "Google (GCP) Service Account"
-       }
-      },
-      {
-       "id": "heroku",
-       "shortDescription": {
-        "text": "Heroku API key"
-       }
-      },
-      {
-       "id": "mailchimp",
-       "shortDescription": {
-        "text": "MailChimp API key"
-       }
-      },
-      {
-       "id": "mailgun",
-       "shortDescription": {
-        "text": "Mailgun API key"
-       }
-      },
-      {
-       "id": "paypal",
-       "shortDescription": {
-        "text": "PayPal Braintree access token"
-       }
-      },
-      {
-       "id": "piacatic",
-       "shortDescription": {
-        "text": "Picatic API key"
-       }
-      },
-      {
-       "id": "sendgrid",
-       "shortDescription": {
-        "text": "SendGrid API Key"
-       }
-      },
-      {
-       "id": "sidekiq-secret",
-       "shortDescription": {
-        "text": "Sidekiq Secret"
-       }
-      },
-      {
-       "id": "sidekiq-sensitive-url",
-       "shortDescription": {
-        "text": "Sidekiq Sensitive URL"
-       }
-      },
-      {
-       "id": "slack-webhook",
-       "shortDescription": {
-        "text": "Slack Webhook"
-       }
-      },
-      {
-       "id": "stripe",
-       "shortDescription": {
-        "text": "Stripe API key"
-       }
-      },
-      {
-       "id": "square",
-       "shortDescription": {
-        "text": "Square access token"
-       }
-      },
-      {
-       "id": "square-oauth",
-       "shortDescription": {
-        "text": "Square OAuth secret"
-       }
-      },
-      {
-       "id": "twilio",
-       "shortDescription": {
-        "text": "Twilio API key"
-       }
-      },
-      {
-       "id": "dynatrace",
-       "shortDescription": {
-        "text": "Dynatrace ttoken"
-       }
-      },
-      {
-       "id": "shopify",
-       "shortDescription": {
-        "text": "Shopify shared secret"
-       }
-      },
-      {
-       "id": "shopify-access",
-       "shortDescription": {
-        "text": "Shopify access token"
-       }
-      },
-      {
-       "id": "shopify-custom",
-       "shortDescription": {
-        "text": "Shopify custom app access token"
-       }
-      },
-      {
-       "id": "shopify-private",
-       "shortDescription": {
-        "text": "Shopify private app access token"
-       }
-      },
       {
        "id": "pypi",
        "shortDescription": {
diff --git a/testdata/expected/report/template_jsonextra.json b/testdata/expected/report/template_jsonextra.json
new file mode 100644
index 0000000..93fb746
--- /dev/null
+++ b/testdata/expected/report/template_jsonextra.json
@@ -0,0 +1,23 @@
+[
+    {
+        "Description": "A test rule",
+        "StartLine": 1,
+        "EndLine": 2,
+        "StartColumn": 1,
+        "EndColumn": 2,
+        "Line": "whole line containing secret",
+        "Match": "line containing secret",
+        "Secret": "a secret",
+        "File": "auth.py",
+        "SymlinkFile": "",
+        "Commit": "0000000000000000",
+        "Entropy": 0,
+        "Author": "John Doe",
+        "Email": "johndoe@gmail.com",
+        "Date": "10-19-2003",
+        "Message": "opps",
+        "Tags": ["tag1","tag2","tag3"],
+        "RuleID": "test-rule",
+        "Fingerprint": ""
+    }
+]
diff --git a/testdata/expected/report/template_markdown.md b/testdata/expected/report/template_markdown.md
new file mode 100644
index 0000000..4f791b2
--- /dev/null
+++ b/testdata/expected/report/template_markdown.md
@@ -0,0 +1,3 @@
+| File | Line | Secret |
+|:-----|-----:|--------|
+| auth.py | 1 | "a secret" |
diff --git a/testdata/report/jsonextra.tmpl b/testdata/report/jsonextra.tmpl
new file mode 100644
index 0000000..33f38d3
--- /dev/null
+++ b/testdata/report/jsonextra.tmpl
@@ -0,0 +1,25 @@
+[{{ $lastFinding := (sub (len . ) 1) }}
+{{- range $i, $finding := . }}{{with $finding}}
+    {
+        "Description": {{ quote .Description }},
+        "StartLine": {{ .StartLine }},
+        "EndLine": {{ .EndLine }},
+        "StartColumn": {{ .StartColumn }},
+        "EndColumn": {{ .EndColumn }},
+        "Line": {{ quote .Line }},
+        "Match": {{ quote .Match }},
+        "Secret": {{ quote .Secret }},
+        "File": "{{ .File }}",
+        "SymlinkFile": {{ quote .SymlinkFile }},
+        "Commit": {{ quote .Commit }},
+        "Entropy": {{ .Entropy }},
+        "Author": {{ quote .Author }},
+        "Email": {{ quote .Email }},
+        "Date": {{ quote .Date }},
+        "Message": {{ quote .Message }},
+        "Tags": [{{ $lastTag := (sub (len .Tags ) 1) }}{{ range $j, $tag := .Tags }}{{ quote . }}{{ if ne $j $lastTag }},{{ end }}{{ end }}],
+        "RuleID": {{ quote .RuleID }},
+        "Fingerprint": {{ quote .Fingerprint }}
+    }{{ if ne $i $lastFinding }},{{ end }}
+{{- end}}{{ end }}
+]
diff --git a/testdata/report/markdown.tmpl b/testdata/report/markdown.tmpl
new file mode 100644
index 0000000..9b064aa
--- /dev/null
+++ b/testdata/report/markdown.tmpl
@@ -0,0 +1,5 @@
+| File | Line | Secret |
+|:-----|-----:|--------|
+{{ range . -}}
+| {{ .File }} | {{ .StartLine }} | {{ quote .Secret }} |
+{{ end -}}